分类标准 |
分类 |
根据线程是否需要对资源加锁 |
悲观锁/乐观锁 |
根据多个线程是否能获取同一把锁 |
共享锁/独享(独占、排他)锁 |
根据锁是否能够重复获取 |
可重入锁/不可重入锁 |
根据锁的公平性进行区分 |
公平锁/非公平锁 |
当多个线程并发访问资源时,当使用synchronized时 |
锁升级(偏向锁/轻量级锁/重量级锁) |
根据资源被锁定后,线程是否阻塞 |
自旋锁/适应性自旋锁 |
首先我们来看看悲观锁与乐观锁。
其实这里无论是悲观锁还是乐观锁,都是一种对锁的设计思想,或者说是设计理念,并不是一种真正的“锁”!两者的区别主要是当多线程并发操作数据时加锁的“态度”上。
1.2 具体实现
MySQL、Oracle等关系型数据库就大量用到了这种悲观锁机制,比如行级锁、表级锁、写锁等,都是在操作数据之前就先加上锁。
而在Java 中,synchronized 和 ReentrantLock 等独占锁(排他锁),都是悲观锁的具体实现。
1.3 使用场景
悲观锁的实现机制是以损失性能为代价的。多线程争抢时,不停地加锁、释放锁,会导致比较多的上下文切换和调度延时。所以加锁的机制会产生额外的开销,并可能会增加死锁发生的概率,引发性能问题。
悲观锁在每次修改数据时都要上锁,效率低,但写数据失败的概率比较低,且先加锁可以保证写操作时数据的正确性,所以适用于写多读少的场景中。
1.4 使用案例
下面简单列出synchronized 和 ReentrantLock 这两种悲观锁的使用方法。
// ------------------------- 悲观锁案例 -------------------------
// synchronized
public synchronized void testSynchronized() {
// 操作同步资源
…
}
// ReentrantLock,需要保证多个线程使用的是同一个锁
private ReentrantLock lock = new ReentrantLock();
public void updateData() {
//加锁
lock.lock();
//操作同步资源
......
//解锁
lock.unlock();
}
无论是synchronized还是Lock,悲观锁都是在显式的锁定资源后,再去对同步资源进行操作。
synchronized是Java中的一个关键字,解决的是多线程之间访问同一资源的同步性。它代表了一种同步的加锁操作,保证在同一时刻最多只能有一个线程 来执行被synchronized修饰的方法 或 代码块,这就保证了同一个共享资源在同一时间只能被一个线程访问到。
所以synchronized关键字解决了多线程中的并发同步问题,实现了阻塞型的并发,保证了线程的安全。
synchronized的作用范围有如下几个:
修饰一个代码块:被修饰的代码块称为同步代码块,作用范围是被大括号{}括起来的部分,作用对象是调用这个代码块的对象;
修饰一个方法:被修饰的方法称为同步方法,其作用范围是整个方法,作用对象是调用这个方法的对象;
修饰一个静态方法:作用范围是整个静态方法,作用对象是这个静态方法及类的所有对象;
修饰一个类:作用范围是synchronized括号后面括起来的部分,作用对象是这个类的所有对象。
public class SynchronizedDemo {
/**
* 对象锁:形式1,既是对象锁也是方法锁
*/
public synchronized void Method01() {
System.out.println("我是对象锁,也是方法锁");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
/**
* 对象锁:形式2(代码块)
*/
public void Method02() {
synchronized (this) {
System.out.println("我是对象锁");
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
/**
* 类锁形式1,给静态方法加锁
*/
public static synchronized void Method03() {
System.out.println("类锁形式1");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
/**
* 类锁形式2,给静态代码块加锁
*/
public void Method04() {
synchronized (SynchronizedDemo.class) {
System.out.println("类锁形式2");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
synchronized 可以修饰 类的实例方法、静态方法还有代码块,根据这些修饰范围的不同,我们可以将synchronized分为不同类型锁,如下:
对象锁:作用在类的实例方法、非静态代码块上,可以控制方法之间的同步。对象锁(包括方法锁)用于锁定实例对象,可以有多个;
方法锁:方法锁也属于对象锁,作用在类的实例方法上;
类锁:作用在静态方法、静态代码块上,控制静态方法之间的同步,该类的所有实例共享同一个类锁。类锁用于锁定类本身,只有1个。
例子
public class SynchronizedTest {
//同步方法
public synchronized void doSth(){
System.out.println("Hello 壹壹哥");
}
public void doSth1(){
//同步代码块
synchronized (SynchronizedTest.class){
System.out.println("Hello 壹壹哥");
}
}
}
对于同步方法,JVM采用ACC_SYNCHRONIZED标记符来进行同步;
对于同步代码块,JVM采用monitorenter、monitorexit两个指令来进行同步。
这里的Monitor指令是依赖于底层操作系统的 Mutex Lock 来实现的,我们了解即可。
在同步方法中,会随着方法的调用和返回值隐式地执行加锁操作,其底层是采用ACC_SYNCHRONIZED来进行加锁操作的。同步方法的常量池中会有一个ACC_SYNCHRONIZED标志位,当某个线程要访问某个方法的时候,会先检查该方法是否有ACC_SYNCHRONIZED标记。如果有设置,则需要先获得监视器锁,然后开始执行该方法,方法执行之后再释放监视器锁。这时如果有其他线程来请求执行该同步方法,会因为无法获得监视器锁而被阻断。值得注意的是,如果在同步方法的执行过程中,发生了异常,并且方法内部并没有处理该异常,那么在异常被抛到方法外面之前该监视器锁就会被自动释放。
同步方法的底层处理逻辑,我们可以查看官方文档,具体请参考:The Java® Virtual Machine Specification
同步代码块的底层加锁逻辑,是通过使用 monitorenter和monitorexit 这两个指令来实现的。我们可以把执行monitorenter指令理解为加锁,执行monitorexit理解为释放锁。 每个实例对象都维护了一个记录着被锁次数的计数器,未被锁定时对象的计数器为0。
关键就是必须要获取对象的监视器monitor锁,当线程获取monitor后才能继续往下执行,否则就只能等待。而这个获取的过程是互斥的,即同一时刻只有一个线程能够获取到monitor锁。当一个线程获得锁(执行monitorenter)后,该计数器自增变为 1;当同一个线程再次获得该对象锁时,计数器会再次自增;当同一个线程释放锁(执行monitorexit指令)时,计数器自减1。当计数器变为0时,锁被释放,其他线程便可以获得锁了。
任意线程想要对Object对象进行同步访问,首先都要获得Object对象的监视器。如果获取失败,该线程就会进入到SynchronizedQueue同步队列中,且线程状态变为BLOCKED状态。当Object对象的监视器被之前的占有者释放后,存储在同步队列中的线程就会有机会重新获取该监视器。
另外我们还要注意,因为synchronized先天具有可重入性,即在同一个锁程中,线程不需要再次获取同一把锁。所以上面代码案例的反编译结果中,doSth1中的第2个monitorexit指令没有对应的monitorenter指令,不需要再次获取monitorenter指令。
JVM基于进入和退出 Monitor 对象,来实现方法的同步和代码块同步。代码块同步是使用 monitorenter 和 monitorexit 指令实现的,monitorenter 指令是在编译后插入到同步代码块的开始位置,而 monitorexit 是插入到方法结束处和异常处。任何对象都有一个 monitor 与之关联,当一个 monitor 被持有后,它将处于锁定状态。
根据虚拟机规范的要求,在执行 monitorenter 指令时,首先要去尝试获取对象的锁。如果这个对象没被锁定,或者当前线程已经拥有了那个对象的锁,把会锁的计数器加1。相应地,在执行 monitorexit 指令时会将锁计数器减1,当计数器被减到0时,锁就被释放了。如果获取对象锁失败了,那当前线程就要阻塞等待,直到对象锁被另一个线程释放为止。
以上就是synchronized的加锁和解锁逻辑。
上面的章节中,我们多次提到Monitor对象,那什么是Monitor呢?这里我们对其简单来了解一下。
synchronized是通过对象内部一个叫做“监视器锁(monitor)”的对象来实现的,监视器锁本质上是依赖于底层操作系统的 Mutex Lock(互斥锁)来实现的。操作系统实现线程之间的切换,需要从用户态转换到核心态,这个成本非常高,状态之间的转换需要相对比较长的时间,这就是为什么 synchronized 效率低的原因。因此,这种依赖于操作系统 Mutex Lock 所实现的锁,我们称之为重量级锁。
synchronized的最大特征就是可以保证在同一时刻只能有一个线程获得对象的监视器(monitor),这就是synchronized的互斥性(排它性)。但这种方式的效率较低,因为每次只能通过一个线程,而我们又不能增加每次通过的线程数量。另外synchronized还是一种重量级锁,使用时会涉及到操作系统状态的切换,效率较低。所以我们很希望可以优化synchronized,那该怎么实现呢?
目前可行的优化方案就是想办法让线程每次通过的速度加快!
Java官方也意识到了这一点,所以从JDK 1.6中开始,在synchronized中引入了偏向锁和轻量级锁,主要从减少获取和释放锁时的消耗方面来进行优化。
引入偏向锁是为了在没有多线程竞争时,尽量减少不必要的轻量级锁执行路径。因为轻量级锁的获取及释放会依赖多次的CAS原子指令,而偏向锁只需要在置换ThreadID时依赖一次CAS原子指令(由于一旦出现多线程竞争的情况,就必须撤销偏向锁,所以偏向锁撤销操作的性能损耗必须小于节省下来的CAS原子指令的性能消耗)。
偏向锁的获取
偏向锁也是需要获取的,获取过程如下:
1>. 访问Mark Word中偏向锁的标识是否设置成“1”,锁标志位是否为“01”——确认为可偏向状态;
2>. 如果为可偏向状态,判断线程ID是否指向了当前线程,如果是则进入步骤(5),否则进入步骤(3)。
3>. 如果线程ID并未指向当前线程,则通过CAS操作竞争锁。如果竞争成功,则将Mark Word中的线程ID设置为当前线程ID,然后执行(5);如果竞争失败,则执行(4);
4>. 如果CAS获取偏向锁失败,则表示有竞争。当到达全局安全点(safepoint)时,获得偏向锁的线程会被挂起,偏向锁升级为轻量级锁,然后被阻塞在安全点的线程继续往下执行同步代码;
5>. 执行同步代码。
偏向锁的释放
偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程不会主动去释放偏向锁。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有字节码正在执行),它会首先暂停拥有偏向锁的线程,判断锁对象是否处于被锁定状态,撤销偏向锁后恢复到未锁定(标志位为“01”)或轻量级锁(标志位为“00”)的状态。
轻量级锁并不是用来代替重量级锁的,它的本意是在没有多线程竞争的前提下,减少传统的重量级锁使用时所产生的性能消耗。
轻量级锁的加锁过程
1>. 在代码进入同步块时,如果同步对象锁状态为无锁状态(锁标志位为“01”状态,是否为偏向锁为“0”),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝;
2>. 拷贝对象头中的Mark Word复制到锁记录中;
3>. 拷贝成功后,虚拟机将使用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针,并将Lock Record里的owner指针指向object mark word。如更新成功,则执行步骤(3),否则执行步骤(4);
4>. 如果这个更新动作成功,那么这个线程就拥有了该对象的锁,并且对象Mark Word的锁标志位设置为“00”,即表示此对象处于轻量级锁定状态;
5>. 如果这个更新操作失败,虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行。否则说明多个线程竞争锁,轻量级锁就要膨胀为重量级锁,锁标志的状态值变为“10”。
轻量级锁的解锁过程
1>. 通过CAS操作,尝试把线程中复制的Displaced Mark Word对象替换当前的Mark Word;
2>. 如果替换成功,整个同步过程完成;
3>. 如果替换失败,说明有其他线程尝试过获取该锁(此时锁已膨胀),那就要在释放锁的同时,唤醒被挂起的线程。
适应性自旋:在使用CAS时,如果操作失败,CAS会自旋再次尝试。由于自旋是需要消耗CPU资源的,所以如果长期自旋就会很浪费CPU。JDK 1.6 加入了适应性自旋,即如果某个锁通过自旋很少成功获得,那么下一次就会减少自旋。
可以通过–XX:+UseSpinning参数来开启自旋(JDK 1.6之前默认关闭自旋);
可以通过–XX:PreBlockSpin修改自旋次数,默认值是10次。
锁消除:锁消除指的是,编译器在运行时,如果检测到有些共享数据不可能存在竞争,那么就执行锁消除。锁消除可以节省无意义的请求锁时间。
锁粗化:我们在写代码时,会推荐将同步块的作用范围尽量的缩小,只在共享数据的实际作用域才进行同步。这样是为了使得需要同步的操作数量尽可能地变小,即使存在锁竞争,等待线程也可以尽快地拿到锁。
之前一直有人把 synchronized 称之为 “重量级锁” ,但在JDK 1.6中,对synchronized进行了以上这些优化之后,比如引入了 偏向锁 和 轻量级锁,减少了获得锁和释放锁带来的性能消耗。而且synchronized 的底层实现现在主要是依靠 Lock-Free 队列,基本思路就是 自旋后阻塞,竞争切换后继续竞争锁,虽然稍微牺牲了公平性,但获得了高吞吐量。在线程冲突较少的情况下,可以获得和 CAS 类似的性能;而线程冲突严重的情况下,性能远高于CAS。所以在JDK 1.6之后,我们大可以放心的使用synchronized,不必太纠结于它的性能问题了。
如果我们想对Java中的多线程进行同步操作,除了可以使用之前讲解的synchronized关键字之外,还可以使用JDK 1.5后新增的ReentrantLock类来实现同步操作,且其使用上比synchronized更加灵活。
Lock完全是由Java编写的,与JVM底层无关。它是java.util.concurrent.locks包中的一个接口,内部有多个实现类,常用的有ReentrantLock、实现类ReentrantReadWriteLock,最终实现都依赖AbstractQueuedSynchronizer(简称AQS)类。我们利用Lock进行同步操作的常用方法如下:
tryLock():尝试获取锁,获取锁成功返回true,否则返回false;
lock()方法: 加锁;
unlock()方法:解锁;
newCondition():返回当前锁的条件变量,通过条件变量可以实现类似notify和wait的功能。
public class LockDemo {
public static void main(String[] args) {
MyReentrantLock reentrantLock = new MyReentrantLock();
Thread thread01 = new Thread(reentrantLock);
Thread thread02 = new Thread(reentrantLock);
thread01.start();
thread02.start();
}
static class MyReentrantLock implements Runnable {
/**
* 创建一个ReentrantLock对象
*/
private Lock lock = new ReentrantLock();
@Override
public void run() {
//加锁
lock.lock();
//执行业务代码
for (int i = 0; i < 5; i++) {
System.out.println("当前线程名:" + Thread.currentThread().getName() + ", i = " + i);
}
//解锁
lock.unlock();
}
}
}
ReentrantLock+Condition实现等待+通知
我们知道,synchronized关键字可以结合wait()、notify()或者notifyAll()方法,来实现线程的等待与通知模式。而ReentrantLock同样也可以实现该功能,此时需要借助Condition对象,实现“选择性通知”的效果。
这个Condition类与ReentrantLock一样,也是JDK 1.5提供的,我们可以在一个Lock对象中创建多个Condition(对象监视器)实例。
public class LockConditionDemo {
/**
* 创建一个Lock锁对象
*/
public static ReentrantLock lock = new ReentrantLock();
/**
* 创建一个Condition条件
*/
public static Condition condition = lock.newCondition();
public static void main(String[] args) {
/**
* 开启第一个线程
*/
Thread thrad01 = new Thread(() -> {
//线程01加锁
lock.lock();
try {
System.out.println(Thread.currentThread().getName() + " 开始执行");
System.out.println(Thread.currentThread().getName() + " 开始等待");
//设置当前线程进入等待
//注意:必须在condition.await()方法调用前,用lock.lock()代码获得同步监视器
//否则会产生IllegalMonitorStateException异常
condition.await();
System.out.println(Thread.currentThread().getName() + " 继续执行");
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
//解锁
lock.unlock();
}
}, "Thread01");
thrad01.start();
//创建第2个线程
Thread thread02 = new Thread(() -> {
//线程02加锁
lock.lock();
try {
System.out.println(Thread.currentThread().getName() + " 开始执行");
//休息2秒
Thread.sleep(2000);
//随机唤醒等待队列中的一个线程
condition.signal();
System.out.println(Thread.currentThread().getName() + " 执行结束");
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
//解锁
lock.unlock();
}
}, "Thread02");
thread02.start();
}
}
注意:
必须在condition.await()方法调用前,用lock.lock()代码获得同步监视器,否则会产生IllegalMonitorStateException异常!
ReentrantReadWriteLock简介
我们知道,ReentrantLock是一个具有完全互斥排他效果的Lock子类,即同一时间只能有一个线程会执行ReentrantLock.lock()方法后的任务。
这样虽然保证了实例变量的线程安全,但效率低下,所以Java提供了Lock的另一个子类—ReentrantReadWriteLock读写锁,其效率更高。在某些不需要操作实例变量的方法中,我们可以使用ReentrantReadWriteLock来提升该方法的代码运行速度。
ReentrantReadWriteLock锁分类
ReentrantReadWriteLock读写锁中会有两个锁:
读操作相关的锁,称为共享锁;
写操作相关的锁,称为排他锁。
多个读锁之间不会互斥,读锁与写锁互斥,多个写锁之间互斥。进行读操作的多个线程可以获取读锁,但进行写操作时的线程只有获取写锁后才能进行写操作。
. Lock锁分类
Lock锁可以分为“公平锁”和“非公平锁”:
公平锁:线程获取锁的顺序是按照线程加锁的顺序来进行分配的,即FIFO先进先出的顺序;
非公平锁:随机拿锁,先来的不一定先拿到锁。这是一种获取锁的抢占机制,可能会造成某些线程一直拿不到锁,结果是不公平的。
ReentrantLock既可以实现公平锁,也可以实现非公平锁,默认情况下是非公平锁。之所以会有非公平锁,是因为当有线程竞争锁时,当前线程会首先尝试获得锁而不是在队列中进行排队等候,这对于那些已经在队列中排队的线程来说是不公平的,这就是非公平锁的由来。
对于Lock的底层原理,我们先简单概括一下。Lock的底层是基于AQS实现的,采用了线程独占的方式,在硬件层面依赖特殊的CAS乐观锁指令。我们常用的Lock子类ReenTrantLock,其内部实现是一种自旋锁,通过循环调用CAS操作来实现加锁。
lock内部利用一个int类型的state状态值来记录锁的状态变更,用一个双向链表来存储竞争锁的线程;
lock争抢锁时,如果没有立即获取到锁,就会将该线程存放到一个等待链表中,并通过CAS自旋来修改状态值;
lock释放锁时:会调整等待链表的结点顺序,修改state状态值。
synchronized与Lock的区别总结如下:
synchronized是一个关键字,锁的操作是由JVM层面进行实现;而Lock是一个接口类,需要程序员自己手动控制加解锁;
synchronized无法判断是否获取到了锁,而Lock可以判断出是否已经获取到了锁;
synchronized会自动释放锁(包括发生异常时),而Lock必须手动释放锁,否则会产生死锁;
synchronized是不可中断的,Lock可以中断也可以不中断,如果中断可以用interrupt来实现;
通过Lock可以知道线程有没有拿到锁,而synchronized不能;
synchronized能锁住方法和代码块,而Lock只能锁住代码块;
synchronized可以使用Object的wait 、notify、notifyAll来进行线程的等待与唤醒,而Lock是使用Condition来进行线程之间的等待与唤醒;
Lock可以使用读锁提高多线程读效率,适合大量代码时的同步操作;而synchronized没有读写锁的概念,适合少量代码的同步操作;
synchronized为非公平锁,ReentrantLock可以控制是否是公平锁;
synchronized是可重入、不可中断、非公平的锁,而Lock是可重入、可判断、可公平也可非公平的锁。
基本概念
顾名思义,乐观锁就是一种很乐观的锁,它看待事情的态度就很乐观。A线程在读取数据时,总是很乐观的认为,并发资源并不会被别的线程修改,所以在读取数据时不会进行加锁操作,但在进行写入操作时会判断当前数据是否被修改过。乐观锁常用于读操作较多的应用中,以此来提高吞吐量,因为不加锁会带来性能的较大提升。
实现方案
乐观锁通常都是基于无锁编程来实现,常用的实现方案有两种:版本号机制 和 CAS实现 。java.util.concurrent.atomic包中原子变量类的递增操作,就是使用了CAS(Compare and Swap)的自旋来实现的乐观锁。
使用场景
乐观锁是基于对比检测的手段来判断待更新的数据是否发生了变化,但不确定数据是否已变化完成。另外乐观锁并未真正地进行加锁,这能够使得读操作的性能大幅提升,所以效率高,但写数据失败的概率比较高,比较适用于读多写少的场景中。
使用案例
//乐观锁案例
//要保证多个线程使用的是同一个AtomicInteger对象
private AtomicInteger atomicInteger = new AtomicInteger();
//执行自增1,内部使用CAS进行自增操作
atomicInteger.incrementAndGet();
根据上面的代码可知,乐观锁是直接去操作同步资源,不会上来就进行加锁操作。
乐观锁的实现方式可以基于CAS机制和版本号机制,但现在主要是基于CAS机制来实现乐观锁
CAS概念
CAS(Compare And Swap),即比较与交换。CAS是一种无锁算法,也就是可以在不使用锁(没有线程被阻塞)的情况下,实现多线程之间的变量同步,java.util.concurrent包中的原子类就是通过CAS实现的乐观锁。
CAS算法
当多个线程使用CAS算法,尝试同时更新同一个变量时,只有其中一个线程能够更新变量的值,其它线程都会失败,但失败的线程并不会被挂起,而是被告知在这次竞争中失败,可以再次进行尝试。CAS算法中会涉及到三个操作数:
需要读写的内存位置值 V;
进行比较的预期值 A;
打算写入的新值 B。
当且仅当 V 的值等于 A 时,CAS就会通过原子方式用新值B来更新V的值(“比较+更新”整体是一个原子操作);如果不相等,则处理器不进行任何操作。一般情况下,“更新”是一个不断重试的操作,会通过Java代码中的while循环不停地进行重试,直到设置成功为止。
CAS存在的问题
CAS机制虽然很高效,但也可能存在3大问题:
ABA问题。CAS需要在操作值时检查内存中的值是否发生了变化,没有发生变化才会更新内存值。但如果内存值原来是A,后来变成了B,然后又变成了A,那么CAS进行检查时会发现值没有发生变化,但它实际上是有变化的。ABA问题的解决思路就是在变量前面添加版本号,每次变量更新时都要把版本号加 1,这样变化过程就从“A-B-A”变成了“1A-2B-3A”。JDK1.5中提供了AtomicStampedReference类来解决ABA问题,具体操作封装在compareAndSet()方法中。compareAndSet()方法首先会检查当前引用和当前标志,与预期引用和预期标志是否相等,如果都相等,则以原子方式将引用值和标志值设置为给定的更新值。
循环时间长开销大。CAS操作如果长时间不成功,会导致其一直自旋,给CPU带来非常大的开销。
只能保证一个共享变量的原子操作。对一个共享变量执行操作时,CAS能够保证原子操作,但对多个共享变量操作时,CAS是无法保证操作的原子性的。JDK从1.5开始提供了AtomicReference类来保证引用对象之间的原子性,可以把多个变量放在一个对象里来进行CAS操作。
版本号机制
版本号机制一般是通过在数据库表中添加一个 version 字段来实现的,version表示数据被修改的次数。当执行写操作并且写入成功后,version = version + 1。当线程A要更新数据时,在读取数据的同时也会读取 version 值。在提交更新时,若刚才读取到的 version 值与当前数据库中的version值相等时才更新;否则重试更新操作,直到更新成功。
在Java中,独享锁和共享锁同样也只是一种概念,并非是真正的“锁”。独享锁与共享锁都是通过AQS来实现的,通过实现不同的方法,来实现独享或者共享。
1.2 具体实现
ReentrantReadWriteLock中有两把锁:ReadLock 和 WriteLock,也就是一个读锁一个写锁,合在一起叫读写锁。ReadLock 和 WriteLock 都是基于内部类 Sync 实现的锁,Sync 是继承于 AQS 的子类,而AQS 是并发的根本,这种结构在CountDownLatch、ReentrantLock、Semaphore里面也都存在。
在 ReentrantReadWriteLock 里面,读锁和写锁的锁主体都是 Sync,但读锁和写锁的加锁方式不一样。读锁是共享锁,写锁是独享锁。读锁的共享锁可保证并发读非常高效,而读写、写读、写写的过程互斥。因为读锁和写锁是分离的,所以ReentrantReadWriteLock的并发性相比一般的互斥锁有了很大提升。
2.2 具体实现
synchronized、ReentrantLock以及ReentrantReadWriteLock中的写锁都是独享(独占、排他)锁。
我们可以用下面一段代码来描述synchronized的可重入性。
//对外层方法加锁
private synchronized void outerMethod(){
System.out.println(“执行外层业务…”);
//调用内层方法
innerMethod();
}
//对内层方法加锁
private synchronized void innerMethod(){
System.out.println(“执行内层业务…”);
}
上面这段代码中,我们对 outerMethod() 和 innerMethod() 分别使用了 synchronized 进行锁定,outerMethod() 方法中调用了 innerMethod() 方法。因为 synchronized 是可重入锁,所以同一个线程在调用 outerMethod() 方法时,也能够进入到 innerMethod() 方法中。
具体实现
Java 中 ReentrantLock 和synchronized 都是可重入锁,可重入锁的优点是在一定程度上可以避免死锁。
如果我们使用不可重入锁来实现,可能就会出现一些问题,容易造成死锁。
公平锁的优点是等待锁的线程不会饿死,缺点是整体吞吐效率相对非公平锁要低。等待队列中除第一个线程以外的其他所有线程都会阻塞,CPU唤醒阻塞线程的开销比非公平锁要大。
1.2 具体实现
ReetrantLock可以通过构造方法来指定该锁是否为公平锁,当new ReetrantLock(true)时就是公平锁。
非公平锁的优点是可以减少频繁唤起阻塞线程的开销,整体的吞吐效率高。因为线程有一定的几率不进行阻塞就直接获得了锁,CPU不必唤醒所有的线程。缺点是处于等待队列中的线程可能会饿死,或者等很久才会获得锁。
2.2 具体实现
synchronized是一种典型的非公平锁,而且synchronized也没办法变成公平锁。另外默认的ReetrantLock就是非公平锁,即当new ReetrantLock(false)时。
2.3 ReetrantLock构造方法源码
通过上面的内容可知,ReetrantLock既可以是公平锁,又可以是非公平锁,这是为什么呢?让我们来看看ReetrantLock的构造方法源码吧。
public ReentrantLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
}
从上面的源码中可知,如果构造ReentrantLock对象的参数为true,就会创建一个公平锁FairSync对象。如果构造ReentrantLock对象的参数为false,则创建一个NonfairSync非公平对象。
无论是FairSync,还是NonfairSync,都是Sync 的子类。我们知道,添加锁和释放锁的大部分操作实际上都是在Sync中实现的,且Sync 继承 AbstractQueuedSynchronizer 类,也就是我们常说的 AQS。AQS是JUC(java.util.concurrent)中最重要的一个类,JUC通过它来实现独占锁和共享锁。
公平锁与非公平锁的lock()方法,唯一的区别就在于公平锁在获取同步状态时多加了一个限制条件:hasQueuedPredecessors()。而hasQueuedPredecessors()方法的主要功能是判断当前线程是否位于同步队列中的第一个,如果是则返回true,否则返回false。
综上,公平锁就是通过同步队列来实现多个线程按照申请锁的顺序来获取锁,从而实现公平的特性。非公平锁加锁时不考虑排队等待问题,直接尝试获取锁,所以存在后申请却先获得锁的情况。
CPU的时间片策略会保证多线程执行时,同一时刻只能有一个线程获取到锁,而对于没有获取到锁的线程
通常有两种处理方式:
没有获取到锁的线程就一直循环等待,不停地判断该资源是否已经释放了锁,这种锁叫做自旋锁,它不用将线程阻塞起来(NON-BLOCKING);
另一种处理方式就是把未获取到锁的线程阻塞起来,等待CPU的重新调度,这种叫做互斥锁。
关于“自旋”这个词,该怎么理解呢?为了让大家更好的理解,壹哥 对“自旋”打个比方。就好比我们去驾校练车,学员很多,但教练车只有一辆,所以现在就会有一堆人去争一辆车。教练规定,每个人都不能长时间占着这辆车不放。现在张三要去练车,于是就凑到车跟前看看能不能用车,结果发现李四正在用车,所以张三就很识趣地往后挪一挪,等待一会(自旋)。等过一会之后,张三再次凑到车跟前,看看能不能用车,反正他就这么一直重复这个过程,直到自己可以得到车的使用权为止。
1.2 基本原理
如果持有锁的线程能在短时间内释放锁,那么那些等待竞争锁的线程就不需要在内核态和用户态之间进行切换,从而不用进入到阻塞状态。它们只需要稍微地等一等(自旋),等到持有锁的线程释放锁之后就可以获取锁,这样就避免了用户进程和内核切换的消耗。
线程持有锁的时间越长,则持有该锁的线程被 操作系统 调度程序中断的风险就越大。如果线程发生中断,那其他线程就会保持旋转状态(反复尝试获取锁),但持有该锁的线程并不打算释放锁,这样导致的是结果就是锁的释放被无限期推迟,直到持有锁的线程彻底完成任务并释放锁为止。
自旋锁的目的是占着CPU资源不进行释放,等到获取锁立即进行处理。
所以为了解决上面这种情况,一个比较好的方案就是给自旋锁设置一个自旋时间,等时间一到就立即释放自旋锁。但这个自选时间该怎么设置呢?如果自旋的执行时间太长,就会有大量的线程处于自旋状态并持续占用 CPU 资源,进而会影响系统的整体性能。因此设置的自旋周期非常重要,JDK 1.6中引入了一个适应性自旋锁。适应性自旋锁意味着自旋时间不是固定的,而是由前一次在同一个锁上的自旋时间以及锁拥有的状态来决定,一般最佳的自旋时间是一个线程的上下文切换时间。
1.3 优缺点
1.3.1 优点
自旋锁避免了用户进程和内核切换的消耗,尽可能地减少了线程的阻塞,也就是自旋的消耗小于线程阻塞挂起再唤醒操作的消耗。适用于执行时间较短的代码。
1.3.2 缺点
如果锁的竞争很激烈,或者持有锁的线程需要长时间占用锁来执行同步代码块,这时就不适合使用自旋锁了。因为自旋锁在获取到锁之前一直都是在占着CPU做无用功,相当于是占着茅坑不拉屎。如果此时有大量的线程在竞争同一个锁,就会导致获取锁的时间很长。即线程自旋的消耗大于了线程阻塞挂起操作的消耗,而其它需要CPU的线程又获取不到CPU,造成了CPU的浪费,这种情况下我们就要关闭自旋锁。
所以添加了自旋锁的线程,一直都是 RUNNABLE 状态的,会一直循环进行检测锁标志。但自旋锁加锁全程都会消耗 CPU,虽然起始开销低于互斥锁,但随着持锁时间的增加,加锁开销会线性增长。即长时间加锁时,自旋锁会非常耗费性能,它阻止了其他线程的运行和调度,所以自旋锁无法保证多线程竞争的公平性。
1.4 使用案例
public class SpinLockTest {
//创建一个原子类对象
private AtomicBoolean available = new AtomicBoolean(false);
//定义加锁方法
public void lock(){
//循环检测尝试获取锁,自旋
while (!tryLock()){
// doSomething...
}
}
//尝试加锁
public boolean tryLock(){
//尝试获取锁,成功返回true,失败返回false
return available.compareAndSet(false,true);
}
//解锁
public void unLock(){
if(!available.compareAndSet(true,false)){
throw new RuntimeException("释放锁失败");
}
}
}
所以在JDK 1.6中,就引入了适应性自旋锁,这就意味着自旋的时间(次数)不再固定,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。如果在同一个锁对象上,自旋等待的线程之前成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也是很有可能再次成功的,进而它将允许自旋等待持续一个相对更长点的时间。如果对于某个锁,自旋很少成功获得过锁,那在以后尝试获取锁时将可能省略掉自旋过程,直接阻塞线程,避免浪费处理器资源。
JDK 1.5之后,通过锁升级机制,其实已经对synchronized进行了大量的优化,主要是利用偏向锁、轻量级锁、锁粗化、锁消除、适应性自旋等手段实现。
这里所谓的“状态”,是指JDK 1.6中,为了减少获取锁和释放锁时所带来的性能问题,而引入的一种锁升级的处理策略。这个状态会随着锁竞争的情况而逐渐升级,整体逻辑就是锁可以升级但不能降级。也就是说偏向锁可以升级成轻量级锁,但无法降为偏向锁;轻量级锁可以升级成重量级锁,但无法降为轻量级锁。这种只能升级但无法降级的策略,其目的就是为了提高获得锁和释放锁的效率。
分别介绍这4种不同的锁状态。
无锁的特点就是修改操作要在循环内进行,线程会不断的尝试修改共享资源。如果没有冲突就修改成功并退出,否则就会继续循环尝试。如果有多个线程修改同一个值,必定会有一个线程能修改成功,而其他修改失败的线程则会不断重试直到修改成功。上面的CAS原理及应用就是无锁的实现。无锁无法全面代替有锁,但无锁在某些场合下的性能是非常高的。
偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程是不会主动释放偏向锁的。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有字节码正在执行),它会首先暂停拥有偏向锁的线程,判断锁对象是否处于被锁定状态。撤销偏向锁后会恢复到无锁(标志位为“01”) 或轻量级锁(标志位为“00”)的状态。
偏向锁在JDK 1.6的JVM里是默认启用的。可以通过JVM参数关闭偏向锁:-XX:-UseBiasedLocking=false,关闭之后程序默认会进入到轻量级锁状态。
当偏向锁的标志位是“0”,锁标志位设置为“00”,表示此对象处于轻量级锁状态。
升级为重量级锁时,锁标志的状态值变为“10”,此时Mark Word中存储的是指向重量级锁的指针,此时等待锁的线程都会进入阻塞状态。
讲完了这4种不同的锁状态,我们对synchronized的锁升级就有了基本的了解,但具体是怎么升级的,请接着往下看。
6.1 锁升级的整体逻辑
自JDK1.6以后,在处理同步锁时会存在锁升级的过程。JVM对同步锁的处理是先从偏向锁开始的,随着锁竞争越来越激烈,会再从偏向锁升级到轻量级锁,最终升级到重量级锁。
具体的说就是,在大多数情况下,锁不仅不存在多线程竞争,而且总是会由同一线程多次获得。所以为了不让这个线程每次获得锁时都进行CAS操作,进而带来不必要的性能消耗,就引入了偏向锁。当一个线程访问对象并获取到锁时,就会在对象头里存储锁偏向的这个线程ID。以后当该线程再次访问该对象时,只需判断对象头的Mark Word里是否有这个线程ID,如果有就不需要再进行CAS操作,直接把锁给它就行了。
当线程竞争更激烈时,偏向锁就会升级为轻量级锁。轻量级锁认为虽然竞争是存在的,但理想情况下竞争的程度很低,通过自旋方式等待一会儿,上一个线程就会自动释放锁。
但当自旋超过了一定的次数,或者一个线程正在持有锁,另一个线程在自旋,又来了第三个线程访问时,也即是锁的竞争很激烈,轻量级锁就会膨胀为重量级锁。重量级锁的代表就是synchronized,重量级锁会使除了此时拥有锁的线程以外的其他线程都阻塞。
6.2 锁升级的具体过程
我们可以把上面的锁升级过程,
当某个对象没有被作为锁对象时,它就是一个普通对象,Mark Word会记录该对象的HashCode,此时锁标志位是01,偏向锁标志位是0;
当该对象被添加同步锁,并有一个线程A抢到了该锁时,锁标志位还是01,但偏向锁标志位为1。另外的23bit记录的是抢到锁的线程id,此时表示该线程进入到了偏向锁状态;
当线程A再次试图获得锁时,JVM发现同步锁对象的标志位是01,偏向锁标志位是1,处于偏向状态。Mark Word中记录的线程id就是线程A自己的id,这表示线程A已经获得了这个偏向锁,直接就可以执行同步锁的代码;
若线程B试图获得这个锁时,JVM发现同步锁处于偏向状态,但Mark Word中的线程id代表的不是线程B。此时线程B就会用CAS操作来试图获得锁,这时获得锁是有可能成功的。如果抢锁成功,就会把Mark Word里的线程id改为线程B的id,代表线程B获得了这个偏向锁,然后就可以执行同步锁代码。如果抢锁失败,则执行下面的步骤5;
如果偏向锁状态时抢锁失败,代表着当前的锁有一定的竞争,偏向锁将升级为轻量级锁。JVM会在当前线程的线程栈中开辟一块单独的空间,里面保存指向该对象锁Mark Word的指针,同时在对象锁Mark Word中保存指向这片空间的指针。上述两个保存操作都是CAS操作。如果保存成功,代表线程抢到了同步锁,就把Mark Word中的锁标志位改成00,可以执行同步锁代码。如果保存失败,就表示抢锁失败,竞争太激烈,则继续执行下面的步骤6。
如果轻量级锁抢锁失败,JVM会使用自旋锁,自旋锁不是一个锁状态,只是代表不断的尝试抢锁。从JDK1.6开始,自旋锁默认启用,自旋次数由JVM决定。如果抢锁成功则执行同步锁代码,如果失败则继续执行下面的步骤7。
如果自旋锁重试之后依然抢锁失败,同步锁会升级至重量级锁,锁标志位改为10。在这个状态下,未抢到锁的线程都会被阻塞。