深入Synchronized底层原理
对于synchronized大家应该都很熟悉,主要作用是在多线程并发时,保证线程访问共享数据时的线程安全。
它的作用有三点:
- 确保线程互斥的访问同步代码
- 保证共享为师的修改及时可见
- 有效解决指令重排(synchronized同步中的代码,JVM不会轻易优化重排序)
Synchronized使用
它的用法主要是从两个维度上来区分:
- 根据修饰对象的分类
- 修饰代码块
- synchronized(this|object)
- synchronized(类.class)
- 修饰方法
- 修饰非静态方法
- 修饰静态方法
- 修饰代码块
- 根据获取的锁来分类
- 获取对象锁
- synchronized(this|object)
- 修改非静态方法
- 获取类锁
- synchronized(类.class)
- 修饰静态方法
- 获取对象锁
1.对象锁
这个对象是新建的,跟其他对象无关:
public class SynchronizeDemo implements Runnable {
@Override
public void run() {
test1();
}
private void test1(){
System.out.println(Thread.currentThread().getName() + "_: " + new SimpleDateFormat("HH:mm:ss").format(new Date()));
synchronized (new SynchronizeDemo()){
try {
System.out.println(Thread.currentThread().getName() + "_start_: " + new SimpleDateFormat("HH:mm:ss").format(new Date()));
Thread.sleep(2000);
System.out.println(Thread.currentThread().getName() + "_end_: " + new SimpleDateFormat("HH:mm:ss").format(new Date()));
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
public static void main(String[] args) {
SynchronizeDemo sd1 = new SynchronizeDemo();
Thread thread1 = new Thread(new SynchronizeDemo(),"thread1");
Thread thread2 = new Thread(new SynchronizeDemo(),"thread2");
Thread thread3 = new Thread(sd1,"thread3");
Thread thread4 = new Thread(sd1,"thread4");
thread1.start();
thread2.start();
thread3.start();
thread4.start();
}
}
运行结果如图
四个线程同时开始,同时结束,因为作为锁的对象与线程是属于不同的实例
2.类锁
无所谓哪个类,都会被拦截
private void test2(){
System.out.println(Thread.currentThread().getName() + "_: " + new SimpleDateFormat("HH:mm:ss").format(new Date()));
synchronized (SynchronizeDemo.class){
try {
System.out.println(Thread.currentThread().getName() + "_start_: " + new SimpleDateFormat("HH:mm:ss").format(new Date()));
Thread.sleep(2000);
System.out.println(Thread.currentThread().getName() + "_end_: " + new SimpleDateFormat("HH:mm:ss").format(new Date()));
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
运行结果如下:
可以看到,类锁一次只能通过一个。
3.this对象锁
就是把synchronized (SynchronizeDemo.class)改为synchronized (this)
控制台打印结果
可能这显示结果有点歧义,其实多运行几次我们会发现,1和2是同时结束的,3和4永远有先后,因为3,4同属于一个实例
4.synchronized修饰方法
private synchronized void test4(){
...
}
打印的结果如下:
thread1_: 22:42:04
thread3_: 22:42:04
thread2_: 22:42:04
thread3_start_: 22:42:04
thread1_start_: 22:42:04
thread2_start_: 22:42:04
thread1_end_: 22:42:06
thread3_end_: 22:42:06
thread2_end_: 22:42:06
thread4_: 22:42:06
thread4_start_: 22:42:06
thread4_end_: 22:42:08
对于非静态方法,同一个实例的线程访问会被拦截,非同一实例可以同时访问,即此时默认的就是对象锁(this)
5.修饰静态方法的结果
在上面方法上加static
thread1_: 22:42:42
thread1_start_: 22:42:42
thread1_end_: 22:42:44
thread4_: 22:42:44
thread4_start_: 22:42:44
thread4_end_: 22:42:46
thread3_: 22:42:46
thread3_start_: 22:42:46
thread3_end_: 22:42:48
thread2_: 22:42:48
thread2_start_: 22:42:48
thread2_end_: 22:42:50
一样的可以看出来,静态方法默认使用的就是类锁
synchronized使用小结
- 对于静态方法,由于此时对象还没生成,所以默认采用的就是类锁(5)
- 而采用类锁,就会拦截所有线程,只能让一个线程访问(2)
- 对于对象锁this,如果是同一实例,那么按顺序执行,如果不是同一实例,就可以同时访问(3,4)
- 如果对象锁与访问的对象无关,那么就会都同时访问(1)
Synchronized原理
实际上,在JVM中,只区分两种不同的用法,修饰代码块与修饰方法,我们可以查看SE8规范,http://docs.oracle.com/javase/specs/jvms/se8/html/jvms-3.html#jvms-3.14
(英文不好,我有小助手怕不怕)大意是:Java虚拟机中的同步是通过显式(通过使用监视器输入和监视器输出指令)或隐式(通过方法调用和返回指令)的监视器输入和退出来实现的。 显示就是使用monitorenter和monitorexit来控制同步代码块;隐式是修饰方法,在运行时常量池中通过ACC_SYNCHRONIZED来标志。
多说无益,直接看它的字节码
public class Test {
public static void main(String[] args) {
}
public synchronized void test1() {
}
public void test2() {
synchronized (this) {
}
}
}
最简单的程序,通过使用javap -v Test.class
来查看它的字节码(注意是class文件,不是java文件)
public synchronized void test1();
descriptor: ()V
flags: ACC_PUBLIC, ACC_SYNCHRONIZED
Code:
stack=0, locals=1, args_size=1
0: return
LineNumberTable:
line 8: 0
LocalVariableTable:
Start Length Slot Name Signature
0 1 0 this LTest;
public void test2();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=2, locals=3, args_size=1
0: aload_0
1: dup
2: astore_1
3: monitorenter //监视器进入,获取锁
4: aload_1
5: monitorexit //监视器退出,释放锁
6: goto 14
9: astore_2
10: aload_1
11: monitorexit
12: aload_2
13: athrow
14: return
可以看到,果然字节码中,synchronized修饰代码块时,是使用monitorenter
和monitorexit
来控制,而synchronized修饰方法的时候,是使用ACC_SYNCHRONIZED
标识。
本质上都是对一个对象的monitor进行获取,而这个获取的过程是排他的,也就是同一时刻只能有一个线程获得同步块对象的监视器monitor。
线程执行到monitorenter指令时,会尝试获取对象所对应的monitor所有权,也就是尝试获取锁,执行到monitorexit,也就是释放所有权,释放锁。
要想理清synchronized的锁的原理,需要掌握两个重要的概念:
- 对象头
- monitor
java对象头
在Hotspot虚拟机中,对象在内存中的存储布局,可以分为三块:对象头Header,实例数据Instance Data,对齐填充Padding。
Hotspot虚拟机的对象头包含了两部分信息:
- Mark Word,用于存储对象自身的运行时数据,比如hash,gc分代年龄,锁状态的标志,线程持有锁,偏向ID,偏向时间戳等等
- Klass Pointer:对象指向它的类的元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。
32位HotSpot虚拟机的对象头存储结构如下
为了验证上图的正确,我们可以查看hotspot的源码
在线地址:http://hg.openjdk.java.net/jdk8u/jdk8u60/hotspot/file/37240c1019fd/src/share/vm/oops/markOop.hpp
public:
// Constants
enum { age_bits = 4,//分代年龄
lock_bits = 2,//锁标识
biased_lock_bits = 1,//是否偏向锁
max_hash_bits = BitsPerWord - age_bits - lock_bits - biased_lock_bits,
hash_bits = max_hash_bits > 31 ? 31 : max_hash_bits,//hask
cms_bits = LP64_ONLY(1) NOT_LP64(0),
epoch_bits = 2//偏向时间戳
};
hash:保存对象的哈希码
age:对象的分代年龄
biased_lock:偏向锁标识位
lock:锁状态标识位
JavaThread*:保存持有偏向锁的线程ID
epoch:保存偏向时间戳
所以,对象头中的Mark Word,synchronized源码就是用了对象头中的Mark Word来标识对象加锁状态。
monitor
Monitor Record是线程私有的数据结构,每一个线程都有一个可用monitor record列表,同时还有一个全局的可用列表。每一个被锁住的对象都会和一个monitor record关联(对象头的MarkWord中的LockWord指向monitor record的起始地址),同时monitor record中有一个Owner字段存放拥有该锁的线程的唯一标识,表示该锁被这个线程占用。如下图所示为Monitor Record的内部结构
线程唯一标识,当锁被释放时又设置为NULL;
EntryQ:关联一个系统互斥锁(semaphore),阻塞所有试图锁住monitor record失败的线程。
RcThis:表示blocked或waiting在该monitor record上的所有线程的个数。
Nest:用来实现重入锁的计数。
HashCode:保存从对象头拷贝过来的HashCode值(可能还包含GC age)。
Candidate:用来避免不必要的阻塞或等待线程唤醒,因为每一次只有一个线程能够成功拥有锁,如果每次前一个释放锁的线程唤醒所有正在阻塞或等待的线程,会引起不必要的上下文切换(从阻塞到就绪然后因为竞争锁失败又被阻塞)从而导致性能严重下降。Candidate只有两种可能的值0表示没有需要唤醒的线程1表示要唤醒一个继任线程来竞争
总结
简单总结一下,同步块使用monitorenter和monitorexit指令,而同步方法是依靠方法修饰符上的flag——ACC_SYNCHRONIZED来完成的。其本质都是对一个对象监视器monitor进行获取,这个获取过程是排他的,也就是同一时刻只能有一个线程获得由synchronized所保护的对象的监视器。而这个监视器,也可以理解为一个同步工具,它是由java对象进行描述的,在Hotspor中,是通过ObjectMonitor来实现,每个对象中天然都内置了一个ObjectMonitor对象。
在java中,synchronized在编译后,会在同步块的前后分别形成一个monitorenter和monitorexit这两个字节码指令,这两个字节码都需要一个reference类型的参数来指明要锁定和解锁的对象,如果java程序中明确指定了对象,那就是这个对象的reference,如果没有指明,那么根据synchronized修饰的是实例方法还是类方法,去取对应的对象实例或者类Class对象来做锁对象。
在执行monitorenter时,首先会尝试获取对象的锁,如果这个对象没有锁,或者当前线程已经拥有了这个对象的锁,那个锁的计数器加1,相应的,在执行monitorexit时指令时,会将锁计数器减1,当计数器为0时,这个锁就被释放。如果获取对象锁失败,那当前线程就要阻塞等待,直到对象锁被另一个线程释放为止。
扩展
synchronized同步块对同一线程来说是可重入的,不会出现自己把自己锁死的情况,其次,同步块在已进入的线程执行完成前,会阻塞后面的其他线程进入。我们知道,Java的线程是映射到操作系统中的的原生线程上的,如果要阻塞或者唤醒一个线程,都需要操作系统来帮忙,这就需要我们从用户态切换到核心态,因此这个状态转换是非常耗费CPU。如果这个代码非常简单的同步块,可能切换状态的时间比代码执行时间还长。所以synchronized是一个重量级的操作,虚拟机本身也做了大量的优化,引入了偏向锁,轻量级锁,重量级锁等,这一部分锁的升级,可以等以后有时间了,再慢慢探讨。当然还可以引入重入锁,解决synchronized过于重量的问题。
参考
《深入理解Java虚拟机》
我的掘金
下面是我的公众号,欢迎大家关注我