可以在语言级支持多线程是Java语言的一大优势,这种支持主要集中在同步上,或调节多个线程间的活动和共享数据。Java所使用的同步是监视器
。
监视器Monitor
Java中的监视器支持两种线程:互斥和协作
- 虚拟机通过对象锁来实现互斥,允许多个线程在同一个共享数据上独立而不干扰地工作
- 协作则是通过
Object
类的wait
方法和notify
方法来实现,允许多个线程为了同一个目标而共同工作
我们可以把监视器
比作一个建筑,它有一个很特别的房间,房间里有一些数据,而且在同一时间只能被一个线程占据。一个线程从进入这个房间到它离开之前,它可以独占地访问房间中的全部数据。
我们用一些术语来定义这一系列动作:
- 进入建筑叫做
进入监视器
- 进入建筑中的那个特别的房间叫做
获得监视器
- 占据房间叫做
持有监视器
- 离开房间叫做
释放监视器
- 离开建筑叫做
退出监视器
除了与一些数据关联外,监视器还是关联到一些或更多的代码,这样的代码称作监视区域
,对于一个监视器来说,监视区域
是最小的、不可分割的代码块。而监视器
会保证在监视区域
上同一时间只会执行一个线程。一个线程想要进入监视器
的唯一途径就是到达该监视器
所关联的一个监视区域
的开始处,而线程想要继续执行监视区域
的唯一途径就是获得该监视器
。
监视器下的互斥
当一个线程到达了一个监视区域的开始处,它就会被放置到该监视器的入口区。如果没有其他线程在入口区等待,也没有线程持有该监视器,则这个线程就可以获得监视器,并继续执行监视区域中的代码。当这个线程执行完监视区域后,它就会退出(并释放)该监视器。
如果一个线程到达了一个一个监视区域的开始处,犯这个监视区域已经有线程持有该监视器了,则这个刚刚到达的线程必须在入口区等待。当监视器的持有者退出监视器后,新到达的线程必须与其它已经在入口区等待的线程进行一次比赛,最终只会有一个线程获得监视器。
监视器下的协作
当一个线程需要一些特别状态的数据,而由另一个线程负责改变这些数据的状态时,同步就显得特别重要。
举例:一个读线程
会从缓冲区
读取数据,而另一个写线程
会向缓冲区
填充数据。读线程
需要缓冲区
处于一个非空的状态,这样才可以从中读取数据,如果读线程
发现缓冲区
是空的,它就必须等待。写线程
负责向缓冲区
写数据,只有写线程
写入完成,读线程
才能相应的读取。
Java虚拟机使用的这种监视器被称作等待-唤醒
监视器。在这种监视器中,在一个线程(方便区分,叫线程A
)持有监视器的情况下,可以通过执行一个等待命令
,暂停自身的执行。
当线程A
执行了等待命令
后,它就会释放监视器,并进入一个等待区,这个线程A
会一直持续暂停状态,直到一段时间后,这个监视器中的其他线程执行了唤醒命令
。
当一个线程(线程B
)执行了唤醒命令
后,它会继续持有监视器,直到他主动释放监视器(执行完监视区域或执行一个等待命令)。当执行唤醒的线程(线程B
)释放了监视器后,等待线程(线程A
)会苏醒,并重新获得监视器。
等待-唤醒
监视器有时也被称作发信号并继续
(这个翻译没谁了。。。。)监视器,究其原因,就是在一个线程执行唤醒操作后,它还会继续持有监视器并继续执行监视区域,过了段时间之后,唤醒线程释放监视器,等待线程才会苏醒。
所以一次唤醒往往会被等待线程
看作是一次提醒,告诉它“数据已经是你想要的状态了”。当等待线程
苏醒后,它需要再次检查状态,以确认是否可以继续完成工作,如果数据不是它所需要的状态,等待线程
可能会再次执行等待命令或者放弃等待退出监视器
。
还是上面的例子:一个读线程
、一个缓冲区
、一个写线程
。假定缓冲区
是由某个监视器
所保护的,当读线程
进入这个监视器
时,它会检查缓冲区
是否为空:
- 如果不为空,读线程会从中取出一些数据,然后退出监视器。
- 如果是空的,读线程会执行一个等待命令,同时它会暂停执行并进入
等待区
。
这样读线程释放了监视器,让其他线程有机会可以进入。稍后,写线程进入了监视器,向缓冲区写入了一些数据,然后执行唤醒,并退出监视器。当写线程执行了唤醒指令后,读线程被标志为可能苏醒,当写线程退出监视器后,读线程被唤醒并成为监视器的持有者。
监视器模型
Java虚拟机中的监视器模型分成了三个区域。如下图:
- 中间大的
监视区域
只允许一个单独的线程,是监视器的持有者; - 左边是
入口区
- 右边是
等待区
等待线程和活动线程使用红色和蓝色区分。
模型中也规定了线程和监视器交互所必须通过的几道门:
- 当一个线程到达监视区域的开始处时,它会从最左边
1号箭头
进入入口区
,当进入入口区
后- 如果没有任何线程持有监视器,也没有任何等待的线程,这个线程就可以通过
2号箭头
,并持有监视器。作为监视器的持有者,它可以继续执行监视区域
中的代码。 - 如果已经有另一个线程正在持有监视器,这个新到达的线程必须在入口区等待,很可能已经有线程已经在等待了,并且这个新线程会被阻塞,不能执行监视区域中的代码。
- 如果没有任何线程持有监视器,也没有任何等待的线程,这个线程就可以通过
- 上图中有三个线程在
等待区
中,这些线程会一直在那里,直到监视区域
中的活动线程
释放监视器 活动线程
会通过两条途径释放监视器:- 如果
活动线程
执行完了监视区域
的代码,它会从5号箭头
退出监视器。 - 如果
活动线程
执行了等待命令,它会通过3号箭头
进入等待区,并释放监视器
- 如果
- 如果
活动线程
在释放监视器前没有执行唤醒命令(同时在此之前没有任何等待区的线程被唤醒并等待苏醒),那么位于入口区的线程们将会竞争获得监视器。 - 如果
活动线程
在释放监视器前执行了唤醒命令,入口区
的线程就不得不和等待区
的线程一起来竞争:- 如果
入口区
的线程获胜,它就会通过2号箭头
进入监视区域
,并获得监视器
- 如果
等待区
的线程获胜,它会通过4号箭头
退出等待区并重新获得监视器。
- 如果
请注意,==一个线程只有通过3号箭头
和4号箭头
才能进入或退出等待区。并且一个线程只有在它持有监视器的时候才能执行等待命令,而且它只能通过再次成为监视器的持有者才能离开等待区。==
线程唤醒的一些细节
在Java虚拟机中,线程在执行等待命令时可以随意指定一个暂停之间。在暂停时间到了之后,即使没有来自其他线程的明确的唤醒命令,它也会自动苏醒。看下面这段代码:
public class MonitorTest {
public static void main(String[] args) {
byte[] buffer = new byte[4];
MonitorObj monitorObj = new MonitorObj();
Thread read00 = new Thread() {
@Override
public void run() {
System.out.println("read00 准备获取锁");
synchronized (monitorObj) {
System.out.println("read00 = " + buffer[3]);
try {
Thread.sleep(1000);
monitorObj.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("read00 = " + buffer[3]);
}
System.out.println("read00 释放锁");
}
};
Thread read01 = new Thread() {
@Override
public void run() {
System.out.println("read01 准备获取锁");
synchronized (monitorObj) {
System.out.println("read01 = " + buffer[3]);
try {
Thread.sleep(1000);
monitorObj.wait(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("read01 = " + buffer[3]);
}
System.out.println("read01 释放锁");
}
};
Thread write = new Thread() {
@Override
public void run() {
System.out.println("write 准备获取锁");
synchronized (monitorObj) {
try {
Thread.sleep(5000);
} catch (InterruptedException e) {
e.printStackTrace();
}
buffer[3] = 99;
//monitorObj.notifyAll();
try {
Thread.sleep(3000);
System.out.println("write thread finish");
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
};
read00.start();
read01.start();
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
write.start();
}
}
class MonitorObj {
}
复制代码
请注意,read01
线程使用的是wait(2000)
方法;read00
线程使用的是wait()
方法。
然后,我们把write
线程的monitorObj.notifyAll()
唤醒方法注释掉,输出如下:
read00 准备获取锁
read01 准备获取锁
read00 = 0
read01 = 0
write 准备获取锁
write thread finish
read01 = 99
read01 释放锁
复制代码
因为wait(2000)
加了暂停时间的原因,read01
还是自动唤醒了。而对于read00
仍然并且会一直处于等待,除非调用唤醒指令notify()
或notifyAll()
。
而对于notify()
和notifyAll()
的使用,请注意==只有当绝对确认只会有一个线程在等待区中挂起的时候,才可以使用notify
(notifyAll
也可以);只要同时存在有多个线程在等待区中被挂起,就应该使用notifyAll
==
对象锁
前面讲过,Java虚拟机的一些运行时数据区会被所有线程共享,像方法区
和堆
。所以,Java程序需要为这两种多线程下的数据访问进行协调:
- 保存在堆中的实例变量
- 保存在方法区中的类变量
程序不需要考虑Java栈中的局部变量,因为是线程私有的。
在Java虚拟机中,每个对象和类在逻辑上都有一个监控器与之相关联的。
- 对于对象来说,相关联的监视器保护对象的实例变量。
- 对于类来说,监视器保护它的类变量。
如果一个对象没有实例变量,或者一个类没有类变量,相关联的监视器就什么都不监视。
为了实现监视器的排他性监视能力,Java虚拟机为每一个对象和类都关联了一个锁(有时候被称为互斥体mutex
)。一个锁就像就像一种任何时候只允许一个线程拥有的特权。
- 正常情况下,线程访问实例变量或者类变量不需要获取锁。
- 但是如果线程获取了锁,那么在它释放这个锁之前,就没有其他线程可以获取这个锁了。
锁住一个对象,其实就是获取对象相关联的监视器。而类锁实际上也是用对象锁来实现的。我们前面说过,当虚拟机装载一个class文件时,它会创建一个java.lang.Class
类的实例。当锁住一个类时,实际上锁住的的就是那个类的Class对象。
一个线程可以允许多次对同一个对象上锁(可重入)。对于每一个对象来说,Java虚拟机维护了一个计数器,记录对象被加了多少次锁:
- 没有被锁的对象的计数器是0
- 线程每加锁一次,计数就加1(只有已经拥有了这个对象锁的线程才能对该对象再次加锁)
- 线程每释放一次锁,计数器减1
- 当计数器为0时,锁就被完全释放了。此时其他线程才可以使用它。
对象锁和监视器
==监视器能够实现拦截线程,保证监视区域只有一个线程在工作。靠的就是对象锁==
在Java虚拟机中,每一个监视区域
都和一个对象引用相关联。所以整个流程差不多是这样子的:
- Java虚拟机中的一个线程进入监视器
入口区
- 线程根据
监视区域
的对象引用,找到对应的数据- 如果数据显示计数器数值为0,表示
监视区域
没有活动线程
,可以(多个线程的话需要竞争)加锁并通过2号箭头
进入监视区域,执行后续代码。 - 如果数值不为0,那么表示
监视区域
正在被占用,线程就要在入口区
等待,等待锁的数值变为0,和其他线程(如果有的话)竞争进入 - 当线程离开
监视区域
后,不管是如何离开的,它都会释放相关对象上的锁。
- 如果数据显示计数器数值为0,表示
虚拟机对于监视区域
的处理
==怎么定义上面提到的监视区域
呢?==
Java中的关键字synchronized
就是用来定义监视区域
的关键。synchronized
可以用来定义同步语句
和同步方法
同步语句
被synchronized
包裹起来的代码块就是同步语句
,像下面这样:
public class SynchronizeTest {
private int[] array = new int[]{1, 2, 3, 4};
public void expandArray() {
synchronized (this) {
for (int i = 0; i < array.length; i++) {
array[i] = array[i] * 10;
}
System.out.println(Arrays.toString(array));
}
}
}
复制代码
对于上面的同步代码块来说,虚拟机要保证不管线程以什么样的形式退出,必须要及时释放锁。
==怎么保证呢?==
假如上面的代码array[i] = array[i] * 10;
不小心写成了array[i] = array[i]/0;
,当执行到这一步的时候就要报java.lang.ArithmeticException
异常了。
对于可能抛出的异常来说,我们会使用try-catch
进行捕获,编译器的做法也是一样的。
我们看下javap -p SynchronizeTest.class
后expandArray()
部分输出:
public void expandArray();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=4, locals=4, args_size=1
0: aload_0
1: dup
2: astore_1
3: monitorenter
4: iconst_0
5: istore_2
6: iload_2
7: aload_0
8: getfield #2 // Field array:[I
11: arraylength
12: if_icmpge 35
15: aload_0
16: getfield #2 // Field array:[I
19: iload_2
20: aload_0
21: getfield #2 // Field array:[I
24: iload_2
25: iaload
26: iconst_0
27: idiv
28: iastore
29: iinc 2, 1
32: goto 6
35: getstatic #3 // Field java/lang/System.out:Ljava/io/PrintStream;
38: aload_0
39: getfield #2 // Field array:[I
42: invokestatic #4 // Method java/util/Arrays.toString:([I)Ljava/lang/String;
45: invokevirtual #5 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
48: aload_1
49: monitorexit
50: goto 58
53: astore_3
54: aload_1
55: monitorexit
56: aload_3
57: athrow
58: return
Exception table:
from to target type
4 50 53 any
53 56 53 any
复制代码
请注意Exception table
这个异常表,这个就是编译器细心为我们加上的。它会监听从方法的第4条指令
到第50条指令
执行过程中的any
异常,出现异常就跳到第53条指令
。
我们可以看到53
往后还有一个monitorexit
在等待执行(这个any
说明啥异常也阻止不了释放锁的决心啊)。
==是不是感觉编译器真滴很贴心哇,赞!==
如果觉得不真实的话我们把synchronized
代码块去掉,再编译一次看下字节码信息,你会发现Exception table
也被清除了。
同步方法
还是上面的类SynchronizeTest
,这次我们把方法改成这样:
public synchronized void expandArray() {
for (int i = 0; i < array.length; i++) {
array[i] = array[i] / 0;
}
System.out.println(Arrays.toString(array));
}
复制代码
我们再来看下相关的字节码:
public synchronized void expandArray();
descriptor: ()V
flags: ACC_PUBLIC, ACC_SYNCHRONIZED
Code:
stack=4, locals=2, args_size=1
0: iconst_0
1: istore_1
2: iload_1
3: aload_0
4: getfield #2 // Field array:[I
7: arraylength
8: if_icmpge 31
11: aload_0
12: getfield #2 // Field array:[I
15: iload_1
16: aload_0
17: getfield #2 // Field array:[I
20: iload_1
21: iaload
22: iconst_0
23: idiv
24: iastore
25: iinc 1, 1
28: goto 2
31: getstatic #3 // Field java/lang/System.out:Ljava/io/PrintStream;
34: aload_0
35: getfield #2 // Field array:[I
38: invokestatic #4 // Method java/util/Arrays.toString:([I)Ljava/lang/String;
请到「今天看啥」查看全文