synchronized详解
设计同步器的意义
多线程编程中,有可能会出现多个线程同时访问同一个共享、可变资源的情况,这个资源我们称之其为临界资源;
这种资源可能是: 对象、变量、文件等。
- 共享:资源可以由多个线程同时访问
- 可变:资源可以在其生命周期内被修改
引出的问题: 由于线程执行的过程是不可控的,所以需要采用同步机制来协同对对象可变状态的访问!
如何解决线程并发安全问题?
实际上,所有的并发模式在解决线程安全问题时,采用的方案都是序列化访问临界资源。
即在同一时刻,只能有一个线程访问临界资源,也称作同步互斥访问。
Java 中,提供了两种方式来实现同步互斥访问:synchronized 和 Lock。
加锁目的:序列化访问临界资源,即同一时刻只能有一个线程访问临界资源(同步互斥访问)
不过有一点需要区别的是:当多个线程执行一个方法时,该方法内部的局部变量并不是临界资源,因为这些局部变量是在每个线程的私有栈中,因此不具有共享性,不会导致线程安全问题
synchronized原理详解
synchronized是基于JVM内置锁实现,通过内部对象Monitor(监视器锁)实现,基于进入与退出Monitor对象实现方法与代码块同步,监视器锁的实现依赖底层操作系统的Mutex lock(互斥锁)实现,它是一个重量级锁性能较低。
当然,JVM内置锁在1.5 之后版本做了重大的优化,如锁粗化(Lock Coarsening)、锁消除(Lock Elimination)、轻量级锁(Lightweight Locking)、偏向锁(Biased Locking)、适应性自旋(Adaptive Spinning)等技术来减少锁操作的开销,,内置锁的并发性能已经基本与 Lock持平。
我们知道synchronized加锁加在对象上,对象是如何记录锁状态的呢?
答案是锁状态是被记录在每个对象的对象头(Mark Word)中,下面我们一起认识一下对象的内存布局
对象的内存布局
对象头:
偏向锁
为了更好的弄清楚,引入org.openjdk.jol.info.ClassLayout
,我们看代码:
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.10</version>
</dependency>
public static void main(String[] args) throws InterruptedException {
Object o = new Object();
System.out.println(ClassLayout.parseInstance(o).toPrintable());
}
运行结果:
我们看标红的就是markWord。
00000001 00000000 00000000 00000000 这后面的是0。但是我们要反过来看
因为在操作系统分为2种模式:大端模式和小端模式。
我们在这里用的是小端模式:
00000000 00000000 00000000 00000001
这里new出来的对象是无锁状态。这里解释下对象的hashcode为什么是0
hashcode是懒加载方式,所以高25位一开始没打印出来。
那我们开始升级锁,看代码:
public static void main(String[] args) throws InterruptedException {
Object o = new Object();
System.out.println(ClassLayout.parseInstance(o).toPrintable());
synchronized (o){
System.out.println(ClassLayout.parseInstance(o).toPrintable());
}
}
运行结果:
那为什么会直接升级为轻量级锁,不应该是先升级成偏向锁吗?
JVM会延迟去启动偏向锁,大概是4秒钟时间。原因:
JVM在启动的时候会加载hashmap,class等等,这里面也会有大量的同步块。而且还有10几个线程。JVM内部本身有一些竞争,为了减少锁升级带来的开销,把偏向锁推迟启动了。
我们延迟5秒去启动,再来看下。默认是开启偏向锁。
public static void main(String[] args) throws InterruptedException {
TimeUnit.SECONDS.sleep(5);
Object o = new Object();
System.out.println(ClassLayout.parseInstance(o).toPrintable());
synchronized (o){
System.out.println(ClassLayout.parseInstance(o).toPrintable());
}
}
运行结果:
我们看见第一个打印没有加同步块的:00000101 00000000 00000000 00000000
第二个打印是加了同步块的:00000101 10111000 00111100 00000010
难道说我们启动了偏向锁后,即使没有同步块也会加锁吗?
我们称没加同步块的为匿名偏向
,00000101 00000000 00000000 00000000
。后边的都是0。代表着当前是可偏向状态。可以理解为已经预先做好准备了,可以偏向,但是还没有偏向,至于偏向谁还不知道。
再给大家看个例子:
@Slf4j
public class T0_BasicLock {
public static void main(String[] args) {
try {
Thread.sleep(5000);
} catch (InterruptedException e) {
e.printStackTrace();
}
Object o = new Object();
log.info(ClassLayout.parseInstance(o).toPrintable());
new Thread(()->{
synchronized (o){
log.info(ClassLayout.parseInstance(o).toPrintable());
}
}).start();
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
log.info(ClassLayout.parseInstance(o).toPrintable());
new Thread(()->{
synchronized (o){
log.info(ClassLayout.parseInstance(o).toPrintable());
}
}).start();
}
}
运行结果:线程交替执行最后会升级轻量级锁。
模拟下升级重量级锁:
public class T0_heavyWeightMonitor {
public static void main(String[] args) throws InterruptedException {
Thread.sleep(5000);
Object a = new Object();
Thread thread1 = new Thread(){
@Override
public void run() {
synchronized (a){
System.out.println("thread1 locking");
System.out.println(ClassLayout.parseInstance(a).toPrintable());
try {
//让线程晚点儿死亡,造成锁的竞争
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
};
Thread thread2 = new Thread(){
@Override
public void run() {
synchronized (a){
System.out.println("thread2 locking");
System.out.println(ClassLayout.parseInstance(a).toPrintable());
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
};
thread1.start();
thread2.start();
}
}
竞争非常激烈:
锁的膨胀升级过程
锁的状态总共有四种,无锁状态、偏向锁、轻量级锁和重量级锁。
随着锁的竞争,锁可以从偏向锁升级到轻量级锁,再升级的重量级锁,但是锁的升级是单向的,也就是说只能从低到高升级,不会出现锁的降级。
从JDK 1.6 中默认是开启偏向锁和轻量级锁 的,可以通过-XX:-UseBiasedLocking来禁用偏向锁。
关于synchronized的使用可以看我的下篇博客 synchronize的8锁现象带你彻底了解