这是一个荔枝的目录:
1.为什么要有线程
(1)什么是进程
想要知道为什么要有线程,必须要先了解什么是进程。进程在我们的生活随处可见,比如我们点击桌面上QQ的快捷方式,其实就是打开了QQ的相应exe文件,就是创建了一个进程,我们打开任务管理器就可以发现:
(2)什么是线程
线程又被称为“轻量化进程”,可能线程是什么并不好说清楚,在这里打个比方大家就明白了:
如果说进程是工厂,那么线程就是工厂里面的各种流水线,他们共同占据着工厂里面的空间。
从这个例子就可以发现:进程>线程,更准确来说,一个进程包含多个线程,而这多个线程共同占据着这个进程的内存空间,并且每个进程至少有一个线程存在,即主线程(我们的java程序大多主线程是main线程)。
(3)线程和进程的区别
线程又被称作轻量化进程,他最大的特点也是区别就是创建和销毁都比进程要快速,调度也比进程快。有了这个优势,为什么要有线程这个问题也就显而易见了。
依然是工厂-流水线的例子
假设我斯某人买了一块地,开办了一家工厂,最近生意不错想扩大规模,我有两种方案:一是再买一块地,设备和原材料等等,二是继续压榨当前这块地,直接买入新的设备增加流水线的数量便可。如果我想快速,高效的扩展规模,由于买地,就相当于进程申请内存一样,这一步很消耗时间(要各种手续),无疑第二种是更好的选择。
这就是多线程所带来的好处
那么在只因算只因里,为了充分的压榨cpu的性能,我们不可能让cpu一次只执行一个程序,这样效率太低了,因此并发(同时进行)编程成了“刚需”,由于线程的优势,所以多线程比多进程更加高效。
当然线程和进程还有其他的区别:
1.同一个进程的不同线程,共用这个进程的空间;而不同的进程的内存空间不同
2.进程是系统资源分配的基本单位(分配内存),线程是系统调度(cpu执行的先后)的基本单位。
2.线程的使用
(1)线程的五种创建方式
a.外部类继承Thread类
classMyThreadextendsThread{@Overridepublicvoidrun(){
System.out.println("继承 Thread, 重写 run");}}publicclassCreateThreadDemo{publicstaticvoidmain(String[] args){
MyThread mt=newMyThread();
mt.start();}}
b.外部类实现Ruannable接口
classMyRunnableimplementsRunnable{@Overridepublicvoidrun(){
System.out.println(" 实现 Runnable, 重写 run");}}publicclassCreateThreadDemo{publicstaticvoidmain(String[] args){
MyRunnable mr=newMyRunnable();
Thread t2=newThread(mr);
t2.start();}
c.匿名内部类继承Thread
Thread t3=newThread(){@Overridepublicvoidrun(){
System.out.println("继承 Thread, 重写 run, 使用匿名内部类");}};
d.匿名内部类实现Ruannble接口
Thread t4=newThread(newRunnable(){@Overridepublicvoidrun(){
System.out.println("实现 Runnable, 重写 run, 使用匿名内部类");}});
t4.start();
e.Lambda表达式
Thread t5=newThread(()->{
System.out.println("Lambda");});
t5.start();
(2)线程的常用方法
下文涉及方法:currentThread()—静态方法
currentThread()获取当前所在线程,和this很像
a.中断线程:interrupt()
这个中断并不是真的把当前的线程掐断了,而是一个标记位。我们用以下代码测试:
package bokecode;publicclassInterruptDemo{publicstaticvoidmain(String[] args){
Thread thread=newThread(newRunnable(){@Overridepublicvoidrun(){while(!Thread.currentThread().isInterrupted()){
System.out.println("0000");try{
Thread.sleep(1000);}catch(InterruptedException e){
e.printStackTrace();}}}});
thread.start();
System.out.println("线程中断了!!!");
thread.interrupt();try{
thread.join();}catch(InterruptedException e){
e.printStackTrace();}
System.out.println("main线程结束了!!");}}
我们发现程序只是抛出了一个异常,而并没有真的被中断,依旧在输出0000。为什么呢,原因在这行代码
Thread.sleep(1000);
这个代码会在后面的线程休眠会讲到,线程出于休眠状态时不会被中断,而是会抛出一个异常,之后这个线程被提前唤醒。如果去掉该行代码,线程就会正常中断,当然这并不是主要解决手段,线程的中断主要还是看我们的代码决定,例如我们在catch这个异常后我们直接break中断循环,这是的interrupt()方法就像是一个标志。
b.测试当前线程是否被中断:interrupted() --静态方法
从源码看出他会调用获取当前线程的方法再判断是否被中断
c.测试这个线程是否被中断: isInterrupted()
由线程实例对象调用,功能和(2)一致,不同点在于:静态方法的(2)判断完之后会清楚中断标志,而(3)不会
d.等待线程死亡:join()
例:编写代码,我们创建一个线程A想要A打印1~1000,main线程打印1001到1999;
publicclassJoinDemo{publicstaticvoidmain(String[] args){
System.out.println("main线程开始了!!");
Thread thread=newThread(newRunnable(){@Overridepublicvoidrun(){for(int i=0;i<1000;i++){
System.out.println(i);}}});
thread.start();for(int i=1000;i<2000;i++){
System.out.println(i);}
System.out.println("main线程结束了!!");}}
可结果不尽人意:打印的非常乱
因为两个线程是同时进行的,想让main线程等A线程跑完再跑,我们只需要调用join方法便可:
在thread.start();
后插入thread。join();
e.join()的其他重载方法:join(long millis),join(long millis, int nanos)
参数代表等多少时间,不再是无限等待下去。
f.线程暂停(ms级):sleep(long millis),sleep(long millis, int nanos)
顾名思义,让线程休息一会,比如一个线程打印1~10,可通过调用此方法来实现每隔一秒打印一次
Thread.sleep(1000);第二个方法精度更高,精确到了纳秒级
i.有关当前线程的一些方法
currentThread():在哪个线程里调用就返回这个线程的实例
getId()和getName():前者是线程创建时就被赋予的编号,唯一存在,不可修改。
而后者可以修改setName(name),可以相同,相当于线程的姓名一样。
getState()返回线程的状态,线程的状态分类会在下文进行介绍
setDaemon(boolean);设置当前线程是守护(后台)线程还是前台线程。
我们创建线程时默认是前台线程(isDeamon()可以检测当前是什么线程)
守护线程:前台线程如果是坤坤,那么守护线程就是我们ikun,坤在ikun就在(除非ikun提前消失),坤无ikun也立即无,就相当于人在塔在的这种感觉。
守护我们最好的坤坤
前台线程:程序是否结束取决于前台线程是否执行完毕,main线程就是典型的前台线程
publicstaticvoidmain(String[] args){
Thread thread=newThread(()->{
System.out.println("守护线程执行");for(int i=0;i<100;i++){
System.out.println(i);}
System.out.println("守护线程执行完毕");});//thread.setDaemon(true);
thread.start();}
如果没有注释的那行代码,结果为thread线程正常结束:
反之程序在main线程执行完毕后,(因为该程序的所有前台线程都结束了)直接终止。
3.线程的七种状态(可通过getState方法查看)
(1)New
Thread对象被创建,但还未启动,比较稚嫩
也就是还未调用start方法,一个线程被真的创建,是取决于是否调用了start方法。
(2)Runnable(Running,Ready)
线程运行状态
Thread对象被创建,并且调用了start方法
(就绪)Ready状态,假设cpu一次只可跑一个线程,一次执行区间,两个线程抢这个cpu,那么没抢到cpu的那个线程就是Ready状态,抢到的那个就是Running状态
(3)Terminated
线程结束状态,线程所要执行的任务结束了
start方法执行结束
(4)Waiting
线程等待状态
调用了wait,join的无参方法
(5)Timed_waiting
线程超时等待状态
调用了sleep,join(带时间参),wait(带时间参)的方法,调用有关时间的方法都会进入这个状态
(6)Blocked
线程阻塞状态
这里涉及到线程锁
假设几个线程抢一把锁,没抢到的线程便会进入阻塞状态,直到抢到为止。
如果不清楚锁是什么,就可以这么理解或者直接看下文:
假如cpu是马桶,那么锁就是厕所的门,把线程比作人,每个人想在马桶上执行,就得抢这个门(上厕所不得锁门不是嘛),那么抢到这个门之后就把他锁上,这是其他人就进入了阻塞状态,直到抢到这个门为止。
线程状态间的转换图
4.线程安全与解决方案
既然是线程,那么逃不掉的,就是线程安全问题。常见的线程安全问题,比如多个线程同时读写(写就是修改)同一个数据,就导致可能你这边数据修改完了,而我没有及时收到信息,我这边还是用的旧数据,这就不对了,这就是修改共享数据导致的问题。所以,线程安全问题本质上就是内存的安全问题。那么为了解决这些问题,java提供了一些手段来帮助我们程序猿简化代码,便于我们保证线程安全。
(0)线程不安全的原因
a.原子性
由于cpu的调度是不确定了,所以如果一个操作不是原子性,很容易导致被另一个线程的突然插入而导致线程安全问题。
那么原子性是什么呢?举个例子n++;
一个看似简单的自增操作,其实暗藏玄鲲,它并不是原子性的操作,大致分为下面三个步骤:
首先从内存中取n的值于寄存器中
其次对寄存器的值进行++操作
最后,把寄存器写回内存
如果两个线程都执行n++,很可能出现,第一步取的值都是旧数据,使得最后n只++的一次的效果。
线程加锁的第一个例子就是这个原因
b.内存可见性
这个在下文的volatile有说。
c.代码顺序性
这个在下文的volatile也有说,即指令重排序。
(1)synchronized
synchronized是java提供的一个关键字,通过它对线程进行加锁,可以用来保证一个数据被修改的时候,同一时间有且至多只有一个数据可以修改。其他线程想要修改就必须等待获取锁的哪个线程执行完。就相当于人为的把我们的代码变成了原子性的操作。
a.线程加锁
线程加锁,也就是synchronized的作用,通过修饰代码块或者方法,来形成加锁的效果。
举个通俗的例子,张三,李四,王五三个人合租,但现在只有一间浴室,张三,李四,王五都打算去洗澡,由于3个人都是直的,所以这时候就看谁跑的快了,如果张三抢到了浴室,就把门锁上了,这个锁门就是加锁,这时其他人就进入阻塞状态,直到张三出来。这时,不管张三在里面干什么,哪怕是睡觉(sleep()方法),另外两个人都得在外面等
具体使用:
现在我们举一个计数器的例子:
int count=0;publicvoidincrease(){
count++;}publicstaticvoidmain(String[] args)throws InterruptedException {
ThreadStateDemo tsd=newThreadStateDemo();
Thread t1=newThread(()->{for(int i=0;i<10000;i++){
tsd.increase();}});
Thread t2=newThread(()->{for(int i=0;i<10000;i++){
tsd.increase();}});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("最后count的结果为:"+tsd.count);}
按理说结果应该为20000,但实际上只有14684,而且每次执行的都不一样,这就是典型的线程安全问题
那么解决方法也很简单,就是加上synchronized便可
修饰方法:同步方法
和上图一样,只需在要加锁的方法前加上synchronized修饰即可
修饰代码块:同步代码块
修饰代码块,主要是缩小锁范围,因为一个方法里面不是所有的地方都需要锁
括号里可以不是this,只要保证不同线程调用这个方法的时候,括号里面的对象都是同一个就可以,这也是java加锁比较特殊的地方。
b.线程死锁
当然加锁也不能随便加,不能因为锁好用就到处加,因为加锁也是一种资源消耗,每次加锁释放锁都是要额外消耗时间的,如果操作不当更会造成线程死锁这个大问题。
什么是线程死锁?
死锁就是一个拿着锁,缺迟迟不释放或者无法释放,导致程序无法继续执行下去
死锁实例
两个线程互相持有各自的锁资源,使得线程死锁
比如:
static Object lockA=newObject();static Object lockB=newObject();//死锁测试:publicstaticvoidmain(String[] args){
Thread t1=newThread(()->{synchronized(lockA){
System.out.println("t1第一步");synchronized(lockB){
System.out.println("t1第二步");}}});
Thread t2=newThread(()->{synchronized(lockB){
System.out.println("t2第一步");synchronized(lockA){
System.out.println("t2第二步");}}});
t1.start();
t2.start();}
这样一行代码,最终执行结果:全卡在了第一步。
因为t1拿了lockA还没释放,t2拿了lockB也没释放,导致后续都无法进行
为什么为产生这种情况呢?
因为一个线程获取到这个锁时,直到该线程执行完,或者调用wait方法之前,其他线程都无法获取到这个锁,因为这个锁不会被提前释放
c.锁策略*(了解即可)
常见十种锁策略
1.乐观锁
即锁认为操作A发生线程冲突的概率很低,也就是锁竞争不激烈,那么干脆就不加锁了,如果检测出有冲突,再抛出来让程序猿解决。
2.悲观锁
即锁认为操作A发生线程冲突的概率很高,也就是锁竞争很激烈,每次执行都会加锁。
这两种策略在不同场景下各有优劣,乐观锁可以省资源,悲观锁更安全。
——————————————————————————————————————————
3.轻量级锁
加锁尽量在用户态处理
4.重量级锁
加锁主要在内核态处理,存在内核态->用户态的转换
——————————————————————————————————————————
5.读写锁
对于数据来说,有读和写两种操作,以两个线程为例:
两个线程都是读数据,线程安全
一个线程读,一个写,线程不安全
两个都写,线程不安全
所以根据你的使用目的,可以判断你的操作是否需要加锁,java给我们提供了相应的类来帮助我们处理,即ReentrantReadWriteLock类。
——————————————————————————————————————————
——————————————————————————————————————————
6.可重入锁
即同一个线程内部存在多次获取同一把锁的代码,不会重复去获取
7.不可重入锁
和可重入相反,每次都需要重新获取锁,一般获取不到第二次就死锁了
——————————————————————————————————————————
8.自旋锁
即线程抢锁失败了,不会阻塞,而是立即再次尝试获取(while循环的感觉),锁一旦放出来,就能第一时间去尝试获取。
——————————————————————————————————————————
9.公平锁
即获取锁的顺序遵顼先来后到的原则,不能抢
10.非公平锁
谁抢到算谁的
d.wait,notify方法介绍
我们在线程状态转换图中,发现了一些没有提到的方法,比如yield,wait等等,其中,wait和与其对于的notify/notifyAll方法都是很重要的方法
注意:下面的方法要搭配 synchronized 来使用. 脱离 synchronized会直接抛出异常,也就是要在同步方法/代码块内使用
wait()方法:使当前线程阻塞等待,并释放对象锁,直到该对象调用了notify/notifyAll方法后,停止阻塞并重新尝试获取到锁。
notify()方法:随机唤醒一个调用wait方法的线程,待当前线程结束后,使那个线程停止阻塞而去尝试获取锁。
notifyAll()方法:和notify唯一不同的是,它是全部唤醒,不是随机。
这两个方法处理不当也会造成线程死锁,就是可能出现notify比wait先执行,导致wait所处线程一直处于阻塞状态
使用实例:
publicstaticvoidmain(String[] args){
Object lock=newObject();
Thread t1=newThread(()->{synchronized(lock){
System.out.println("线程开始等待");try{
lock.wait();}catch(InterruptedException e){
e.printStackTrace();}
System.out.println("线程等待结束");}});
t1.start();//代码补充
Thread t2=newThread(()->{synchronized(lock){
System.out.println("唤醒t1线程");
lock.notify();//可能会出现先notify后wait的结果}});
t2.start();}
结果为
进入阻塞,不在往下执行
增加补充代码后,t1被t2唤醒,继续执行
(2)volatile关键字的作用
场景介绍,观察以下代码:
/*volatile*/int count=0;static Scanner scanner=newScanner(System.in);publicstaticvoidmain(String[] args){
Demo demo=newDemo();
Thread t1=newThread(()->{while(demo.count==0){try{
Thread.sleep(1000);}catch(InterruptedException e){
e.printStackTrace();}
System.out.println("count等于0");}
System.out.println("count不等于0了");});
t1.start();
Thread t2=newThread(()->{
System.out.println("修改count的值为:");
demo.count=scanner.nextInt();});
t2.start();}
可以发现,明明我们也就已经了修改了count的值,为什么程序还没有停止呢
当我们给count加上volitale就可以发现程序及时的停止了
那么原因是什么呢?
我们可以发现我们的t1线程,是循环判断count是否等于0,由于多次拿到的值都是0,在编译器优化下就认为这个值一直是0,如果每次我都去内存去取这个值不是很浪费吗?所以他就把这个值存到寄存器里,每次都从寄存器里取这个值,所以我们t2线程改了count内存上的值,t1也察觉不到,而volatile就帮助我们解决了这个问题。
获取数据时,强制读写内存. 速度是慢了, 但是数据变的更准确了
使用方法
volatile 类型 变量名//就是修饰变量用的
a.保证内存可见性
即一个线程修改某个数据的时候,另一个线程能及时看见
b.防止指令重排序
什么是指令重排序:
A a=new A();
我们在实例化对象时,大体可以分为三个步骤:
1.为A对象开辟内存空间,并记录内存首地址
2.调用A的构造方法来初始化对象
3.将内存首地址赋予a引用
可以发现步骤一必须率先执行,而2,3两条在顺序在单线程的情况下什么样的顺序都可以,我们在使用的时候,很可能编译器通过自身的优化,就将这个顺序反过来了。单线程下无所谓,但一旦到了多线程,就有大问题。如果指令变成了132,很可能cpu刚执行完3指令,被另一个线程插队了,导致虽然a引用不为null,但是里面并不是有效数据,这就造成了线程安全问题。
PS:不保证原子性
这个大家把加法器的synchronized去掉,给count加上volatile就可以测试出来了。
结尾
那么今天的分享就到这里了,四鲲磨一篇,感谢阅读😶🌫️
(可能会有一点点的错别字 )
版权归原作者 .斯人 所有, 如有侵权,请联系我们删除。