目录
多线程是处理复杂问题的基本手段,合理运用,能够显著提升解决问题的性能,提高用户体验。所以,多线程的正确使用是java程序员的基本功,今天主要讲解多线程相关的基础知识,为后期工作、学习做准备。
一、线程的状态
在java.lang.Thread.State中定义了线程的6中状态,分别是:New、Runnable、Blocked、Waiting、TimedWaiting和Terminated。
New:线程刚创建出来,尚未启动时的状态;
Runnable:可运行状态,执行start方法后的状态(跟CPU时间片没有关系);
Blocked:阻塞状态,如等待锁资源;
Waiting:等待状态,等待其他线程通知,如执行了wait()/Thread.join()/LockSupport.park();
TimedWaiting:定时等待,有超时时间的等待其他线程通知状态,如Thread.sleep()、Object.wait()、Thread.join()、LockSupport.parkNanos()、LockSupport.parkUntil();
Terminated:终止状态;
各状态之间的转化关系如下图:
二、线程的中止
开车有刹车,泼出去的水也有想收回来的时候,那么一个正在执行的线程可不可以中止呢?java为我们提供了哪些线程中止的方式?答案是肯定的。常用的线程中止方法有以下几种:
1、Stop:中止线程,并且清除监控器锁的信息,但是可能导致线程安全问题(如:同步代码块执行到一半的时候,线程被中止了,会破坏该同步代码块的原子性、一致性),不建议使用。
2、Destroy:JDK未实现该方法。
3、interrupt:如果目标线程在调用Object class的wait()/wait(long)/wait(long,int)方法、join()/join(long,int)/join(long,int)方法、sleep(long,int)方法时被阻塞,那么interrupt会生效,该线程的中断状态将被清除,并且抛出InterruptedException异常,由开发人员通过捕获异常进而决定是继续执行完还是回滚,避免同步代码块的原子性被破坏。如果目标线程是被IO或者NIO的Channel锁阻塞,则IO操作会被中断或者返回特殊异常值。达到终止线程的目的。如果以上条件都不满足,则会设置此线程的中断状态。推荐使用!
4、标志位:通过判断共享flag标志变量的值,控制线程是否继续执行。推荐使用!
三、CPU缓存和内存屏障
在我们组装电脑的时候,内存大小是一个重要的性能参数。因为CPU的运行速度极快,如果数据直接从硬盘读取,那CPU再强大也是英雄无用武之地,高射炮打蚊子,大材小用。那你以为有了内存做缓存就可以了吗?不是的,CPU还是嫌内存慢!于是各厂商就在CPU内部又增加了缓存结构,而且还不只是一个,而是增加了三级缓存,如下图:
多级缓存:
主要分三级缓存,从内到外分别是L1、L2、L3。
L1 Cache(一级缓存):是CPU第一层高速缓存,分为数据缓存和指令缓存,一般大小为32-4096KB;
L2 Cache(二级缓存):由于L1级高速缓存容量的限制,为了再次提高CPU的运算速度,在CPU外部放置一高速存储器,即二级缓存;
L3 Cache(三级缓存):可以进一步降低内存延迟,同时提升大数据量计算时处理器的性能。具有较大L3缓存的处理器可以提供更有效的文件系统缓存行为及较短消息和处理器队列长度,一般多核共享一个L3缓存。
CPU缓存现在都是内置的。
CPU在读取数据时,先在L1中寻找,再从L2寻找,再从L3寻找,然后是内存,最后是外部存储器。
同一数据可能同时保存在主内存、各个CPU内核的各级缓存中,就像是你们家的大门钥匙有好几份,各个家庭成员人手一份,那你怎么知道现在谁在家?在家做了什么?或者说你把锁换了后,怎么通知其他人?这就存在一个缓存之间同步的问题。
缓存同步协议:
多CPU读取同样的数据进行缓存,进行不同运算之后,最终写入主内存以哪个CPU为准?为了保证多核CPU高速缓存回写数据一致性,CPU厂商提出并实现了MESI协议,保证了缓存的一致性。MESI协议,它规定每条缓存有个状态位,定义了四个状态:
Modified(修改态):此cache行已被修改(脏行),内容不同于主内存,为此cache专有;
Exclusive(专有态):此cache行内容同于主内存,但不出现于其他cache中;
Shared(共享态):此cache行内容同于主内存,但也出现于其他cache中;
Invalid(无效态):此cache行内容无效(空行)。
多处理器时,单个CPU对缓存中数据进行了改动,需要通知给其他CPU,也就是说,CPU要控制自己的读写操作,还要监听其他CPU发出的通知,从而保证最终一致。
运行时指令重排:
为了保证数据的一致性、完整性,存储资源一般都支持“一写多读”,即在同一时刻对同一资源的访问只允许一个程序做写操作或者多个线程做读操作。这就存在一个问题,如果所有程序都按顺序执行,那么将存在很多阻塞等待时间,CPU没有充分的利用起来。
比如你要做两件事情,一个是去银行存钱,一个是吃早餐,如果必须先取钱再吃早餐,那你可能需要在银行排很长的队,但是如果你发现银行现在很多人在排队,你是不是可以先吃了早餐再说,即可以将没有依赖影响的其他事情先做了,称为指令重排。同样的,当CPU写缓存时发现缓存区块正被其他CPU占用,为了提高CPU处理性能,可能将后面的读缓存指令优先执行,只要保证最终结果是一致的就可以,即指令重排遵守as-if-serial语义,不管怎么重排序,程序执行(单线程)的结果不能被改变。编译器和处理器不会对存在数据依赖关系的操作做重排序。
指令重排对于单线程程序来说是优化,但是对于多线程程序来说就不一定了,也有可能是灾难,可能导致程序执行的乱序问题。那该如何解决?
内存屏障:
解决了上述问题,处理器提供了两个内存屏障指令(Memory Barrier)。
写内存屏障(Store Memory Barrier):在指令后插入Store Barrier,能让写入缓存中的最新数据立即写入主内存,让其他线程可见。强制写入主内存,这种显示调用,CPU就不会因为性能考虑而去对指令重排序。
读内存屏障(Load Memory Barrier):在指令前插入Load Barrier,可以让高速缓存中的数据失效,强制重新从主内存加载数据。
类似java中的volatile关键字,后期会有详细讲解。
四、线程通信
多人团队协作开发的时候,成员之间会有一个沟通的问题,互相之间要了解工作进度及遇到的问题,成员沟通效率会影响项目的开发进度及质量。同样的,当多个线程同时工作时,也存在线程通信、协调的问题。多线程之间通信方式有多种,文件共享、网络共享、共享变量及JDK提供的线程协调API等等。这里主要讲解JDK提供的线程协调API。
suspend/resume(不推荐使用,已弃):调用suspend挂起目标线程,通过resume可以恢复线程的执行。弃用的原因主要是容易写出线程挂起后无法唤醒的程序,比如:1、在同步代码块中调用suspend方法挂起之后,由于不会释放锁资源,如果唤醒代码块需要获取同一锁资源,则会出现死锁的现象;2、如果先调用resume,后调用suspend,也会出现线程无法正常唤醒的情况。
wait/notify/notifyAll(推荐):这些方法只能由同一对象锁的持有者线程调用,也就是说必须写在同步代码块中,否则会抛出IllegalMonitorStateException异常。
wait方法导致当前线程等待,加入该对象锁的等待集合中,并且放弃当前持有的对象锁。
notify/notifyAll方法唤醒一个或所有正在等待这个对象锁的线程。
对象锁.wait();会释放锁资源,同步代码块不会死锁。但是如果先调用notify,再调用wait,也会导致wait的线程无法恢复,一直处于waiting状态。
park/unpark(推荐):线程通过工具类LockSupport调用part方法则等待“许可”(LockSupport.park();),通过工具类LockSupport调用unpark方法则为指定线程提供“许可(permit)”(LockSupport.unpark(threadObject);)。不要求park/unpark的调用顺序。多次调用unpark之后,再调用park,程序会直接运行。多次调用unpark也不会累加许可,即多次unpark之后,只对一个park有效,再次调用park依然会等待。
在同步代码块中,park不会释放锁资源,所以在同步代码块中使用容易出现死锁。
编码建议:判断等待条件是否成立的代码,应该在循环中检查等待条件,原因是处于等待状态的线程可能会收到错误警报或伪唤醒,如果不在循环中检查等待条件,程序可能会在没有满足结束条件的情况下退出等待状态。
五、线程封闭
多线程之间需要共享部分数据,当然也会有自己独有的数据,比如线程内的局部变量就是该线程独有的,其他线程无法访问(栈封闭)。但是在线程内部,通过局部变量来保存线程特有的数据有一定的局限性,比如多个方法之间互相调用时,如果想把数据传过去就必须使用参数,参数列表过多会影响代码的可读性。那有没有更优美的方式保存线程特有的数据呢?答案是肯定的,那就是ThreadLocal变量(线程封闭)。
ThreadLocal变量的使用分三步:1、定义公共变量;2、线程内设置数据;3、线程内获取数据;
定义公共变量:
public static ThreadLocal<String> value = new ThreadLocal<>();
线程内设置数据:
value.set("这是当前线程设置的数据123");
线程内获取数据:
String v = value.get();
当前线程只能获取到当前线程设置的数据,线程之间不会有影响。可以理解为ThreadLocal变量底层维护了一个Map,key是线程对象,value是线程设置的数据。举个例子:我们去超市购物,会将自己的东西放到储物柜里,这个储物柜就是你定义的公共的ThreadLocal变量,每个人会将自己的东西放到不同的格子内,取东西的时候也是从自己那个格子里取。
六、小结
这一节,我们学习了java中的线程状态、线程中止、线程通信、线程封闭等内容,以及CPU缓存、指令重排、内存屏障等概念。这些都是基础知识,是为后面的学习服务的。接下来我们学习线程池的应用及实现原理。