详谈Java中的锁机制

详谈Java中的锁机制

  • 1. 为什么使用锁
    • 1. 1 线程介绍
      • 1.1.1 线程的生命周期
      • 1.1.2 线程数与CPU内核的关系
      • 1.1.3 线程的创建方法
    • 1.2 锁的类型及区别
  • 2. 显式锁
    • 2.1 CLH队列锁介绍
    • 2.2 AQS介绍
      • 2.2.1 AQS的常用接口介绍
      • 2.2.2 AQS的使用方法:ReentrantLock的实现
      • 2.2.3. 如何自定义锁
  • 3. 内置锁
    • 3.1 CAS介绍
    • 3.2 volatile与synchronized介绍
      • 3.2.1 volatile和synchronized的概念
      • 3.2.2 synchronized的优化方法:
    • 3.4 使用synchronized的简单示例
  • 4. 总结

1. 为什么使用锁

提及锁,便避不开并发编程。在Java的使用过程中,并发编程是最容易出现问题的操作,且所造成的问题往往都如幽灵一般时隐时现,实难排查。因此Java提出锁的概念以保证并发编程中的线程安全,使程序在提升效率的同时保证较高的可靠性。

1. 1 线程介绍

由于Java中的锁主要是为了解决并发编程中引入的种种同步、竞争问题,因此追根溯源,在理清各种锁之前,还是应简单提及线程的概念。

1.1.1 线程的生命周期

线程的生命周期如下图所示。
详谈Java中的锁机制_第1张图片
这里有必要说明一下的是关于yeild方法,在Java SE6 Doc中对于yeild方法的说明是:

Causes the currently executing thread object to temporarily pause and allow other threads to execute.

放弃调用者所在线程的当前时间片儿,让其他线程去使用。但是java6中还提到:

A hint to the scheduler that the current thread is willing to yield its current use of a processor. The scheduler is free to ignore this hint.

也就是说,只是向CPU说明自己愿意放弃时间片儿,而是不是会暂停而去执行其他线程,还要看CPU自己的调度情况。尤其是多核CPU时,可能内核并未被充分利用,yield可能没有明显效果。
另外,针对Windows平台和linux内核对yield的处理方法也不相同:

Windows:
The Hotspot VM now implements Thread.yield() using the Windows SwitchToThread() API call. This call makes the current thread give up its current timeslice, but not its entire quantum.

Linux:
Under Linux, Hotspot simply calls sched_yield(). The consequences of this call are a little different, and possibly more severe than under Windows.

因此对yield方法的态度还是以了解为主,尽量不去使用它达成某种目的。

1.1.2 线程数与CPU内核的关系

在1.1.1中我们谈到了各个线程并行工作实际上是需要CPU来调度的,即并非每个线程都能够同时工作,而是各个线程的工作时间被划分成片儿(timeslice),由CPU不断地切换线程,从而实现各线程同时工作的“效果”。
这里我想扩展一下 最优线程数量的确定方法
实际上线程数量如何确认与具体的任务类型以及硬件环境都是相关的。比如在《Java Concurrency in Practice》书中提到了下面一段话:

The ideal size for a thread pool depends on the types of tasks that will be submitted and the characteristics of the deployment system. Thread pool sizes should rarely be hard‐coded; instead pool sizes should be provided by a configuration mechanism or computed dynamically by consulting Runtime.availableProcessors.

也就是说线程池中的线程数量不应该是确定的,而应该是计算出来的可变数值。首先需要明确的是,线程需要完成的任务为计算密集型,还是I/O密集型。
若一项任务没有读取I/O的部分,100%由计算组成,那么CPU基本全时间段运行,此使线程数最多与CPU内核数相同即可(或者内核数+1),因为过多的线程反而会占用CPU的一些精力去调度。那为什么最多可以达到内核数+1呢? 这是为了防止当内存占用过高,CPU访问虚拟内存出现页错误(或其他原因)以外挂起时,可以有一个线程做backup,保证CPU全功率运行;
若一项任务是I/O密集型,例如CPU的占用时间的一半都处于阻塞状态,线程数量就可以多一点了。一般经验数值是CPU内核数量的两倍。
实际上对于线程数的计算可以使用公式:

