Java多线程通关——基础知识挑战
等掌握了基础知识之后,才有资格说基础知识没用这样的话。否则就老老实实的开始吧。
对象的监视器
每一个Java对象都有一个监视器。并且规定,每个对象的监视器每次只能被一个线程拥有,只有拥有它的线程把它释放之后,这个监视器才会被其它线程拥有。
其实就是说,对象的监视器对于多线程来说是互斥的,即一个线程从拿到它之后到释放它之前这段时间内,其它线程是绝对不可能再拿到它的。这是由JVM保证的。
这样一来,对象的监视器就可以用来保护那种每次只允许一个线程执行的方法或代码片段,就是我们常说的同步方法或同步代码块。
Java包括两种范畴的对象(当然,这样讲可能不准确,主要用于帮助理解),一种就是普通的对象,比如new Object()。一种就是描述类型信息的对象,即Class<?>类型的对象。
这两类都是Java对象,这毋庸置疑,所以它们都有监视器。但这两类对象又有明显的不同,所以它们的监视器对外表现的行为也是不同的。
请看下面表达式:
Object o1 = new Object();
Object o2 = new Object();
o1 == o2; //false
o1和o2是分别new出来的两个对象,它们肯定不相同。又因为监视器是和对象关联的,所以o1的监视器和o2的监视器也是不同的,且它们没有任何关系。
所以必须是同一个对象的监视器才行,不同对象的监视器达不到预期的效果,这一点要切记。
再看下面的表达式:
o1.getClass() == o2.getClass(); //true
o1.getClass() == Object.class; //true
但是o1的类型信息对象(o1.getClass())和o2的类型信息对象(o2.getClass())是同一个,且和Object类的类型信息对象(Object.class)也是同一个。这不废话嘛,o1和o2都是从Object类new出来的。哈哈。
类型信息对象本身的类型是Class<?>,在类加载器(ClassLoader)加载一个类后,就会生成一个和该类相关的Class<?>类型的对象,该对象会被缓存起来,所以类型信息对象是全局(同一个JVM同一个类加载器)唯一的。
这也就说明了,为什么同一个类new出来的多个对象是不同的,但是它们的类型信息对象却是同一个,且可以使用“类.class”直接获取到它。
Java语言规定,使用synchronized关键字可以获取对象的监视器。下面分别来看这两类对象的监视器用法。
普遍对象的监视器:
class SyncA {
//方法A
public synchronized void methodA() {
//同步方法,当前对象的监视器
}
//方法B
public void methodB() {
synchronized(this) {
//同步代码块,当前对象的监视器
}
}
}
class SyncB {
//对象
private SyncA syncA;
public SyncB(SyncA syncA) {
this.syncA = syncA;
}
//方法C
public void methodC() {
synchronized(syncA) {
//同步代码块,syncA对象的监视器
}
}
}
//new一个对象
SyncA syncA = new SyncA();
//把该对象传进去
SyncB syncB = new SyncB(syncA);
//A、B、C这三个方法都要拥有syncA这个对象的监视器才能执行
new Thread(syncA::methodA).start();
new Thread(syncA::methodB).start();
new Thread(syncB::methodC).start();
这三个线程都去获取同一个对象(即syncA)的监视器,因为一个对象的监视器一次只能被一个线程拥有,所以这三个线程是逐次获取到的,因此这三个方法也是逐次执行的。
这个示例告诉我们,利用对象的监视器可以做到的,并不只是同一个方法不能同时被多个线程执行,多个不同的方法也可以不能同时被多个线程执行,只要它们用到的是同一个对象的监视器。
类型信息对象的监视器:
class SyncC {
//静态方法A
public static synchronized void methodA() {
//同步方法,类型信息对象的监视器
}
//静态方法B
public static void methodB() {
synchronized(SyncC.class) {
//同步代码块,类型信息对象的监视器
}
}
}
class SyncD {
//类型信息对象
private Class<SyncC> syncClass;
public SyncD(Class<SyncC> syncClass) {
this.syncClass = syncClass;
}
//方法C
public void methodC() {
synchronized(syncClass) {
//同步代码块,SyncC类的类型信息对象的监视器
}
}
//方法D
public void methodD() {
synchronized(syncClass) {
//同步代码块,SyncC类的类型信息对象的监视器
}
}
}
//A、B、C、D这四个方法都要拥有SyncC类的类型信息对象的监视器才能执行
new Thread(SyncC::methodA).start();
new Thread(SyncC::methodB).start();
new Thread(new SyncD(SyncC.class)::methodC).start();
new Thread(new SyncD((Class<SyncC>)new SyncC().getClass())::methodD).start();
因为一个类的类型信息对象只有一个,所以这四个线程其实是在竞争同一个对象的监视器,因此这四个方法也是逐次执行的。
通过这个示例,再次强调一下,不管是方法还是代码块,不管是静态的还是实例的,也不管是属于同一个类的还是多个类的,只要它们共用同一个对象的监视器,那么这些方法或代码块在多线程下是无法并发运行的,只能逐个运行,因为同一个对象的监视器每次只能被一个线程所拥有,其它线程此时只能被阻塞着。
注:在实际使用中,一定要确保是同一个对象,尤其是使用字符串类型或数字类型的对象时,一定要注意。
几个重要的方法
首先是Object类的wait/notify/notifyAll方法,因为Java中的所有类最终都继承自Object类,所以,可以使用任何Java对象来调用这三个方法。
不过Java规定,要在某个对象上调用这三个方法,必须先获取那个对象的监视器才行。再次提醒,监视器是和对象关联的,不同的对象监视器也是不同的。
请看下面的用法:
//new一个对象
Object obj = new Object();
//获取对象的监视器
synchronized(obj) {
//在对象上调用wait方法
obj.wait();
}
很多人首次接触这一部分的时候一般都会比较懵,主要是因为搞不清人物关系。
这里的wait方法虽然是在对象(即obj)上调用的,但却不是让这个对象等待的。而是让执行这行代码(即obj.wati())的线程(即Thread)在这个对象(即obj)上等待的。
这里的线程是等待的“主体”,对象是等待的“位置”。比如学校开运动会时,会在操场上为每班划定一个位置,并插上一个牌子,写上班级名称。
这个牌子就相当于对象obj,它表示一个位置信息。当学生看到本班牌子之后,就会自动去牌子后面排队等待。
学生就相当于线程,当学生看到牌子就相当于当线程执行到obj.wait(),学生去牌子后面排队等待就相当于线程在对象obj上等待。
当线程执行完obj.wait()后,就会释放掉对象obj的监视器,转而进入对象obj的等待集合中进行等待,线程由运行状态变为等待(WAITING)状态。此后这个线程将不再被线程调度器调度。
(说明一点,当多个线程去竞争同一个对象的监视器而没有竞争上时,线程会变为阻塞(BLOCKED)状态,而非等待状态。)
线程选择等待的原因大多都是因为需要的资源暂时得不到,那什么时候资源能就位让线程再次执行呢?其实是不太好确定的,那干脆就到资源OK时通知它一声吧。
请看下面的方法:
//获取对象(还是上面那个)的监视器
synchronized(obj) {
//在对象上调用notify方法
obj.notify();
}
有了上面的基础,现在就好理解多了。代码的意思就是通知在对象obj上等待的线程,把其中一个唤醒。即把这个线程从对象obj的等待集合中移除。此后这个线程就又可以被线程调度器调度了。可能有一部分人觉得现在被唤醒的那个线程就可以执行了,其实不然。
当前线程执行完notify方法后,必须要释放掉对象obj的监视器,这样被唤醒的那个线程才能重新获取对象obj的监视器,这样才可以继续执行。
就是当一个线程想要通过wait进入等待时,需要获取对象的监视器。当别的线程通过notify唤醒这个线程时,这个线程想要继续执行,还需要获取对象的监视器。
notifyAll方法的用法和notify是一样的,只是含义不同,表示通知对象obj上所有等待的线程,把它们全部都唤醒。虽然是全部唤醒,但也只能有一个线程可以运行,因为每次只有一个线程能获取到对象obj的监视器。
还有一种wait方法是带有超时时间的,它表示线程进入等待的时间达到超时时间后还没有被唤醒时,它会自动醒来(也可以认为是被系统唤醒的)。
这种情况下没有超时异常抛出,虽然线程是自动醒来,但想要继续执行的话,同样需要先获取对象obj的监视器才行。
注:线程通过wait进入等待时,只会释放和这个wait相关的那个对象的监视器。如果此时线程还拥有其它对象的监视器,并不会去释放它们,而是在等待期间一直拥有。这块一定要注意,避免死锁。
使用须知:
处在等待状态的线程,可能会被意外唤醒,即此时条件并不满足,但是却被唤醒了。当然,这种情况在实践中很少发生。但是我们还是要做一些措施来应对,那就是再次检测条件是否满足,不满足的话再次进入等待。
可见这是一个具有重复性的逻辑,因此把它放到一个循环里是最合适的,如下这样:
//获取对象的监视器
synchronized(obj) {
//判断条件是否满足
while(condition is not satisfied) {
//在对象上调用wait方法
obj.wait();
}
}
这样一来,即使被意外唤醒,还会再次进入等待。直到条件满足后,才会退出while循环,执行后面的逻辑。
多线程的话题怎么能少了主角呢,下面有请主角上场,哈哈,就是Thread类啦。关于线程,我在上一篇文章中已经谈过,这里再赘述一遍,希望加深一下印象。
线程是可以独立运行的“个体”,这就导致我们对它的“控制能力”变弱了。当我们想让一个线程暂停或停止时,如果强制去执行,会产生两方面的问题,一是使正在执行的业务中断,导致业务出现不一致性。二是使正在使用的资源得不到释放,导致内存泄漏或死锁。可见,强制这种方式不可取。(看看Thread类的那些废弃方法便知)
所以,只能采取柔和的方式,就是你对一个线程说,“大哥,停下来歇会吧”,或者是,“大哥,停止吧,不用再执行了”。虽然听着是恶心了点,但意思就是这样的。那么当线程接收到这个“话语”时,它必须要做出反应,自己让自己停止,当然,线程也可以根据自己的需要,选择不停止而继续执行。
这才是和线程交互最安全的方式,就像一个高速行驶的汽车,只有自己慢慢停下来才是最好的方式,直接通过外力干预,很大概率是车毁人亡。
这种柔和的处理方式,在计算机里有个专用名词,叫中断。这是一种交互方式,你对别人发送一个中断,别人要响应这个中断并做出相应的处理。如果别人不响应你的这个中断,那只能是“热脸贴冷屁股”,完全没了面子。可见,参与中断的双方必须要提前约定好,你怎么发送,别人怎么处理,否则只能是鸡同鸭讲。
Thread类和中断相关的方法有三个:
实例方法,void interrupt(),表示中断线程,要中断哪个线程就在哪个线程的对象上调用该方法。
Thread t = new Thread(() -> {doSomething();});
t.start();
t.interrupt();
new一个线程,启动它,然后中断它。
当一个线程被其它线程中断后,这个线程必须要能检测到自己被中断了才行,于是就有了下面这个方法。
实例方法,boolean isInterrupted(),返回一个线程是否被中断。常用于一个线程检测自己是否被中断。
if(Thread.currentThread().isInterrupted()) {
doSomething();
return;
}
如果线程发现自己被中断,做一些事情,然后退出。该方法只会去读取线程的中断状态,而不会去修改它,所以多次调用返回同样的结果。
线程在处理中断前,需要将中断状态清除一下,即将它设置成false。否则下次检测时还是true,以为又中断了呢,实则不是。
静态方法,static boolean interrupted(),该方法有两个作用,一是返回线程是否被中断,二是如果中断则清除中断状态。
Thread.interrupted();
由于这个方法是静态方法,所以只能用于当前线程,即线程自己清除自己的中断状态。
由于这个方法会清除中断状态,所以,如果第一次调用返回true的话,紧接着再调用一次应该返回false,除非在两次调用之间线程真的又被中断了。
还有一种特殊情况就是,在你中断一个线程时,这个线程恰巧没有在运行,它可能是因为竞争对象的监视器“失败”(即没有争取上)而处于阻塞状态,可能是因为条件不满足而处于等待状态,可能是因为在睡眠中。总之,线程目前没有在执行代码。
由于线程目前没有在执行代码,所以根本就无法去检测这个中断状态,也就是无法响应中断了,这样肯定是不行的。所以设计者们此时选择了抛异常。
因此,不管是由于阻塞/等待/睡眠,只要一个线程处于“停止”(即没有在运行)时,此时去中断它,线程会被唤醒,接着同样要去再次获取监视器,然后就收到了InterruptedException异常了,我们可以捕获这个异常并处理它,使线程可以继续正常运行。此时既然已经收到异常了,所以中断状态也就同时给清除了,因为中断异常已经足够表示中断了。
仔细想想这种设计其实颇具人性化。就好比一个人,在他醒着的时候,跟他说话,他一定会回应你。当他睡着时,跟他说话,其实他是听不到的,自然无法回应你。此时应该采取稍微暴力一点的手段,比如把他摇晃醒。
所以,一个线程正在运行时,去中断它,是不会抛异常的,只是设置中断状态。此时中断状态就表示了中断。一个线程在没有运行时(阻塞/等待/睡眠),去中断它,会抛出中断异常,同时清除中断状态。此时中断异常就表示了中断。
然后就是sleep方法,表示线程临时停止执行一段时间,这里只有一个要点,就是在睡眠期间,线程拥有的所有对象的监视器都不会被释放。
Thread.sleep(1000);
由于sleep是静态方法,所以,一个线程只能让自己睡眠,而没有办法让别的线程睡眠,这是完全正确的,符合我们一直在阐述的思想。一个线程的行为应该由自己掌控,别的线程顶多可以给你一个中断而已,而且你还可以选择处理它或忽略它。
最后一个方法是join,它是一个实例方法,所以需要在一个线程对象上调用它,如下:
Thread t = new Thread(() -> {doSomething();});
t.start();
t.join();
表示当前线程执行完t.join()代码后,就会进入等待,直到线程t死亡后,当前线程才会重新恢复执行。我在上一篇文章中把它比喻为插队,线程t插到了当前线程的前面,所以必须等线程t执行完后,当前线程才会接着执行。
这里主要是想说下它的源码实现,join方法标有synchronized关键字,所以是同步方法,而且在方法体内调用了从Object类继承来的wait方法。
所以join方法可以这样来解释,当前线程获取到线程对象t的监视器,然后执行t.wait(),使当前线程在线程对象t上等待,当前线程从运行状态进入到等待状态。由于对象t是一个线程,这是非常特殊的,因为线程执行完是会终止的,且在终止时会自动调用notifyAll方法进行通知。
有句话是这样讲的,“鸟之将死,其鸣也哀;人之将死,其言也善”。因此,一个线程都快要死了,是不是应该通知在自己身上等待的其它所有线程,把大伙都唤醒。总不能让所有人都给自己“陪葬”吧,哈哈。
因此,在线程t执行结束后,会自动执行t.notifyAll()来通知所有在t上等待的线程,并把它们全部唤醒。所以当前线程会继续接着执行。
为什么说notifyAll()是自动执行的呢?因为源码中并没有去调用它,而实际却执行了,所以只能是系统自动调用了。
所以,从宏观上看,就是当前线程在等待线程t的死亡。
任何Java对象都有监视器,所以线程对象也有监视器,但线程对象确实比较特殊,所以它的wait/notify方法也会有特殊的地方,因此官方建议我们不要随意去玩Thread类的这些方法。
完整示例源码:
https://github.com/coding-new-talking/java-code-demo.git
如果以上内容阁下全部都知道,而且理解到位,那已经很厉害了,请等待下篇多线的文章吧。
(END)
作者是工作超过10年的码农,现在任架构师。喜欢研究技术,崇尚简单快乐。追求以通俗易懂的语言解说技术,希望所有的读者都能看懂并记住。下面是公众号的二维码,欢迎关注!