基本概念
在Java共享内存模型的设计中,多个线程并发操作共享资源就会有线程安全问题。例如下面的程序两个线程并发对静态变量分别做自增和自减操作,如果没有线程安全问题最终运算结果应该是0。
public class SyncDemo {
private static volatile int counter = 0;
public static void increment() {
counter++;
}
public static void decrement() {
counter--;
}
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
increment();
}
}, "t1");
Thread t2 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
decrement();
}
}, "t2");
t1.start();
t2.start();
t1.join();
t2.join();
log.info("counter={}", counter);
}
}
控制台打印结果:
每次运算结果可能都不相同,有可能为正数有可能为负数,也有可能是0,这显然是存在线程安全问题的。为什么counter变量被volatile修饰了,还存在线程安全问题呢?首先是因为volatile只能保证可见性不能保证原子性,其次i++这种自增或i--这种自减操作并不是原子操作。我们使用jclasslib插件可以看到i++的JVM字节码指令如下:
//i++
getstatic #2 //获取静态变量的值,#2指向的是常量池中的第2个常量,就是counter
iconst_1 //将int类型的常量1压入操作数栈
iadd //自增
putstatic #2 //将修改后的值存入静态变量counter
i--的JVM字节码指令如下:
getstatic #2 //获取静态变量counter的值
iconst_1 //将int类型的常量1压入操作数栈
isub //自减
putstatic #2 //将修改后的值存入静态变量counter
对于单线程,以上的指令一定是串行执行的,但在多线程环境下这8条指令有可能互相交错执行,例如有一种可能的执行顺序如下图:
临界区和临界资源
当多个线程对共享资源执行写操作发生指令交错时,就会出现线程安全问题。一段代码块内如果存在对共享资源的多线程读写操作,这段代码块就叫做临界区,其共享资源叫做临界资源。上面例子中的counter共享变量就是临界资源,而increment()方法和decrement()方法都存在对临界资源的写操作,它们就是临界区。
竟态条件(Race Condition)
多个线程在临界区内执行,由于代码的执行序列不同而导致结果无法预测,称之为发生了竟态条件。为了避免临界区的竟态条件发生,可以使用以下方式:
- 阻塞式解决方案:synchronized、Lock;
- 非阻塞式解决方案:原子变量。
synchronized简介
synchronized是Java提供的一种原子性内置锁,Java中的每个对象都可以把它当作一个同步锁来使用,这些Java内置的使用者看不到的锁被称为内置锁,也叫监视器锁。
synchtonized可以完成互斥和同步功能,区别如下:
- 互斥是保证临界区的竟态条件发生,同一时刻只能有一个线程执行临界区代码;
- 同步是由于线程执行的先后顺序不同,某个线程需要等待其他线程运行到某个点。
synchronized可以使用在多个地方,使用位置决定了锁对象,具体如下表:
位置分类 | 具体分类 | 伪代码 | 被锁的对象 |
方法 | 实例方法 | public synchronized void mathod() { } | 调用该方法的类的实例对象 |
静态方法 | public static synchronized void method() { } | 类对象 | |
代码块 | 实例对象 | synchronized(this) { } | 执行该同步代码块的实例对象 |
class对象 | synchronized(User.class) { } | 类对象 | |
任意实例对象 | User user = new User(); synchronized(user) { } | 实例对象 |
使用synchronized实例方法或synchronized同步代码块可以解决上面的线程安全问题,只需要在临界区(increment()和decerment()方法)加上synchronized即可。
public synchronized static void increment() {
counter++;
}
public synchronized static void decrement() {
counter--;
}
修改之后的代码,重新执行结果如下:
synchronized关键字通过对象锁保证了临界区内的代码的原子性,上面测试程序在使用了synchronized修饰后新的时序图如下图:
synchronized底层原理
Monitor
Monitor直译为监视器,在操作系统领域也叫管程,指的是管理共享变量以及对共享变量操作的过程,让它们支持并发。
在管程的发展史上,先后出现过三种不同的管程模型,分别为Hasen模型、Hoare模型和MESA模型。下面对使用较广泛的MESA模型做一下介绍。
MESA模型
MESA模型将共享变量和对共享变量的操作统一封装起来,只保留一个入口,在这个入口处有一个等待队列,当多个线程试图进入管程内部时,只会允许一个线程进入,其他线程在等待队列中等待。
MESA模型还引入了条件变量的概念,在管程内部,存在一些条件变量和条件变量对应的等待队列。如果线程T1进入管程运行一段时间后需要触发某个条件A才能继续执行,线程T1就会进入条件变量是A的等待队列,随后管程入口的等待队列就可以有一个线程T2进入管程继续执行。在T2执行一段时间后,触发了条件A,线程T2就可以通知条件变量A所对应的等待队列中的一个或多个线程,被通知的线程T1从条件等待队列中出来再进入管程入口处的等待队列中等待执行。根据以上描述我们可以看出来条件变量和条件变量等待队列是用来解决线程之间的同步问题的。
MESA模型可以表示如下图:
通过以上我们得知,线程T1被唤醒后进入了管程入口的等待队列,并没有立即获取锁执行。这就会导致有可能当线程T1从等待队列再次进入管程时它需要的条件A已经又不满足了,为了解决这个问题,应该在线程T1等待触发条件A的地方循环检验条件A,依照以下编程范式:
while(条件不满足) {
wait();
}
这样当线程T1再次从等待队列进入管程执行且条件A又不满足时,线程T1会再次调用wait()方法进入条件变量A的等待队列中,直到条件A再次触发。
Java的内置管程
Java参考了MESA模型,其内置的管程synchronized对MESA模型进行了精简,主要是将MESA模型中的多个条件变量改成了一个。synchronized关键字、wait()方法、notify()方法和notifyAll()方法就是实现管程技术的组成部分。
我们将这三个方法代入到上面管程的举例中。例如线程T1获得锁进入管程执行,执行一段时间后由于需要满足条件A(flag=true)才能继续执行,因此调用wait()方法等待条件A满足,如下:
while(!flag) {
wait();
}
线程T1进行条件变量A(flag=true)的等待队列中,随后线程T2从管程入口的等待队列进入管程执行将flag共享变量修改为true,调用notify()方法唤醒线程T1,如下:
flag = true;
notify();
线程T1被唤醒从条件变量A的等待队列进入管程入口的等待队列,等待线程T2释放锁之后再获取锁继续执行wait()方法之后的代码。
synchronized的底层实现
synchronized是基于Monitor机制实现的JVM内置锁,依赖于底层操作系统的互斥原语Mutex,因此它在使用时需要从用户态切换到内核态,是一个重量级锁,性能较低。但在JDK1.5之后synchronized做了许多优化,包括锁粗化、锁消除、轻量级锁、偏向锁和自适应自旋等技术,这使得内置锁的性能与Lock几乎持平。
synchronized同步方法是通过方法中的access_flags中设置ACC_SYNCHRONIZED标志来实现的,例如我们使用jclasslib查看被synchronized修饰的decrement()方法的字节码文件如下图:
synchronized同步代码块是通过monitorenter和monitorexit两个指令来实现的,这两个指令的执行是JVM通过调用操作系统的互斥原语mutex来实现,会导致用户态和内核态直接来回切换,对性能有较大影响。将decrement()修改为使用synchronized代码块,其字节码文件如下图:
java.lang.Object类的wait()、notify()和notifyAll()方法都是本地方法,其具体实现都需要依赖ObjectMonitor,也就是说每个Object都有一个ObjectMonitor,是JVM内部基于C++实现的一套机制。ObjectMonitor主要数据结构如下:
ObjectMonitor() {
_header = NULL; //对象头 markOop
_count = 0;
_waiters = 0,
_recursions = 0; // 锁的重入次数
_object = NULL; //存储锁对象
_owner = NULL; // 标识拥有该monitor的线程(当前获取锁的线程)
_WaitSet = NULL; // 等待线程(调用wait)组成的双向循环链表,_WaitSet是第一个节点
_WaitSetLock = 0 ;
_Responsible = NULL ;
_succ = NULL ;
_cxq = NULL ; //多线程竞争锁会先存到这个单向链表中 (FILO栈结构)
FreeNext = NULL ;
_EntryList = NULL ; //存放在进入或重新进入时被阻塞(blocked)的线程 (也是存竞争锁失
败的线程)
_SpinFreq = 0 ;
_SpinClock = 0 ;
OwnerIsThread = 0 ;
_previous_owner_tid = 0;
}
当某个线程获取当前对象的锁时,将这个线程放到当前对象的ObjectMonitor的cxq栈的头部。当释放锁时,默认的策略是:如果EntryList为空,则将cxq中的元素按原有顺序插入到EntryList,并唤醒第一个线程,即当EntryList为空时,首先唤醒的是后来的线程;如果EntryList不为空,则直接从EntryList中唤醒线程。这个过程可以表示如下图:
那么当synchronized将锁加在对象上时,对象是如何记录锁状态的呢?这就不得不提到Java对象的构成了。
Java对象的内存布局
Hotspot虚拟机中,对象在内存中的存储布局可以分为三块区域:对象头、实例数据和对齐填充。其中对象头又包括MarkWord、Klass Pointer以及当当前对象是数组时还有一个数组长度。对象中记录锁状态的部分就是在MarkWord中,其他的部分我们在JVM部分已经介绍过了,此处不再赘述。
32位JVM的对象头的结构可以表示如下图:
64位JVM的对象头的结果可以表示如下图:
对象头使用低3bit来表示锁标志位,以及根据不同的锁状态其他位存储相应的内容,具体如下表:
锁状态 | 存储内容 | 偏向锁标志位 | 锁标志位 |
无锁 | hashcode,分代年龄 | 0 | 01 |
偏向锁 | 线程id,偏向时间戳,分代年龄 | 1 | 01 |
轻量级锁 | 指向栈中锁记录的指针 | 无 | 00 |
重量级锁 | 指向Monitor的指针 | 无 | 10 |
GC标记 | 无 | 无 | 11 |
对象锁状态
对象的锁状态主要包括无锁、偏向锁、轻量级锁和重量级锁。
无锁
无锁指的是没有对共享资源进行锁定,所有的线程都可以访问且修改这个共享资源,但同时修改时只有一个线程能修改成功,其他的都被覆盖了。
偏向锁
偏向锁是一种针对加锁操作的优化手段。由于大多数情况下,锁不仅不存在多线程竞争,而且总是由同一个线程多次获得,为了消除数据在无竞争情况下锁重入(CAS操作)的开销才引入的偏向锁。偏向锁是指当一段同步代码一直被同一个线程访问时,即不存在多线程竞争时,该线程在后续访问时会自动获得锁,通过减少获取锁来提高性能。
当某个synchronized代码块第一次被线程T1访问时,线程T1通过CAS修改synchronized锁住的对象的对象头中的锁状态为偏向锁,线程id指向访问代码块的线程T1。持有偏向锁的线程不会主动释放锁,即线程T1执行完synchronized代码块后,对象头的锁状态依旧是偏向锁,且线程id仍指向线程T1。当线程T1再次访问该代码块时,只需要判断对象头的线程id是否指向当前线程T1的偏向锁即可,如果是则直接获取锁,不需要再使用CAS操作来加锁。
另外,当JVM开启了锁偏向模式时,新创建对象的初始锁状态就是偏向锁。锁偏向模式是在JDK6默认开启的。
当存在其他线程尝试竞争偏向锁时,偏向锁就会升级为轻量级锁。需要注意的是,这里的锁竞争指的是某个线程尝试获取偏向锁的时候,发现该锁已经被占用,只能等待其释放。对于多个线程轮流获取一个偏向锁,但每次获取锁的时候都很顺利没有发生阻塞的情况,这不算锁竞争。
偏向锁的延迟偏向
偏向锁具有偏向锁延迟机制:HotSpot虚拟机在启动后有4S左右的延迟才会对每个新建的对象开启偏向锁模式。这样做的原因是JVM在启动时会进行一系列的复杂活动,在这个过程中会使用大量的synchronized关键字对对象加锁,且这些锁大多数都不是偏向锁。为了减少初始化时间,JVM默认延时加载偏向锁。
我们使用JOL(Java Object Layout)工具来打印输出在JVM刚启动时一个对象的对象头和JVM启动5S后另一个对象的对象头,验证以上说法。
测试程序:
public class LockEscalationDemo{
public static void main(String[] args) throws InterruptedException {
log.debug(ClassLayout.parseInstance(new Object()).toPrintable());
Thread.sleep(5000);
log.debug(ClassLayout.parseInstance(new Object()).toPrintable());
}
}
控制台打印结果如下:
可以看到JVM刚启动时的锁状态是无锁状态,休眠5S后,新创建的对象变成了偏向锁状态。但线程id仍旧是0(需要注意的是此处的线程id不是Java对象的线程id),此时处于可偏向但未偏向任何线程,也叫匿名偏向状态。
上图有一个问题是明明对象头中的MarkWord是使用低三位来表示锁标志位的,为什么上图中锁标志位显示在前面?这就需要对字节序做一个简单的介绍。
计算机硬件有两种存储数据的方式:大端字节序和小端字节序。
- 大端字节序(Big Endian):高位字节在前,低位字节在后,也就是我们常见的二进制的表示方式;
- 小端字节序(Little Endian):低位字节在前,高位字节在后,也就是上图中的表示方式。
那么为什么需要使用小端字节序这种我们不易查看的存储方式呢?原因是计算机电路的计算都是从低位开始的,先处理低位字节效率较高,因此需要小端字节序这种存储方式,上面就是使用小端字节序的方式来显示的。
需要注意的是,一个无锁对象在偏向锁模式开启后仍然是无锁状态,且这个对象被某个线程加锁也不会变成偏向锁模式,而是直接升级为轻量级锁。下面的程序可以验证以上说法,在JVM刚启动时创建了一个obj对象,在JVM刚启动时、休眠5S偏向锁模式开启后和被synchronized锁定后都打印了该对象的对象头。
public class LockEscalationDemo{
public static void main(String[] args) throws InterruptedException {
Object obj = new Object();
log.debug(ClassLayout.parseInstance(obj).toPrintable());
Thread.sleep(5000);
log.debug(ClassLayout.parseInstance(obj).toPrintable());
new Thread(new Runnable() {
@SneakyThrows
@Override
public void run() {
log.debug(Thread.currentThread().getName()+"开始执行。。。\n"
+ClassLayout.parseInstance(obj).toPrintable());
synchronized (obj){
log.debug(Thread.currentThread().getName()+"获取锁执行中。。。\n"
+ClassLayout.parseInstance(obj).toPrintable());
}
log.debug(Thread.currentThread().getName()+"释放锁。。。\n"
+ClassLayout.parseInstance(obj).toPrintable());
/*Thread.sleep(10000);*/
}
},"thread1").start();
}
}
控制台打印结果如下,可以看到这个对象在JVM刚启动时和休眠5S偏向锁模式开启后都是无锁状态,被线程1锁定后直接升级为轻量级锁状态。
21:33:07.086 [main] DEBUG com.tuling.jucdemo.sync.LockEscalationDemo - java.lang.Object object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) e5 01 00 20 (11100101 00000001 00000000 00100000) (536871397)
12 4 (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
21:33:12.097 [main] DEBUG com.tuling.jucdemo.sync.LockEscalationDemo - java.lang.Object object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) e5 01 00 20 (11100101 00000001 00000000 00100000) (536871397)
12 4 (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
21:33:12.099 [thread1] DEBUG com.tuling.jucdemo.sync.LockEscalationDemo - thread1开始执行。。。
java.lang.Object object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) e5 01 00 20 (11100101 00000001 00000000 00100000) (536871397)
12 4 (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
21:33:12.099 [thread1] DEBUG com.tuling.jucdemo.sync.LockEscalationDemo - thread1获取锁执行中。。。
java.lang.Object object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 58 f7 7f c1 (01011000 11110111 01111111 11000001) (-1048578216)
4 4 (object header) 61 00 00 00 (01100001 00000000 00000000 00000000) (97)
8 4 (object header) e5 01 00 20 (11100101 00000001 00000000 00100000) (536871397)
12 4 (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
21:33:12.100 [thread1] DEBUG com.tuling.jucdemo.sync.LockEscalationDemo - thread1释放锁。。。
java.lang.Object object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) e5 01 00 20 (11100101 00000001 00000000 00100000) (536871397)
12 4 (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
基于以上证明,我们做出以下总结:
- 偏向锁模式开启后新创建的对象是可偏向状态(匿名偏向),对象头的标志位为101偏向锁,但线程id为0,即没有偏向任何线程;
- 无锁状态不会升级为偏向锁,被synchronized锁定后直接升级为轻量级锁。
偏向锁撤销
前面介绍对象头的MarkWord时,如果对象处于偏向锁状态,MarkWord是没有存储当前对象的hashcode的值的,那如果我们调用该对象的hashcode()方法会发生什么呢?在下面的程序中,分别打印了JVM刚启动时、睡眠5S后、线程1获取对象锁之前、线程1获取到对象锁之后以及线程1释放锁后的对象锁状态,并且在睡眠5S后和线程1获取对象锁之前调用了对象的hashcode()方法。
public class LockEscalationDemo{
public static void main(String[] args) throws InterruptedException {
log.debug(ClassLayout.parseInstance(new Object()).toPrintable());
//HotSpot 虚拟机在启动后有个 4s 的延迟才会对每个新建的对象开启偏向锁模式
Thread.sleep(5000);
log.debug(ClassLayout.parseInstance(new Object()).toPrintable());
Object obj = new Object();
obj.hashCode();
log.debug(ClassLayout.parseInstance(obj).toPrintable());
new Thread(new Runnable() {
@Override
public void run() {
log.debug(Thread.currentThread().getName()+"开始执行。。。\n"
+ClassLayout.parseInstance(obj).toPrintable());
synchronized (obj){
log.debug(Thread.currentThread().getName()+"获取锁执行中。。。\n"
+ClassLayout.parseInstance(obj).toPrintable());
}
log.debug(Thread.currentThread().getName()+"释放锁。。。\n"
+ClassLayout.parseInstance(obj).toPrintable());
}
},"thread1").start();
控制台打印结果如下:
23:41:26.817 [main] DEBUG com.tuling.jucdemo.sync.LockEscalationDemo - java.lang.Object object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) e5 01 00 20 (11100101 00000001 00000000 00100000) (536871397)
12 4 (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
23:41:31.832 [main] DEBUG com.tuling.jucdemo.sync.LockEscalationDemo - java.lang.Object object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 05 00 00 00 (00000101 00000000 00000000 00000000) (5)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) e5 01 00 20 (11100101 00000001 00000000 00100000) (536871397)
12 4 (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
23:41:31.835 [thread1] DEBUG com.tuling.jucdemo.sync.LockEscalationDemo - thread1开始执行。。。
java.lang.Object object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 01 fd 72 cf (00000001 11111101 01110010 11001111) (-814547711)
4 4 (object header) 3e 00 00 00 (00111110 00000000 00000000 00000000) (62)
8 4 (object header) e5 01 00 20 (11100101 00000001 00000000 00100000) (536871397)
12 4 (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
23:41:31.836 [thread1] DEBUG com.tuling.jucdemo.sync.LockEscalationDemo - thread1获取锁执行中。。。
java.lang.Object object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 98 f2 9f a8 (10011000 11110010 10011111 10101000) (-1465912680)
4 4 (object header) 36 00 00 00 (00110110 00000000 00000000 00000000) (54)
8 4 (object header) e5 01 00 20 (11100101 00000001 00000000 00100000) (536871397)
12 4 (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
23:41:31.837 [thread1] DEBUG com.tuling.jucdemo.sync.LockEscalationDemo - thread1释放锁。。。
java.lang.Object object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 01 fd 72 cf (00000001 11111101 01110010 11001111) (-814547711)
4 4 (object header) 3e 00 00 00 (00111110 00000000 00000000 00000000) (62)
8 4 (object header) e5 01 00 20 (11100101 00000001 00000000 00100000) (536871397)
12 4 (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
从程序打印结果我们可以看到:
- JVM启动时,对象处于无锁状态;
- 线程睡眠5S后,对象处于偏向锁状态;
- 此处调用hashcode()方法;
- 线程1获取锁之前,对象处于无锁状态;
- 线程1获取锁后,对象处于轻量级锁状态;
- 线程1释放锁后,对象处于无锁状态。
由此我们可以得出当对象处于可偏向状态(锁标志位101,线程id为0)状态时,调用hashcode()方法会让对象再也不会偏向,原因就是对象的hashcode只会计算一次,偏向锁状态的对象头是无法存储hashcode的,因此对象无法进入偏向锁状态。轻量级锁的hashcode存储在锁记录中,重量级锁的hashcode存储在Monitor中,因此只有偏向锁无法记录hashcode。
然后我们把hashcode()方法的调用挪到线程1获取锁之后,修改后的程序代码如下:
public class LockEscalationDemo{
public static void main(String[] args) throws InterruptedException {
log.debug(ClassLayout.parseInstance(new Object()).toPrintable());
//HotSpot 虚拟机在启动后有个 4s 的延迟才会对每个新建的对象开启偏向锁模式
Thread.sleep(5000);
Object obj = new Object();
log.debug(ClassLayout.parseInstance(obj).toPrintable());
new Thread(new Runnable() {
@Override
public void run() {
log.debug(Thread.currentThread().getName()+"开始执行。。。\n"
+ClassLayout.parseInstance(obj).toPrintable());
synchronized (obj){
obj.hashCode();
log.debug(Thread.currentThread().getName()+"获取锁执行中。。。\n"
+ClassLayout.parseInstance(obj).toPrintable());
}
log.debug(Thread.currentThread().getName()+"释放锁。。。\n"
+ClassLayout.parseInstance(obj).toPrintable());
}
},"thread1").start();
控制台打印结果:
23:50:55.433 [main] DEBUG com.tuling.jucdemo.sync.LockEscalationDemo - java.lang.Object object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) e5 01 00 20 (11100101 00000001 00000000 00100000) (536871397)
12 4 (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
23:51:00.453 [main] DEBUG com.tuling.jucdemo.sync.LockEscalationDemo - java.lang.Object object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 05 00 00 00 (00000101 00000000 00000000 00000000) (5)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) e5 01 00 20 (11100101 00000001 00000000 00100000) (536871397)
12 4 (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
23:51:00.455 [thread1] DEBUG com.tuling.jucdemo.sync.LockEscalationDemo - thread1开始执行。。。
java.lang.Object object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 05 00 00 00 (00000101 00000000 00000000 00000000) (5)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) e5 01 00 20 (11100101 00000001 00000000 00100000) (536871397)
12 4 (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
23:51:00.455 [thread1] DEBUG com.tuling.jucdemo.sync.LockEscalationDemo - thread1获取锁执行中。。。
java.lang.Object object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 1a 87 e3 3e (00011010 10000111 11100011 00111110) (1055098650)
4 4 (object header) 78 01 00 00 (01111000 00000001 00000000 00000000) (376)
8 4 (object header) e5 01 00 20 (11100101 00000001 00000000 00100000) (536871397)
12 4 (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
23:51:00.456 [thread1] DEBUG com.tuling.jucdemo.sync.LockEscalationDemo - thread1释放锁。。。
java.lang.Object object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 1a 87 e3 3e (00011010 10000111 11100011 00111110) (1055098650)
4 4 (object header) 78 01 00 00 (01111000 00000001 00000000 00000000) (376)
8 4 (object header) e5 01 00 20 (11100101 00000001 00000000 00100000) (536871397)
12 4 (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
从程序打印结果我们可以得到:
- JVM启动时,对象锁处于无锁状态;
- 睡眠5S后,对象锁处于偏向锁状态;
- 线程1获取锁之前,对象锁处于偏向锁状态;
- 此处调用hashcode()方法;
- 线程1获取锁之后,对象锁处于重量级锁状态;
- 线程1释放锁之后,对象锁处于重量级锁状态。
由此我们可以得出当对象处于偏向锁状态时,调用hashcode()方法会让该锁直接升级为重量级锁。
以上是由hashcode引起的偏向锁撤销。wait(timeout)方法和notify()方法也会使偏向锁撤销,其中notify()方法会让偏向锁升级为轻量级锁,wait(timeout)会让偏向锁升级为重量级锁。此处就不再演示了。
轻量级锁
当在偏向锁状态下发生锁竞争时,偏向锁就会升级为轻量级锁。获取锁的操作其实就是通过CAS操作修改对象头的锁标志位,先比较当前锁标志位是否变成了无锁状态(001),如果是则将其设置为轻量级锁状态并设置锁记录指针指向当前线程的锁记录中。
重量级锁
如果锁竞争情况严重,某个线程通过CAS获取锁失败,这个线程会将轻量级锁升级为重量级锁,使用CAS操作修改锁标志位,但不修改持有锁的线程id。后续的线程尝试获取该锁时,发现被占用的锁是重量级锁,就会直接将自己挂起而不是忙等,直到被唤醒。
需要注意的是,当重量级锁被释放时虽然对象锁状态应该变为无锁,但由于重量级锁状态的对象头中指向的是Monitor,对象头的锁状态需要与Monitor回收同步修改,因此在重量级锁刚被释放时锁状态可能还是显示重量级锁(10),过一段时间才会被置为无锁状态(001)。
锁升级
锁对象状态转换
无锁、偏向锁、轻量级锁和重量级锁之间的状态转换可以总结如下:
- 对于禁用了类的偏向锁定模式或JVM刚启动时创建的对象,初始锁状态是无锁状态;
- 对于启用了偏向锁定模式或JVM启动约4S后创建的对象,初始锁状态是匿名偏向锁状态;
- 处于无锁状态的对象被某个线程加锁后直接升级为轻量级锁,不会升级到偏向锁状态;
- 处于匿名偏向锁状态的对象被某个线程加锁后,其对象头中的线程id指向这个线程,变为偏向锁状态;
- 处于偏向锁状态但此时未锁定的对象,发生偏向锁撤销后(例如调用hashcode()方法)会变为无锁状态;
- 处于偏向锁状态且此时被锁定的对象,发生偏向锁撤销后会升级为轻量级锁状态;
- 处于偏向锁状态且此时被锁的的对象,在同步块内调用hashcode()方法或wait()方法都会直接升级到重量级锁状态;
- 处于轻量级锁状态的对象当线程竞争激烈时会碰撞为重量级锁;
- 轻量级锁和重量级锁在锁释放后都会变为无锁状态。
以上过程可以表示如下图:
synchronized锁优化
synchronized锁针对一些锁状态和特殊的场景做了相应的一些优化,包括针对偏向锁的批量重定向和批量撤销、针对重量级锁的自旋优化、针对同一个对象的反复加锁和解锁场景的锁粗化、针对不必要的加锁操作的锁消除等。
偏向锁批量重偏向和批量撤销
偏向锁在只有一个线程反复进入同步块时,减少了这个线程加锁解锁的性能开销。但在偏向锁状态有其他线程尝试获取锁时,就需要等到safe point(安全点)时将偏向锁撤销为无锁状态或升级为轻量级锁,就会消耗一定的性能。因此在多线程竞争频繁的情况下,使用偏向锁反而会影响性能。因此引入了偏向锁批量重偏向和批量撤销机制。
批量重偏向机制是为了解决一个线程创建了大量对象并执行了初始的同步操作,随后另一个线程来讲这些对象作为锁对象进行操作导致的大量的偏向锁撤销操作的问题;批量撤销机制是为了解决多线程竞争激烈场景下使用偏向锁的问题。
原理
以class为单位,为每个class维护一个偏向锁撤销计数器,每一次该class的对象发生偏向锁撤销操作时,该计数器+1。当这个值达到重偏向阀值(默认20)时,JVM就认为该class的偏向锁有问题,因此就会进行批量重偏向。
每个class对象都有一个对应的epoch字段,每个处于偏向锁状态对象的MarkWord中也有该字段,其初始值为创建该对象时class中的epoch的值。每次发生批量重偏向时,就将epoch+1,同时遍历JVM中所有线程的栈,找到该class所有正处于加锁状态的偏向锁,将其epoch字段修改为新的值。下次获得锁时,发现当前对象的epoch值和class的epoch值不相等,就表示已经偏向了其他线程,此时不会执行撤销操作,而是直接通过CAS操作将其MarkWord的Thread id修改为当前线程。
当epoch的值达到重偏向的阈值(默认20)后,假设class的计数器继续增长,直到达到批量撤销的阈值(默认40)后,JVM就认为该class的使用场景存在多线程竞争,会将该class标记为不可偏向,此后该class的对象的锁不会再变成偏向锁状态,而是直接变成轻量级锁。
- 批量重定向和批量撤销是针对类的优化,与对象无关;
- 偏向锁重偏向一次之后不可再次重偏向;
- 当某个类已经出发批量撤销机制后,JVM会默认该类产生了严重的问题,剥夺了该类的新的实例对象使用偏向锁的权利。
自旋优化
由于重量级锁的加锁解锁需要用户态和内核态的切换,这是一笔很大的开销,因此可以在重量级锁竞争时使用自旋,如果某个线程通过自旋获取了锁,就可以避免这个线程的阻塞。但自旋的线程也在运行,即也占用CPU时间。
在JDK1.6之后自旋是自适应的,例如对象刚刚的一次自旋操作成功过,就认为自旋成功的可能性高,自旋次数增加几次;否则,就减少自旋次数甚至不自旋。
锁粗化
如果一系列的连续操作都会对同一个对象反复加锁及解锁,甚至加锁操作是出现在循环体中的,这样即使没有出现线程竞争,频繁地进行互斥同步操作也会导致不必要的性能损耗。JVM对此作了锁粗化的优化,即当JVM检测到有一连串零碎的操作都是对同一对象的加锁,将会扩大加锁同步的范围到整个操作序列的外部。
锁消除
锁消除指的是删除不必要的加锁操作。JVM在JIT编译期间,通过对运行上下文的扫描,去除不可能存在共存资源竞争的锁。锁消除可以节省毫无意义的请求锁时间。
逃逸分析
逃逸分析是一种可以有效减少Java程序中同步负载和内存对分配压力的跨函数全局数据流分析算法。通过逃逸分析,Java Hotspot编译器能够分析出一个新的对象的引用的使用范围从而决定是否要将这个对象分配到堆上。逃逸分析的基本行为是分析对象动态作用域。
逃逸分析包括方法逃逸和线程逃逸两种:
- 方法逃逸:当一个对象在方法中被定义后,如果这个对象可能被外部方法所引用,例如作为返回参数传递到方法外,就认为这个对象是方法逃逸的。
- 线程逃逸:一个对象可能被其他线程访问到,例如赋值给类变量或可以在其他线程中访问的实例变量等,就认为这个对象是线程逃逸的。
通过逃逸分析,编译器可以对代码做出以下优化:
- 同步省略或锁消除:如果一个对象被发现只能从一个线程中被访问到,那么这个对象的操作可以不考虑同步;
- 将堆分配转化为栈分配:对于一个非逃逸对象,如果其满足标量替换条件,且可以在栈上分配,则优先考虑在栈上分配。