N(thread) = N(cpu) / (1 - 阻塞系数)

其中阻塞系数是:

阻塞系数 = 阻塞时间 /(阻塞时间 + 计算时间)

因此当阻塞时间为0时,线程数等于CPU内核书,而阻塞时间占到工作时间90%时,则需要10倍的CPU内核数的线程数量。
以上公式来源于《Java Concurrency in Practice》

1.1.3 线程的创建方法

说完线程,我想顺便扩展一下线程的创建方法。线程共两种创建方法:通过Runnable任务创建线程、通过继承Thread类创建线程。
使用Runnable任务创建线程方法示例:

private static class MyRunnable implements Runnable {
    @Override
    public void run() {
        System.out.println("runnable test");
    }
}

public static void main(String[] args) {
    Thread thread1 = new Thread(new MyRunnable());
    thread1.start(); // 启动线程
}

使用类继承方法的示例:

private static class Mythread extends Thread {
        @Override
        public void run() {
            System.out.println("thread test");
        }
}

public static void main(String[] args) {
    Mythread thread = new Mythread();
    thread.start();
}

当线程需要有返回值时,也可以使用Callable来创建线程(java1.5以后加入),但实际上Callable传入线程是通过Runnable进行的,因此并不能算为其他方法。
使用示例:

private static class MyCallable implements Callable<String> {
        @Override
        public String call() throws Exception {
            return "callable return value";
        }
}

public static void main(String[] args) {
	MyCallable callable = new MyCallable();
	// FutureTask实现了,RunnableFuture,而RunnableFuture则同时继承了Runnable和Future
	// Future接口中提供了cancel、isCancelled、isDone、get等任务控制和查询接口。
	// Note:接口之间的实现需要用继承:extends
	FutureTask<String> task = new FutureTask<String>(callable);
	new Thread(task).start();
	String result = task.get(); // 这里会阻塞,直至线程任务完成得到返回值;
	// String result = task.get(DELAY_TIME, SECONDS); // 使用这个可以设置阻塞超时;
}

1.2 锁的类型及区别

介绍完完线程的概念,那么并发编程的主角便可以入场了,那就是锁。在Java中常能见到很多关于锁的分类,比如:悲观锁,乐观锁,偏向锁,公平锁,非公平锁,等等。对于这些分类是依据锁的哪些特点笔者一直以来都有些困惑,因此希望通过这篇文章将各种锁的概念、原理、特征等梳理清晰。

2. 显式锁

Java中常见的的显式锁有ReentrantLock, ReentrantReadWriteLock, LockSupport, StampedLock(java1.8新增)。这些基本上都使用了AQS机制,结合CAS和CLH实现了面向不同需求的显式锁。

2.1 CLH队列锁介绍

在介绍各种显式锁以前,先简单梳理一下CLH(Craig, Landin, and Hagersten)队列锁,因为CLH队列锁也是Java中最常用的同步工具AQS的基本思想,而大部分锁都是使用AQS来实现的。
CLH队列锁是通过自旋来实现的:每个试图获取锁的线程都会有一个属于自己的CLH节点,节点中有是否具有锁的标志位locked,以及其前驱节点的引用。它在队列中等待的过程中不断循环访问前驱节点的locked标志位,直到前驱节点locked标志位变为false(释放锁),它便正式得到了锁。具体申请锁的流程分为以下三步:

  1. 线程A得到锁,创建CLH节点,locked标志位置为true;
  2. 线程B申请锁,如果是公平锁,那么B则创建CLH节点,找到队尾,将locked置为true,得到A线程的节点引用,不断循环访问A线程的locked位,等待得到锁;如果是非公平锁,则线程B会直接去队首试图获取锁,失败了才寻找队尾;
  3. 线程A执行完毕,释放锁,locked为false,节点回收,线程B察觉到以后得到锁。

2.2 AQS介绍

AbstractQueuedSynchronizer是Java中常用的一种同步工具。该抽象类定义了state成员用于记录锁状态,getState/setState用于获取和设置锁状态,以及CAS操作compareAndSetState用于变更锁状态。

2.2.1 AQS的常用接口介绍

AQS中需要重写的方法如下表所示:

