1 synchronized
1.1 synchronized介绍
- synchronized机制提供了对每个对象相关的隐式监视器锁,并强制所有锁的获取和释放都必须在同一个块结构中。当获取了多个锁时,必须以相反的顺序释放。即synchronized对于锁的释放是隐式的。
- synchronized同步块对于同一条线程是可重入的,不会出现把自己锁死的问题。
- synchronized可以修饰类、方法(包括静态方法)、代码块。修饰类和静态方法时,锁的对象是Class对象;修饰普通方法时,锁的是调用该方法的对象;修饰代码块时,锁的是方法块括号里的对象。
- synchronized性能中避免“读/读”操作,但读操作频繁,通过ReentrantLock提供的ReadWriteLock读写锁来解决该问题;阻塞线程时,需要OS不断的从用户态转到核心态,消耗处理器时间,通过自适应自旋来解决该问题。
- synchronized的锁是存放在Java对象头里的。
1.2 synchronized的三种使用场景
1.2.1 修饰实例方法
1)说明:对当前对象实例this加锁
2)示例:
public class Demo1 {
public synchronized void methodA() {
System.out.println("synchronized 修饰 普通实例方法");
}
}
1.2.2 修饰静态方法
1)对当前类的Class对象加锁
2)示例
public class Demo2 {
public synchronized static void methodB() {
System.out.println("synchronized 修饰 静态方法");
}
}
1.2.3 修饰代码块
1)给指定对象加锁
2)说明
public class Demo3 {
public void methodC() {
synchronized(this) {
System.out.println("synchronized代码块对当前对象加锁");
}
}
public void methodC() {
synchronized(Demo3.Class) {
System.out.println("synchronized代码块对当前类的Class对象加锁");
}
}
}
1.3 synchronized实现同步的基础
Java中每一个对象都可以作为锁,这是synchronized实现同步的基础:
- 普通同步方法:锁是当前实例对象;
- 静态同步方法:锁是当前类的Class对象;
- 同步方法块:锁是括号里面的对象;
同步代码块:monitorenter指令插入到同步代码块的开始位置,monitorexit指令插入到同步代码块的结束位置,JVM需要保证每一个monitorexit和monitorenter相对应,任何对象都有一个monitor与之相对应,任何对象都有一个monitor与之相关联,当且一个monitor被持有之后,他处于锁定状态,线程执行到monitorenter指令时,将会尝试获取对象所对应的monitor所有权,即尝试获取对象的锁。
同步方法:synchronized方法则会被翻译成普通的方法调用和返回指令如:invokevirtual、areturn指令,在VM字节码层面并没有任何特别的指令来实现被synchronized修饰的方法,而是在Class文件的方法表中将该方法的access_flags字段中的synchronized标志位置1,表示该方法是同步方法并使用调用该方法的对象或该方法所属的Class在JVM的内部对象表示Klass做为锁对象。
1.4 synchronized反编译字节码
synchronized字节码层面实现的锁(锁计数器)。
- synchronized关键字经编译后,在同步块前后分别形成monitorenter和monitorexit两个字节码指令;
- 在执行monitorenter指令时,首先要尝试获取对象的锁,若这个对象没被锁定,或当前线程已经拥有了那个对象的锁,就把锁的计数器加1;
- 在执行monitorexit指令时会将锁计数器减1,当计数器为0时,锁就会被释放;
- 若获取对象锁失败,当前线程进入阻塞等待,直到对象锁被另外一个线程释放为止。
1.4.1 monitorenter
(重入锁的体现)每个对象都有一个监视器锁(monitor),当monitor被占用时就会处于锁定状态,线程执行monitorenter指令时尝试获取monitor的所有权。
- 如果monitor的进入数为0,则该线程进入monitor,然后将进入数设置为1,该线程即为monitor的所有者。
- 如果线程已经占有该monitor,只是重新进入,则进入monitor的进入数加1。
- 如果其他线程已经占用了monitor,则该线程进入阻塞状态,直至monitor的进入数为0,再重新尝试获取monitor的所有权。
1.4.2 monitorexit
- 执行monitorexit的线程必须是object所对应的monitor的所有者。
- 指令执行时,monitor的进入数减1,如果减1后进入数为0,那么线程就会退出monitor,不再是这个monitor的所有者。其他被这个monitor阻塞的线程可以尝试去获取这个monitor的所有权。
1.5 Java对象
1.5.1 Java对象组成
Java对象存储在堆内存中,对象由对象头、实例变量和填充字节组成。
1.5.2 对象头
- synchronized用的锁是存放在Java对象头里。Hotspot虚拟机的对象头主要包括两部分数据:Mark Word(标记字段)、Klass Pointer(类型指针)。
- Klass Point是对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。
- Mark Word用于存储对象自身的运行时数据,它是实现轻量级锁和偏向锁的关键。如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等等。Java对象头一般占有两个机器码(在32位虚拟机中,1个机器码等于4字节,也就是32bit),但是如果对象是数组类型,则需要三个机器码,因为JVM虚拟机可以通过Java对象的元数据信息确定Java对象的大小,但是无法从数组的元数据来确认数组的大小,所以用一块来记录数组长度。
下图是Java对象头的存储结构(32位虚拟机):
对象的hashCode | 对象的分代年龄 | 是否为偏向锁 | 锁标志位 |
---|---|---|---|
25bit | 4bit | 1bit | 2bit |
对象头信息是与对象自身定义的数据无关的额外存储成本,单考虑VM的空间小了,Mark Word设计成一个非固定的数据结构以便在极小的空间内存存储尽量多的数据,会根据对象的状态复用自己的存储空间,即Mark Word是随着程序的运行发生变化。
1.5.3 实例数据
是对象存储的真正有效信息,存储着自身定义的和从父类继承下来的实例字段,字段的存储顺序会受到虚拟机的分配策略和字段在Java源码中定义顺序的影响,这部分内存按4字节对齐。
1.5.4 对齐填充字段
由于虚拟机要求对象起始地址必须是8字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐。
2 ReentrantLock
2.1 ReentrantLock介绍
- Lock提供一种无条件的、可轮询的、定时的以及可中断的锁获取操作,所有加锁和解锁都是显式的。
- ReentrantLock实现Lock接口,并提供与synchronized相同的互斥性和内存可见性。
- ReentrantLock提供和synchronized一样的可重入的加锁语义。
- ReentrantLock是显式锁,需要显式进行lock以及unlock操作。形式比内置锁复杂,必须在finally块中释放锁,否则如果在被保护的代码中抛出异常,这个锁就永远都无法释放。加锁时,需要考虑在try块中抛出异常的情况,如果可能使对象处于某种不一致的状态,则需要更多的try-catch或try-finally代码块。
2.2 ReentrantLock示例
Lock lock = new ReentrantLock();
...
lock.lock();
try{
//更新对象状态
//捕获异常,并在必要时恢复不变性条件
}finally{
lock.unlock();
}
2.3 使用ReentrantLock的灵活性
(也是与synchronized的区别)
2.3.1 等待可中断
使用lock.lockInterruptibly()
可以使得线程在等待锁支持响应中断;
使用lock.tryLock()
可以使线程在等待一段时间过后如果还未获得锁就停止等待而非一直等待,更好的避免饥饿和死锁问题;
2.3.2 公平锁
默认情况下是非公平锁,但是也可以是公平锁,公平锁就是锁的等待队列的FIFO,不建议使用,会浪费许多时钟周期,达不到最大利用率。
2.3.3 锁可绑定多个条件
与ReentrantLock搭配的通信方式是Condition
,且可以为多个线程建立不同的Codition。
private Lock lock = new ReentrantLock();
private Condition condition = lock.newCondition();
condition.await(); //等价于synchronized中的wait()方法
condition.signal(); //等价于notify()
condition.signalAll(); //等价于notifyAll()
2.4 读-写锁
2.4.1 读写锁的出现原因
ReentrantLock实现一种标准的互斥锁,每次最多只有一个线程能持有ReentrantLock,限制了并发性,互斥是一种保守的加锁策略,虽然避免了“写/写”冲突和“写/读”冲突,但也避免了“读/读”冲突,而大部分情况下读操作比较多,如果此时能够放宽加锁需求,允许多个读操作的线程同时访问数据结构,可以提升程序的性能(只要每个线程保证读取到最新的数据,并且在读取数据时不会有其他线程修改数据就行)
2.4.2 ReentrantLock提供的非互斥的读写锁的定义
一个资源可以被多个读操作访问,或者被一个写操作访问,但两者不能读写操作同时进行。(多读、一写、不可同读写)
读-写锁是一种性能优化措施,可以实现更高的并发性,提高程序的性能。
当锁的持有时间较长并且大部分操作都不会修改被守护的资源时,读-写锁可以提高并发性。
3 Lock接口
3.1 Lock接口介绍
Lock接口提供一种无条件的、可轮询的、定时的以及可中断的锁获取操作,所有加锁和解锁都是显示的。
3.2 Lock方法
void lock();
:获取锁。void lockInterruptibly() throws InterruptedException;
:若当前线程未被中断,获取锁。boolean tryLock();
:仅当调用时锁为空闲状态才获取锁。boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
:如果锁在给定的等待时间内空闲,并且当前线程未被中断,则获取锁。void unlock();
:释放锁。Condition newCondition();
:返回绑定在此Lock实例的新Condition实例。
4 锁优化
4.1 jdk1.6对锁进行优化:
自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁。
4.2 锁的四个状态:
无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态。
锁可以升级(随着锁的竞争激烈程度而升级),但是不可降级。
4.3 自旋锁
- 引入原因:线程的阻塞和唤醒需要CPU从用户态转为核心态以及核心态转为用户态,频繁的阻塞和唤醒对CPU来说是一件负担很重的工作,势必会给系统的并发性能带来很大的压力。同时我们发现在许多应用上面,对象锁的锁状态只会持续很短一段时间,为了这一段很短的时间频繁地阻塞和唤醒线程是非常不值得的。
- 原理:就是让该线程等待一段时间(执行一段无意义的循环即可(自旋)),不会被立即挂起,看持有锁的线程是否会很快释放锁。(线程自循环等待,不立即挂起)
- 缺点:自旋不能代替阻塞,可以避免线程切换带来的开销,但是会占用处理器的时间,若持有锁的线程很快释放锁,则自旋效率好;反之,自旋的线程会白白消耗掉处理器的资源,带来性能上的浪费。
- 针对缺点的解决方案:自旋等待时间有限度——超过定义的时间没有获得锁就应该被挂起。自旋锁是JDK1.4.2引入,默认关闭,使用-XX:UseSpining开启,在JDK1.6默认开启,默认自旋次数为10次,可以通过-XX:PreBlockSpin调整。最终是通过自适应自旋锁来解决这一问题。
4.4 自适应自旋锁
- 引入原因:自旋锁的自旋次数是固定的,当持有锁不能很快释放锁时,效率低下,浪费处理器资源,自旋次数需要通过-XX:PreBlockSpin调整。所以引入自适应自旋锁不再固定自旋次数。
- 原理:由前一次在同一个锁上的自旋时间及锁的拥有者状态决定。线程如果自旋成功了,那么下次自旋的次数会更加多,因为虚拟机认为既然上次成功了,那么此次自旋也很有可能会再次成功,那么它就会允许自旋等待持续的次数更多。反之,如果对于某个锁,很少有自旋能够成功的,那么在以后要或者这个锁的时候自旋的次数会减少甚至省略掉自旋过程,以免浪费处理器资源。(主要就是看前一次自旋成功与否,成功多就增加自旋次数;成功少,就减少自旋次数)
4.5 锁消除
- 引入原因:在某些情况下,JVM检测到不可能存在共享数据竞争,如果继续加锁,降低性能,无意义。
- 原理:锁消除依据是逃逸分析的数据支持,检测到不存在共享数据竞争,就消除锁,从而节省请求锁的时间。(逃逸分析检测是否有共享数据竞争,若有,消除锁)
4.6 锁粗化
- 引入原因:一系列的连续加锁解锁操作,导致不必要的性能损耗。
- 原理:将多个连续的加锁、解锁操作连接在一起,扩展成一个范围更大的锁。如Vector的add操作操作,JVM检测到对同一个对象(vector)连续加锁、解锁操作,则合并成一个更大范围的加锁、解锁操作,进行锁粗化。(多个连续加锁、解锁合并成一个更大范围的锁)
4.7 轻量级锁(00)
- 引入原因:在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能损耗。
- 原理:当关闭偏向锁功能或者多个线程竞争偏向锁导致偏向锁升级为轻量级锁,则会尝试获取轻量级锁,其步骤如下:
获取锁
- 1)判断当前对象是否处于无锁状态(hashcode、0、01),若是,则JVM首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝(官方把这份拷贝加了一个Displaced前缀,即Displaced Mark Word);否则执行步骤 3);
- 2)JVM利用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针,如果成功表示竞争到锁,则将锁标志位变成00(表示此对象处于轻量级锁状态),执行同步操作;如果失败则执行步骤 3);
- 3)判断当前对象的Mark Word是否指向当前线程的栈帧,如果是则表示当前线程已经持有当前对象的锁,则直接执行同步代码块;否则只能说明该锁对象已经被其他线程抢占了,这时轻量级锁需要膨胀为重量级锁,锁标志位变成10,后面等待的线程将会进入阻塞状态;
释放锁:轻量级锁的释放也是通过CAS操作来进行的,主要步骤如下:
- 1)取出在获取轻量级锁保存在Displaced Mark Word中的数据;
- 2)用CAS操作将取出的数据替换当前对象的Mark Word中,如果成功,则说明释放锁成功,否则执行 3);
- 3)如果CAS操作替换失败,说明有其他线程尝试获取该锁,则需要在释放锁的同时需要唤醒被挂起的线程。
- 性能分析:对于轻量级锁,其性能提升的依据是“对于绝大部分的锁,在整个生命周期内都是不会存在竞争的”,如果打破这个依据则除了互斥的开销外,还有额外的CAS操作,因此在有多线程竞争的情况下,轻量级锁比重量级锁更慢;
4.8 偏向锁(01)
- 引入原因:为了在无多线程竞争的情况下尽量减少不必要的轻量级锁执行路径。轻量级锁的加锁解锁操作是需要依赖多次CAS原子指令的。
- 原理
获取锁:
- 1)检测Mark Word是否为可偏向状态,即是否为偏向锁1,锁标识位为01;
- 2)若为可偏向状态,则测试线程ID是否为当前线程ID,如果是,则执行步骤 5),否则执行步骤 3);
- 3)如果线程ID不为当前线程ID,则通过CAS操作竞争锁,竞争成功,则将Mark Word的线程ID替换为当前线程ID,否则执行步骤 4);
- 4)通过CAS竞争锁失败,证明当前存在多线程竞争情况,当到达全局安全点,获得偏向锁的线程被挂起,偏向锁升级为轻量级锁,然后被阻塞在安全点的线程继续往下执行同步代码块;
- 5)执行同步代码块。
释放锁:偏向锁的释放采用了一种只有竞争才会释放锁的机制,线程是不会主动去释放偏向锁,需要等待其他线程来竞争。偏向锁的撤销需要等待全局安全点(这个时间点是上没有正在执行的代码)。其步骤如下:
- 1)暂停拥有偏向锁的线程,判断锁对象是否还处于被锁定状态;
- 2)撤销偏向锁,恢复到无锁状态(01)或者轻量级锁的状态;
4.9 重量级锁(10)
重量级锁通过对象内部的监视器(monitor)实现,其中monitor的本质是依赖于底层操作系统的Mutex Lock实现,操作系统实现线程之间的切换需要从用户态到内核态的切换,切换成本非常高。
4.10 锁的优缺点的对比
锁 | 优点 | 缺点 | 适用场景 |
---|---|---|---|
偏向锁 | 加解锁不需要额外操作,速度较快 若是发生线程间的锁竞争,会产生锁撤销的消耗 | 适用于只有一个线程访问同步块的场景 | |
轻量级锁 | 竞争的线程不会阻塞,程序响应速度块 | 不断的自旋会消耗CPU资源 | 追求响应时间,同步块执行速度很快 |
重量级锁 | 线程竞争不使用自旋,CPU消耗少 | 线程会阻塞,相应慢 | 追求吞吐量,同步块执行时间较长 |
Q&A
synchronized方法和synchronized块的区别
- synchronized块:是一种细粒度的并发控制,只会将块中的代码同步,位于方法内、synchronized块之外的代码是可以被多个线程同时访问到的,锁的是方法块后面括号里的对象;synchronized方法是一种粗粒度的并发控制,某一时刻,只能有一个线程执行该synchronized方法,锁的是调用该方法的对象。
- 同步代码块:monitorenter指令插入到同步代码块的开始位置,monitorexit指令插入到同步代码块的结束位置,JVM需要保证每一个monitorexit和monitorenter相对应,任何对象都有一个monitor与之相对应,任何对象都有一个monitor与之相关联,当且一个monitor被持有之后,他处于锁定状态,线程执行到monitorenter指令时,将会尝试获取对象所对应的monitor所有权,即尝试获取对象的锁;同步方法:synchronized方法则会被翻译成普通的方法调用和返回指令如:invokevirtual、areturn指令,在VM字节码层面并没有任何特别的指令来实现被synchronized修饰的方法,而是在Class文件的方法表中将该方法的access_flags字段中的synchronized标志位置1,表示该方法是同步方法并使用调用该方法的对象或该方法所属的Class在JVM的内部对象表示Klass做为锁对象。在常量池中添加了ACC_ SYNCHRONIZED标识符,JVM就是根据该标识符来实现方法的同步。
synchronized和ReentrantLock的异同
同:
- 都是可重入的;
- 都属于同步互斥的手段;
异:
1 底层:synchronized是原生语法层面的互斥锁;ReentrantLock是API层面的互斥锁;
2. 加锁释放锁范式:synchronized是内置锁,获取多个锁后,以相反的顺序隐式释放锁;ReentrantLock必须显式加锁释放锁,且可以自由的顺序释放锁。
3. 功能:ReentrantLock增加高级功能:等待可中断、可实现公平锁、锁可以绑定多个条件。
功能 | 说明 |
---|---|
等待可中断 | 当持有锁的线程长期不释放锁的时候,正在等待的线程可以选择放弃等待,改为处理其他事情,可中断特性适用于处理执行时间非常长的同步块。 |
公平锁 | ReentrantLock可以是公平锁,默认为非公平锁,通过带布尔值的构造函数使用公平锁,公平锁是多个线程在等待同一个锁时,必须按照申请锁的时间顺序来一次获得锁;synchronized是非公平锁,非公平锁是不保证的,在锁释放时,任何一个等待锁的线程都有机会获得锁。 |
锁绑定多个条件 | 指一个ReentrantLock对象可同时绑定多个Condition对象,多次调用newCondition()方法;而在synchronized中,锁对象的wait()和notify()或notifyAll()方法可以实现一个隐含的条件,若要和多个条件关联,需要额外的添加一个锁。 |
什么是可重入锁?
重入锁实现重入性:每个锁关联一个线程持有者和计数器:
- 当计数器为0时表示该锁没有被任何线程持有,那么任何线程都可能获得该锁而调用相应的方法;
- 当某一线程请求成功后,JVM会记下锁的持有线程,并且将计数器置为1;
- 此时其它线程请求该锁,则必须等待;而该持有锁的线程如果再次请求这个锁,就可以再次拿到这个锁,同时计数器会递增+1;
- 当线程退出同步代码块时,计数器会递减-1,如果计数器为0,则释放该锁。
synchronized锁的存储位置?
- synchronized锁存放的位置是Java对象头里,如果对象是数组类型,则JVM用3个字节宽存储对象头,如果对象是非数组类型,则用2字节宽度存储对象头。
- 对象头由mark word+类型指针组成。mark word默认存储对象的hashcode、分代年龄和锁标志位。
synchronized锁存储在对象头,何为对象头?
对象头中包括两部分数据:标记字段和类型指针;
- 类型指针Klass Point是对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例;
- 标记字段Mark Word是一个非固定的数据结构,用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等等。
Lock与synchronized的主要区别:
- 类型:Lock是一个接口;synchronized是Java中的关键字,synchronized是内置的语言实现;
- 异常:Lock在发生异常时,如果没有主动通过unLock释放锁,可能造成死锁现象,使用Lock时需要在finally块中释放锁;synchronized在发生异常时,会自动释放线程中的占有的锁,不会导致死锁现象发生。
- 加锁释放锁:synchronized机制提供了对与每个对象相关的隐式的监视器锁,并强制所有锁的获取和释放都必须在同一个块结构中。当获取了多个锁时,他们必须以相反的顺序释放。即synchronized对于锁的释放是隐式的。而Lock机制必须显式的调用Lock对象的unlock()方法,这样,获取锁和释放锁可以不在同一个块中,这样可以以更自由的顺序释放锁。
- 响应中断:Lock可以让等待锁的线程响应中断;synchronized不可以让等待锁的线程响应中断,等待的线程会一直等待下去,不能够响应中断;
- 获取锁成功与否:通过Lock可以知道有没有成功获取锁;synchronized不能;
- 读操作:Lock可以提高多个线程进行读操作的效率(通过ReadWriteLock);而synchronized避免读/读操作
当一个线程进入一个对象的一个synchronized()方法后,其他线程能进入此对象的什么样的方法?
- 当一个线程在调用synchronized()方法的过程中,另一个线程可以访问同一个对象的非synchronized()方法;
- 当一个线程在调用synchronized()方法的过程中,另一个线程可以访问静态synchronized()方法,因为静态方法的同步锁是当前类的字节码,与非静态方法不能同步;
- 当一个线程在调用synchronized()方法的过程中,在这个方法内部调用了wait()方法,则另一个线程就可以访问同一个对象的其他synchronized()方法。
什么是同步代码块和内置锁?
同步代码块
- 同步代码块包括两部分:一个作为锁定的对象引用,一个作为由这个锁保护的代码块。
- 静态的synchronized方法以Class对象作为锁;
内置锁
- 每个Java对象都可以用于做一个实现同步的锁,则这些锁称为内置锁或监视器锁。
- 获得内置锁的唯一途径是进入由这个锁保护的同步代码块或方法。
- 内置锁:互斥且可重入。
为什么要创建一种与内置锁相似的新加锁机制?
内置锁能很好的工作,但是在功能上存在一些局限性,如synchronized无法中断一个正在等待获取锁的线程,或者无法在请求获取一个锁的时候无限地等待下去,内置锁必须在获取该锁的代码块中释放,简化了编码工作,且与异常处理操作实现很好的交互,但无法实现非阻塞结构的加锁机制。所以需要提供一种更灵活的加锁机制来提供更好的活跃性或性能。
synchronized和ReentrantLock的抉择
- 高级功能:当需要可定时的、可轮询的、可中断锁、公平锁以及非块结构的锁(锁分段技术)时,才使用ReentrantLock,否则优先使用synchronized,毕竟现在JVM内置的是synchronized。
- ReentrantLock的危险性:如果在try-finally中,finally未进行unlock,就会导致锁没有释放,无法追踪最初发生错误的位置。
ReentrantLock中断和非中断加锁区别?
ReentrantLock的中断和非中断加锁模式的区别在于:线程尝试获取锁操作失败后,在等待过程中,如果该线程被其他线程中断了,它是如何响应中断请求的。lock()
方法会忽略中断请求,继续获取锁直到成功;而lockInterruptibly()
则直接抛出中断异常来立即响应中断,由上层调用者处理中断。
参考书籍
《Java并发编程的艺术》
《Java并发编程实战》
《深入理解Java虚拟机》