synchronized底层浅析(一)
之前说过hashMap,我们知道hashMap是一种非线程安全的集合,主要原因是它在多线程的情况下,插入、删除、扩容的时候容易导致数据丢失或者链表环
那我们也知道ConcurrentHashMap、hashTable是线程安全的,我们看hashTable的源码时,会发现源码中很多方法都是加了synchronized 关键字的 那我们今天就来分析下synchronized 关键字的使用场景及如何起作用。
使用场景:
synchronized关键字最主要有以下3种应用方式
1.修饰普通方法,作用于当前实例加锁,进入同步方法前要获得当前实例的锁
public class SynchronizedTest implements Runnable{ static int i =0; public synchronized void increase(){ System.out.println(i++); System.out.println("进入增加方法:"+Thread.currentThread().getName()); } @Override public void run() { System.out.println(Thread.currentThread().getName()+"在执行"); for(int j=0;j<10;j++){ System.out.println("即将进入增加方法:"+Thread.currentThread().getName()); increase(); } } public static void main(String[] args) { SynchronizedTest test1 = new SynchronizedTest(); Thread thread1 = new Thread(test1); Thread thread2 = new Thread(test1); Thread thread3 = new Thread(test1); Thread thread4 = new Thread(test1); thread1.start(); thread2.start(); thread3.start(); thread4.start(); } //结果为线程名称乱序 }
也就是说如果两个线程,线程A 和线程B 同时执行 对于同一个对象而言,能够同时进入非 synchronized声明的方法,但是如果这个方法中调用了 synchronized声明的方法,则将会进行线程抢占,同一时间只有一个线程能够拿到进入这个方法的锁。
这里注意:它的锁的范围是这个对象中的这个方法。
2.修饰静态方法,作用于当前类对象加锁,进入同步代码前要获得当前类对象的锁
public class SynchronizedTest implements Runnable{ static int i =0; public static synchronized void increase(){ System.out.println(i++); System.out.println("进入增加方法:"+Thread.currentThread().getName()); } @Override public void run() { System.out.println(Thread.currentThread().getName()+"在执行"); for(int j=0;j<10;j++){ System.out.println("即将进入增加方法:"+Thread.currentThread().getName()); increase(); } } public static void main(String[] args) { SynchronizedTest test1 = new SynchronizedTest(); Thread thread1 = new Thread(test1); Thread thread2 = new Thread(test1); Thread thread3 = new Thread(test1); Thread thread4 = new Thread(test1); thread1.start(); thread2.start(); thread3.start(); thread4.start(); } }
注意:该锁对象是Class实例,因为静态方法存在于永久代,因此静态方法锁相当于该类的一个全局锁;
3.修饰代码块,指定加锁对象,对给定对象加锁,进入同步代码块前要获得给定对象的锁
public class SynchronizedTest implements Runnable{ static SynchronizedTest synchronizedTest = new SynchronizedTest(); static int i =0; @Override public void run() { System.out.println(Thread.currentThread().getName()+"准备执行"); for(int j=0;j<10;j++){ System.out.println("即将进入增加方法:"+Thread.currentThread().getName()); try { Thread.sleep(1000L); } catch (Exception e) { } synchronized (synchronizedTest){ System.out.println(Thread.currentThread().getName()+"执行中"); i++; } } } public static void main(String[] args) { Thread thread1 = new Thread(synchronizedTest); Thread thread2 = new Thread(synchronizedTest); Thread thread3 = new Thread(synchronizedTest); Thread thread4 = new Thread(synchronizedTest); Thread thread5 = new Thread(synchronizedTest); Thread thread6 = new Thread(synchronizedTest); thread1.start(); thread2.start(); thread3.start(); thread4.start(); thread5.start(); thread6.start(); } }
这种就是对进入synchronize代码块的对象加锁,也是对象锁。
我们再来看下把这些代码反编译之后出现的指令:
我们可以看到明显的monitorenter和monitorexit命令,还有一些是在方法维度有ACC_SYNCHRONIZED
其实这个关键字是同monitorenter和monitorexit命令是一样的,当方法调用时,调用指令将会检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先获取monitor,获取成功之后才能执行方法体,方法执行完后再释放monitor。在方法执行期间,其他任何线程都无法再获得同一个锁。
我们先说下这两个命令的工作方式:
-
monitorenter:每个对象都是一个监视器锁(monitor)。当monitor被占用时就会处于锁定状态,线程执行monitorenter指令时尝试获取monitor的所有权,过程如下:
- 如果monitor的进入数为0,则该线程进入monitor,然后将进入数设置为1,该线程即为monitor的所有者;
- 如果线程已经占有该monitor,只是重新进入,则进入monitor的进入数加1;
- 如果其他线程已经占用了monitor,则该线程进入阻塞状态,直到monitor的进入数为0,再重新尝试获取monitor的所有权;
-
monitorexit:执行monitorexit的线程必须是objectref所对应的monitor的所有者。指令执行时,monitor的进入数减1,如果减1后进入数为0,那线程退出monitor,不再是这个monitor的所有者。其他被这个monitor阻塞的线程可以尝试去获取这个 monitor 的所有权。
monitorexit指令出现了两次,第1次为同步正常退出释放锁;第2次为发生异步退出释放锁;
我们先说下monitor,我们认为每个java对象都有一种天生的锁属性,那么这个锁属性是体现在哪里的呢?那我们就来看下在JVM中对象是怎么样的?
(来自于:https://www.jianshu.com/p/e62fa839aa41)
如图:
java的对象可以分为三个部分:对象头、实例数据、对齐填充
实例数据 中存储的就是:存放类的属性数据信息,包括父类的属性信息
对齐信息:由于HotSpot规定对象的大小必须是8的整数倍,而对象头刚好是8的整数倍,如果对象实例数据这部分不是的话,就需要占位符对齐填充。
最后说下最重要的对象头:对象头包含如下信息
Mark Word(标记字段):这里包含了很多重要的信息,比如:哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等 我们把这里分为两块来说,下一次会主要讲这里因为锁状态的变化这里的改变。
Klass Pointe(类型指针):对象指向它的类元数据的指针,JVM通过这个指针确定该对象是哪个类的实例。
Array Length(数组长度):只有在对象是个数据的前提下才有这个信息,否则没有。
-
- 普通对象包含:Mark Word、Klass Pointer
- 数组对象包含:Mark Word、Klass Pointer、Array Length
Mark Word 这个部分在32位下是32bit,在64位下是64bit 但是不论其他结构如何,最后两位一定标识的是锁的状态,我们先观察最后这两位:
32位JVM
存储内容(30bit) | 锁状态(2bit) |
---|---|
identify_hashcode:25 | age:4 | biased_lock:1 | (01)无锁 |
threadId:23 | age:4 | epoch:2 | biased_lock:1 | (01)偏向锁 |
ptr_to_lock_record:30 | (00)轻量级锁 |
ptr_to_heavyweight_monitor:30 | (10)重量级锁 |
gc_info:30 | (11)GC标记 |
64位JVM
存储内容(62bit) | 锁状态(2bit) |
---|---|
unused:25 | identify_hashcode:25 | unused:1 | age:4 | biased_lock:1 | (01)无锁 |
threadId:54 | epoch:2 | unused:1 | age:4 | biased_lock:1 | (01)偏向锁 |
ptr_to_lock_record:62 | (00)轻量级锁 |
ptr_to_heavyweight_monitor:62 | (10)重量级锁 |
gc_info:62 | (11)GC标记 |
当MarkWord锁标识位为10,其中指针指向的是Monitor对象的起始地址。
在Java虚拟机(HotSpot)中,Monitor是由ObjectMonitor实现的,其主要数据结构如下(位于HotSpot虚拟机源码ObjectMonitor.hpp文件,C++实现的):
ObjectMonitor() { _header = NULL; _count = 0; // 记录个数 _waiters = 0, _recursions = 0; _object = NULL; _owner = NULL; _WaitSet = NULL; // 处于wait状态的线程,会被加入到_WaitSet _WaitSetLock = 0 ; _Responsible = NULL ; _succ = NULL ; _cxq = NULL ; FreeNext = NULL ; _EntryList = NULL ; // 处于等待锁block状态的线程,会被加入到该列表 _SpinFreq = 0 ; _SpinClock = 0 ; OwnerIsThread = 0 ; }
ObjectMonitor中有两个队列,_WaitSet 和 _EntryList,用来保存ObjectWaiter对象列表( 每个等待锁的线程都会被封装成ObjectWaiter对象 ),_owner指向持有ObjectMonitor对象的线程,当多个线程同时访问一段同步代码时:
- 首先会进入 _EntryList 集合,当线程获取到对象的monitor后,进入 _Owner区域并把monitor中的owner变量设置为当前线程,同时monitor中的计数器count加1;
- 若线程调用 wait() 方法,将释放当前持有的monitor,owner变量恢复为null,count自减1,同时该线程进入 WaitSet集合中等待被唤醒;
- 若当前线程执行完毕,也将释放monitor(锁)并复位count的值,以便其他线程进入获取monitor(锁);
同时,Monitor对象存在于每个Java对象的对象头Mark Word中(存储的指针的指向),
通过上面描述,我们应该能很清楚的看出Synchronized的实现原理,Synchronized的语义底层是通过一个monitor的对象来完成,其实wait/notify等方法也依赖于monitor对象,这就是为什么只有在同步的块或者方法中才能调用wait/notify等方法,否则会抛出java.lang.IllegalMonitorStateException的异常的原因。