方法名称 描述
protected boolean tryAcquire(int) 获取同步状态,主要操作需要包括获取当前锁状态,进行CAS设置同步状态。
protected boolean tryRelease(int) 释放同步状态,等待的线程有机会获取同步状态

AQS中提供了一些列模板方法用于自定义组件调用,这些方法基本分为:独占式获取/释放同步状态(是否有超时),共享式获取/释放同步状态(是否有超时),查询同步队列中的线程几种,详见下表:

方法名称 描述
void acquire(int) 调用tryAcquire获取同步状态,如果成功则返回,否则进入同步队列等待
void acquireInterruptibly(int) 与acquire相同,但是进入同步队列后可打断并返回InterruptionException
boolean tryAcquireNanos(int, long) 在以上基础上增加了超时,规定时间内获取到锁返回true,否则返回false
void acquireShared(int) 共享式获取同步状态,可以多个线程同时获取同步状态
void acquireSharedInterruptibly(int) 共享式获取同步状态,且可打断
boolean tryAcquireSharedNanos(int, long) 共享式获取同步状态,且有超时
boolean releaseShared(int) 共享式释放同步状态
Collection getQueuedThreads() 获取等待在同步队列上的线程集合

2.2.2 AQS的使用方法:ReentrantLock的实现

AQS的使用是主要围绕模板方法和CAS的。
前文中讨论到,AQS类中定义了一个volatile的state共享状态,用于记录当前共享工具的归属情况。看到volatile修饰,马上便可以联想到CAS了,查看源码,果然,AQS是使用CAS操作来维护state的:

    protected final boolean compareAndSetState(int var1, int var2) {
        return unsafe.compareAndSwapInt(this, stateOffset, var1, var2);
    }

具体CAS的原理我放在了3.1里面讨论,这里具体看一下AQS是怎样通过模板方法,重写tryAcquire和CAS来实现同步功能,首先以公平锁为例:

        protected final boolean tryAcquire(int var1) {
            Thread var2 = Thread.currentThread();
            int var3 = this.getState();
            if (var3 == 0) {
            // 当前的state是无人占用的,需要继续判断CLH队列是否有人排队,无人排队才会继续申请锁。
                if (!this.hasQueuedPredecessors() && this.compareAndSetState(0, var1)) {
                    this.setExclusiveOwnerThread(var2);
                    return true;
                }
            } else if (var2 == this.getExclusiveOwnerThread()) {
            // 如果当前state本身就不为0了,证明一定有人占用锁,那么需要判断是否该线程自己在占用。
                int var4 = var3 + var1;
                if (var4 < 0) {
                    throw new Error("Maximum lock count exceeded");
                }

                this.setState(var4);
                return true;
            }
            return false;
        }

tryAcquire是AQS公开的一个需要重写的方法,在这个方法里可以选择将锁设计为可重入或者公平/非公平。上面这段代码为可重入的公平锁,它的主要特点是:1)在获取锁以前会首先查看CLH队列锁中是否具有前驱节点(AQS也是以CLH队列锁为基础的)–hasQueuedPredecessors(),如果没有前驱节点,则直接使用CAS方法compareAndSetState更新state状态,如果成功了则记录下来该线程;2)如果当前state本身已经不为0了,那么就确认当前是有人占用锁的,无需检查CLH前驱节点,此使需要判断当前锁的主人是否是申请者本身,如果是,则state需要更新(+1),直接返回true。这里是实现可重入锁的关键部分。
tryAcquire重写以后是如何使用的呢?一般我们使用显示锁是调用它的lock方法的,所以先看下lock的源码:

        final void lock() {
            this.acquire(1);
        }

发现它使用的是AQS的模板方法acquire,并不是tryAcquire呀!于是再看一下这个模板方法:

    public final void acquire(int var1) {
    // 首先通过重写的tryAcquire,如果没有成功,并且CLH队列插入节点成功,那么认为该线程开始等待锁了,开始进入阻塞状态。
        if (!this.tryAcquire(var1) && this.acquireQueued(this.addWaiter(AbstractQueuedSynchronizer.Node.EXCLUSIVE), var1)) {
        // 这里是通过线程的interrupt方法实现的
            selfInterrupt();
        }

    }

