所有的Java程序最终都会被编译成包含字节码(Java虚拟机的机器语言)的class文件。这篇文章将带着大家一探Java虚拟机处理线程同步的机制。
线程与数据共享
Java有许多优点,其中之一就是其在语言级别上对于多线程的支持。该特性的主要特性主要体现在多线程情况下对数据共享的调配与访问。
对于一个正在执行的Java应用,其运行时所产生的数据会被JVM划分并分别存储于多个不同的区域,包括一个或多个Java栈,堆以及方法区。
在Java虚拟机中,每个不同的线程都拥有一个独立的Java栈,栈中的数据(局部变量,参数以及该线程调用的每个方法的返回值)其他线程无法访问仅有当前线程可访问。其中栈用于存储基本数据类型以及对象的引用。在JVM中,栈不能用于存储真正的对象,所有的对象实例都会存储在堆中。
在JVM中有且仅有一个堆且这个堆由所有线程共享。堆中仅用于存储对象实例。单独的基本数据类型或是对象的引用是无法存储于堆中,因为他们都属于对象实例的一部分。此外,数组(包括基本数据类型的数组)也是存储于堆中因为Java中数组也是对象。
除了Java栈与堆,在JVM中另一个用于存储数据的区域就是方法区。方法区用于存储类中的静态变量。方法区与栈相似也是用于存储基本数据类型以及对象的引用。与栈不同的是,这些方法区中的静态变量是被所有线程共享的。
对象与类锁
综上所述,在JVM中有两块内存区域是被所有线程所共享的,分别是:
a)堆:用于存储所有对象的实例。
b)方法区:用于存储所有的静态变量。
如果有多个线程在同一时间点需要调用相同的对象或者静态变量,他们访问数据的行为就必须被正确地管理否则程序将产生无法预计的结果。
为了协调多线程访问数据,JVM赋予每个对象和类一个锁(lock)。通俗来讲,一个锁相当于在任意时间点上仅有一个线程能拥有的特权。如果一个线程想要访问一个对象或者类,线程会向JVM发出请求。线程向JVM发出请求之后的某个时间点上,JVM会给线程分配对应的锁给请求该锁的线程(这个时间点或早或晚甚至会永远也不会到来)。当线程不再需要该锁,线程会将锁归还给JVM。之后如果其他线程需要这个锁,JVM会将其分配给其他线程。
类锁的实现与对象锁一致。当JVM加载一个类文件,JVM会创建一个类型为java.lang.Class的class实例。当你锁住一个class,你实际上是锁住了这个类的Class实例。
线程访问实例或者类变量时不需要获取对应的锁。如果一个线程拥有一个锁,任何其他的线程,直到拥有锁的线程自己释放锁,都不可访问加锁的数据。
监控器
JVM使用锁时会与监控器(monitors)配合。监控器可以理解为一个监视者,他监视着一系列的程序,保证在某一时间点只有一个线程在执行程序。
每个JVM会关联一个对象的引用。当一个线程到达了某个处于监控器监控下的代码块的第一条指令行,该线程必须持有该引用实例的锁。直到线程拥有对应的锁才能够被允许执行对应的程序。一旦线程拥有了锁,该线程就可以进入被保护程序的代码块中。
当线程离开了代码块,无论他是以何种形式离开,他都会释放相关对象实例对应的锁。
多重加锁
一个线程被允许能够多次锁住同一对象实例。对于每一个对象实例,JVM都会为其维护一个计数器来计算对象被锁住的次数。一个从未被加锁的对象,该计数器的值为0,当某个线程第一次获取这个对象的锁,这个计数器的值加1。每次线程获取到同一对象的锁,对应的计数器的值加1。每次线程释放锁,该计数器的值减1。当计数器的值为0,该实例的锁就会释放并且可重新分配给其他线程。
同步模块
在Java术语中,协调必须访问共享数据的多线程称为同步。Java提供了两种内置方式来同步访问数据:synchronization语句以及同步方法。
synchronization语句
为了使用synchronization语句,你需要使用synchronized关键字以及指向一个对象实例的说明语句。示例reverseOrder()如下:
class KitchenSync {
private int[] intArray = new int[10];
void reverseOrder() {
synchronized (this) {
int halfWay = intArray.length / 2;
for (int i = 0; i < halfWay; ++i) {
int upperIndex = intArray.length - 1 - i;
int save = intArray[upperIndex];
intArray[upperIndex] = intArray[i];
intArray[i] = save;
}
}
}
}
在上述例子中,处在synchronized模块中的代码直到当前对象(this)获取对应的锁时才会执行。如果替换this的引用使之指向另一个对象,在程序被执行前线程需要获取对应的锁。
两个操作指令monitorenter和monitorexit用于执行方法中的同步模块:
当JVM执行至monitorenter指令时,该操作指令会获取对应栈上引用的对象的锁。如果线程已经拥有该对象的锁则锁计数器加1。当monitorexit指令被执行,计数器减1。当计数器值为0,锁被释放。
同步方法
为了同步整个方法,我们只需在方法上加上synchronized关键字即可:
class HeatSync {
private int[] intArray = new int[10];
synchronized void reverseOrder() {
int halfWay = intArray.length / 2;
for (int i = 0; i < halfWay; ++i) {
int upperIndex = intArray.length - 1 - i;
int save = intArray[upperIndex];
intArray[upperIndex] = intArray[i];
intArray[i] = save;
}
}
}
JVM没有使用任何特殊的操作指令来调用或者返回同步的方法。当JVM解析某个方法的符号引用,JVM将决定这个方法是否为同步方法。如果是,JVM将在调用该方法前获取锁。对于一个实例方法,JVM在调用前会获取该对象对应的锁;对于一个class方法,JVM在调用该方法前会获取这个类对应的锁。当一个同步方法执行完毕,无论该方法是返回结果还是抛出异常,锁都会被释放。