终于发现,原来我们重写的tryAcquire只是申请锁中间的一个小步骤,后面更能体现AQS的CLH队列锁的特征。
除了公平锁以外,ReentrantLock还提供了非公平锁的实现.非公平锁在方法调用结构上有些许区别,它首先在lock中便申请了一次同步状态,成功了则直接记录线程,失败了才会继续走获取锁的流程。在tryAcquire中,它又一次直接试图更新state状态。也就是说,它不仅比公平锁多了一次申请更新状态,还在更新状态前省去了判断CLH队列前驱节点的过程,也就是说它在试图“插队”,这也就是非公平的体现。

        final void lock() {
            if (this.compareAndSetState(0, 1)) {
                this.setExclusiveOwnerThread(Thread.currentThread());
            } else {
                this.acquire(1);
            }

        }

        protected final boolean tryAcquire(int var1) {
            return this.nonfairTryAcquire(var1);
        }


        final boolean nonfairTryAcquire(int var1) {
            Thread var2 = Thread.currentThread();
            int var3 = this.getState();
            if (var3 == 0) {
                if (this.compareAndSetState(0, var1)) {
                    this.setExclusiveOwnerThread(var2);
                    return true;
                }
            } else if (var2 == this.getExclusiveOwnerThread()) {
                int var4 = var3 + var1;
                if (var4 < 0) {
                    throw new Error("Maximum lock count exceeded");
                }

                this.setState(var4);
                return true;
            }

            return false;
        }

2.2.3. 如何自定义锁

// TODO

3. 内置锁

除了显式锁以外,Java还有一个非常重要的锁:synchronized关键词。这是Java提供的一个内置锁,无需主动申请和释放,使用也比较安全。一般没有特殊的需求,我们都是优先使用synchronized的,它可以最大程度避免死锁。

3.1 CAS介绍

CAS(Compare and Swap)是指CPU的原子操作。我们都知道在并发编程中,线程是不能轻易去读写主内存中的变量的,即便主内存中变量使用了volatile修饰,也并不能保证其原子性,导致类似于i++等需要进行“read+write”的组合操作时,会发生线程不安全情况。因此新一代CPU提出了CAS方案,封装了一系列原子变量,如:AtomicInteger等。在这些类中,通过compareAndSet将“read+write”封装为一步操作,保证在中间不会出现时间片用尽、调度线程而出现写入错误的情况。
CAS顾名思义,主要是通过比较+交换来实现的。首先线程会读取主内存中的value,计算后使用CAS确认是否看而已写回主内存,比较方法是:先判断当前主内存的value与自己前一次读取内容是否相同,如果相同则交换,如果不同则再从头来一次,如下图所示,其中COMPARE和SWAP是被CPU组合为单指令操作的。

Created with Raphaël 2.2.0 START READ CALCULATE COMPARE SWAP END yes no

因此CAS被称为无锁化操作,这里面不需要进行线程阻塞等上下文切换,可以使线程继续运行,从而节省了上下文切换所消耗的时间。但是这种操作也容易引发一些问题:
ABA问题-> 由于CAS操作只关心当前与它读取的旧值是否一致,所以可能会忽略中间出现的变化。比如某变量在当初读取值的时候是A,线程计算的时候变量变为B,重新写入的时候它又变回了A,比较发现没有变化,然而实际上发生过变化的。为了解决这个问题,可以在比较基础上增加个版本戳stamp,比较值的同时也要比较版本戳。比如jdk1.5开始提供的AtomicStampedReference就通过版本戳解决此问题;
循环太久导致CPU繁忙问题->CAS是通过自旋来实现锁的,但是自旋过程中CPU一直处在工作中。如果线程过多且当前线程优先级太低,可能导致某线程常期处于自旋状态,对CPU的消耗和时间消耗都超过了线程挂起的消耗,得不偿失。因此有时使用自适应自旋锁在适当的时候会将其更改为将线程真正挂起;(此处回顾2.2.2中介绍ReentrantLock使用CAS的部分,它虽然是原子操作,但并未使用自旋,而是在判断一次状态后,直接决定是否加入队列中等待)

3.2 volatile与synchronized介绍

3.2.1 volatile和synchronized的概念

volatile和synchronized都是非常常用的描述符,但此处还是希望能够简单总结一下两个描述符的区别:volatile:有两个功能,可以保证共享变量的可见性(某线程将共享变量变更以后,其他线程将被迫重新从主内存中读取一次,保证各线程能够第一时间看到共享变量的变化),以及读取的有序性(volatile可以防止指令的重排序),但是不能保证操作的原子性。
volatile修饰符会为被修饰的变量增加一个“lock:”前缀,该lock前缀可以使当前处理器缓存行的数据直接写到了系统内存中,同时还可以令其他CPU里缓存了该地址的数据无效,导致其他线程发现数据无效,不得不重新从主内存中再次读取。
synchronized:可以保证可见性、有序性以及原子性。
synchronized是通过monitor对象实现锁的。针对synchronized的代码块JVM会在指令的前后标记monitorEnter和monitorExit,这是两句指令,用于将之间的指令整合成一块儿,当执行到此处时,需要活得monitor的所有权,即获取锁;针对标记synchronized的方法,JVM会使用ACC_SYNCHRONIZED标识符来区别其与普通方法。当调用有ACC_SYNCHRONIZED标识符的方法时,同样需要获取monitor对象,方法执行完成释放。同一时间不可有多个线程拥有monitor对象。

3.2.2 synchronized的优化方法:

synchronized是一个可变化的锁,这也是JVM对其进行的一系列优化:
当没有发现多线程竞争现象时,synchronized属于偏向锁,即当线程发现该锁为偏向锁(对象的markword中会有锁标识),且线程ID为当前线程,则无需任何获取锁操作,而直接执行代码块;
当使用偏向锁时发现获取锁的线程不是当前线程,且CAS竞争失败,此时便成为轻量级锁(通过更改markword标识);轻量级锁即自旋锁或适应性自旋锁,通过CAS竞争来获取同步状态的乐观锁;
当自旋超过一定阈值(切换上下文的耗时)时,轻量级锁将变为重量级锁,即悲观锁,此时synchronized修饰的部分会直接被锁定,等待的线程会被挂起。

3.4 使用synchronized的简单示例

在介绍完synchronized以后我来举个简单且常见的例子来看一下synchronized的使用场景:懒汉模式下的单例模式(使用双重校验锁):

// 定义为volatile防止变量初始化时发生重排序,导致返回的对象没有初始化完成,部分成员为空
private static volatile CarAdapterManager  sInstance = null;

public static CarAdapterManager getInstance(Context context) {
// 第一次判空,为了降低synchronized造成的损耗
   if (sInstance == null) {
     // 代码块静态锁,即类锁(针对class对象的锁)
       synchronized(CarAdapterManager.class) {
           // 第二次判空,防止获取锁的过程中,其他线程将对象初始化。
            if (sInstance == null) {
                sInstance = new CarAdapterManager(context);
            }
        }
   }
   return sInstance;
}

单例模式是最常用synchronized的场景,因为这里非常容易发生竞争,而使用synchronized即能保证线程安全,又不容易发生死锁。但其实还可以使用另一种替代方案以简化代码,比如使用延迟类初始化方法:

public class CarAdapterManager  {
    private CarAdapterManager(){}
    private static class SingletonHolder{
        public static CarAdapterManager  instance = new CarAdapterManager();
    }
    public static CarAdapterManager  getInstance(){
        return SingletonHolder.instance;
    }
}

细心的你会发现这段代码里是没有锁的,却能保证线程安全,为什么呢?原来这和类的初始化有关!
SingletonHolder这个静态内部类在加载后是没有进行初始化的,而在第一次有人调用getInstance时,它的静态成员需要被引用,才会触发类的初始化,返回CarAdapterManager的实例,而在类的初始化过程中,JVM为其增加了锁,防止类重复初始化。所以这相当于利用了JVM内部的锁,不需要自己再使用锁了。

4. 总结

在文章的最后,我们总结一下常见的锁的分类,目前针对锁的分类比较多,但每种分类都是依据不同的角度。一般常见的有:悲观锁/乐观锁,公平锁/非公平锁,可重入锁/非可重入锁,独占锁/共享锁等。
1) 乐观锁 vs 悲观锁
所谓乐观锁,就是每次获取锁之前默认共享变量是无人触碰的,因此即用即看,而不对它加以保护。而悲观锁就是默认这里一定有人触碰,必须先把他锁起来,其他人不得入内。所以上文提到的CAS操作,都是乐观锁,因为它没有真的把代码块锁起来。而重量级锁的synchronized,会真正将共享变量锁起,其他线程将阻塞等待。
2) 公平锁 vs 非公平锁
这是根据锁的获取规则定义的。
上文简单提到了两种锁的特征:公平锁,就是任何线程都需要经过相同的步骤获取锁,即有人排在队伍中就需要先排队;而非公平锁则是获取锁的时候,无论队伍中是否有人,都首先去看一下锁是否可以直接拿到,属于插队行为,是不公平的。
3) 自旋锁 vs 适应性自旋锁
所谓自旋锁,即CAS中循环获取锁的机制。这里的重要特征是CPU没有进行上下文切换将线程挂起,而是让它循环访问锁的状态,通过循环来实现“假阻塞”;适应性自旋锁则是设置了一个非固定的超时或次数限制,对于某个锁,如果其他线程很容易获取或者刚刚获取,可能认为这个锁教容易获取,会增加自旋的事件/次数限制;而如果某个锁非常难以获取,则可能降低次数限制或者直接进入线程挂起,从而避免非必要CPU开销。
4) 可重入锁 vs 非可重入锁
即某个线程如果得到了一个锁,那么它是否可以再次获取相同的锁。在上文中我们就是用可重入锁ReentrantLock来举例的,在tryAcquire中能够看到,可重入锁会判断获取锁的是否是当前线程,这样可以防止线程中进入同一个锁控制的不同方法而导致的死锁;非可重入锁就是即便同一个线程,也不可以多次活得相同的锁。
5) 独占锁 vs 共享锁
独占锁,即只可以单个线程获取的锁,而共享锁是可以多个线程同时获取的。可能好奇的朋友要问,既然是多个线程同时获取,那为何还要上锁呢?实际上共享锁也有自己的规则,比如加上共享锁时,其他线程就不可以再加独占锁,且一般共享锁中的内容不可以进行写操作。一般共享锁/独占锁都是可以通过AQS来自行实现的,Java中的ReentrantReadWriteLock就是一个可重入的读写独占/共享锁。在写的时候,使用独占锁,在读时,使用共享锁。
6) 偏向锁 vs 轻量级锁 vs 重量级锁
这些实际上是专门针对synchronized锁进行的划分。
偏向锁:当线程发现该锁为偏向锁(对象的markword中会有锁标识),且线程ID为当前线程,则无需任何获取锁操作,而直接执行代码块;
轻量级锁:当使用偏向锁时发现获取锁的线程不是当前线程,且CAS竞争失败,此时便成为轻量级锁(通过更改markword标识);轻量级锁即自旋锁或自适应性自旋锁,通过CAS竞争来获取同步状态的乐观锁;
重量级锁:轻量级锁即自旋锁或适应性自旋锁,通过CAS竞争来获取同步状态的乐观锁;
当自旋超过一定阈值(切换上下文的耗时)时,轻量级锁将变为重量级锁,即悲观锁,此时synchronized修饰的部分会直接被锁定,等待的线程会被挂起。

写在最后:Java之所以提出锁的概念,是因为有并发编程的需求,而所谓并发的安全性,就是多线程同时读写共享变量的安全性。解决这个安全问题的方法无非是从三个因素入手:操作的原子性、有序性和可见性。Java围绕着三个特性提供了多种锁,包括synchronized内置锁以及一系列显式锁,而这些锁实际上都基于CAS结合CLH队列锁的原理。所以说,学习这些锁的基础,便是了解CAS和CLH原理,以及各种锁是如何使用他们的。但是本篇文章的介绍也只围绕锁的概念,没有仔细研究它所涉及的周边知识,比如JVM的内存特征类的加载等。这些就留到以后的文章来梳理吧。菜鸟第一次总结博文,希望有缘的读者能够提出宝贵意见,多谢!

你可能感兴趣的:(Java)