zoukankan      html  css  js  c++  java
  • java并发编程系列之原理篇-synchronized与锁

    前言

    Java中的锁都是基于对象的锁,Java中的每一个对象都可以作为一个锁,我们常听到类锁其实也是对象锁,因为Java类只有一个class对象(一个Java类可以有多个实例对象,多个实例对象共享这一个Java类)。之所以有锁的概念,都是因为在多个线程在访问一个共享变量资源时会发生一些不可控制的问题。所以,锁控制的就是共享资源对象。

    锁的分类

    Java 6 为了减少获得锁和释放锁带来的性能消耗,引入了“偏向锁”和“轻量级锁“。在Java 6 以前,所有的锁都是”重量级“锁。所以在Java 6 及其以后,一个对象其实有四种锁状态,它们级别由低到高依次是:

    1. 无锁状态
    2. 偏向锁状态
    3. 轻量级锁状态
    4. 重量级锁状态

    锁还有其他的分类,比如自旋锁乐观锁和悲观锁共享锁(读)和独享锁(写)可重入锁和不可重入锁公平锁和非公平锁,下面我们来介绍一下这些概念。

    • 自旋锁 - 它是指当一个线程在获取锁的时候,如果锁已经被其他线程获取到了,那么该线程将循环等待,然后不断的判断锁是否能够被成功获取,直到获取到锁菜退出循环。比如被synchronized关键字修饰的对象锁就是一个自旋锁。

    • 乐观锁 - 它是假设线程在争抢锁时不会发生冲突(读写冲突),如果发生冲突,则判断当前锁的状态是否已经改变,如果改变,则本次写操作失效,重新进行读取最新数值后的写操作。比如ReentrantReadWriteLock中的readLock就实现了乐观锁的机制。
    • 悲观锁 - 它是假设线程在争抢锁时一定会发生冲突(读写),所以如果一个线程争抢了锁在进行读或写操作的时候,其他线程是不能进行读或写操作的。比如synchronized和ReentrantLock都是一种悲观锁。

    • 共享锁(读) - 它是指一个线程在拥有该共享锁的时候,它可以对共享资源进行读操作,同时其他线程同样也可以获得该共享锁对共享资源进行读的操作,但是不能对共享资源进行写的操作。比如ReentrantReadWriteLock.readLock()就是一个共享锁。
    • 独享锁(写) - 它是指一个线程拥有了该独享锁的时候,它可以对共享资源进行写操作,但其他线程就不能获得该独享锁对共享资源进行读或者写的操作。比如ReentrantReadWriteLock.writeLock()就是一个独享锁。

    • 可重入锁 - 它是指一个线程获取了一个可重入锁之后,它还可以多次来获取这个可重入锁,也就是说可以自由进入同一个锁所同步的其他的代码。比如synchronized和ReentrantLock就是一种可重入锁。
    • 不可重入锁 - 它是指一个线程获取了一个不可重入锁之后,不能再进入该锁所同步的其他代码块。

    • 公平锁 - 它是指线程获取锁的顺序是按照先进先出(FIFO)的顺序来的。比如ReentrantLock可以通过构造函数传递一个true来实现公平锁。
    • 非公平锁 - 它是指线程获取锁的顺序有可能因为CPU调度策略的不同而使线程获取锁的顺序不确定。synchronized和ReentrantLock都可以实现非公平锁。

    关于锁的几种状态,是锁被线程占用之后,由于锁被多个线程争抢,从而由无状态锁向其他状态进行升级转换。我们这里结合synchronized关键字进行解释。

    Synchronized关键字

    Synchronized关键字,用来给一段代码加上一个锁,从而实现多个线程在访问同一资源对象锁的时候进行同步执行(也即串行执行)。它是一个独享、非公平、悲观的锁。它通常有以下几种使用方法:

    // 关键字在实例方法上,锁为当前类的实例
    public synchronized void instanceLock() {
        // code
    }
    
    // 关键字在静态方法上,锁为当前Class对象
    public static synchronized void classLock() {
        // code
    }
    
    // 关键字在代码块上,锁为括号里面的对象
    public void blockLock() {
        Object o = new Object();
        synchronized (o) {
            // code
        }
    }
    

    被synchronized关键字修饰的代码块属于临界区,也就是说同一时刻只能有一个线程来执行临界区的代码。关于类锁和类的对象锁有以下两种等价写法:
    类的对象锁写法

    // 关键字在实例方法上,锁为当前实例
    public synchronized void instanceLock() {
        // code
    }
    
    // 关键字在代码块上,锁为括号里面的对象
    public void blockLock() {
        synchronized (this) {
            // code
        }
    }
    

    类锁的写法

    // 关键字在实例方法上,锁为当前实例
    public static synchronized void instanceLock() {
        // code
    }
    
    // 关键字在代码块上,锁为括号里面的对象
    public void blockLock() {
        synchronized (this.getClass()) {
            // code
        }
    }
    

    锁的优化

    在使用锁的时候,JVM会对包含锁的代码进行锁优化,锁优化包括两种:锁消除锁粗化

    1. 锁消除

    锁消除可以通过JVM的参数来设置,-XX:+DoEscapeAnalysis -XX:+EliminateLocks。当这样设置后,当某一个本方法只被一个线程重复调用,不存在产生安全问题时,JIT会触发优化,在方法被连续调用时默认去掉多余的锁。比如某一方法使用StringBuffer的append方法进行连续调用时,则会消除中间调用方法里的synchronized。

    1. 锁粗化

    锁粗化和锁消除类似,将代码中使用多个同一个对象锁的synchronized关键字进行合并,只使用一个。

    synchronized关键字的原理(锁状态的升级过程)

    我们知道,当没有线程来争夺锁的时候,这个锁就是一个无状态锁,这个时候任何线程都可以去尝试来争夺该锁并对共享资源进行修改。而随着多个线程竞争情况的升级,锁的状态也会随着升级。同时,锁也会发生降级,但它的发生条件是比较苛刻的,它发生在JVM进行Stop-The-World期间,当JVM进入安全点的时候,会检查是否有闲置的锁,然后进行降级。这里我们不过多去讨论锁降级。

    java对象头

    由于Java的锁都是基于对象的,所以我们要来看一看对象的“锁信息”被存放在哪里。我们都知道,Java中的每个实例化对象都存放在堆内存中,除了存放该对象的成员变量的数据以外,还有一个对象头。如果是非数组类型,则用2个字宽来存储对象头,如果是数组,则会用3个字宽来存储对象头。在32位处理器中,一个字宽是32位,64位处理器中,一个字宽是64位。对象头的内容如下:

    长度 内容 说明
    32/64bit Mark Word 存储对象的hashCode或锁信息等
    32/64bit Class Metadata Address 存储到对象类型数据的指针
    32/64bit Array length 数组的长度(如果是数组)

    从上表中我们可以看出对象的锁信息存放在Mark Word字段中,那么我们来看一下具体的Mark Word里边的内容:

    锁状态 29 bit 或 61 bit 1 bit 是否是偏向锁? 2 bit 锁标志位
    无锁 对象的hashcode(25bit)和分代年龄(4bit) 0 01
    偏向锁 线程ID(23bit)、Epoch时间戳(2bit)、分代年龄(4bit) 1 01
    轻量级锁 指向栈中锁记录的指针 此时这一位不用于表示偏向锁 00
    重量级锁 指向互斥量(重量级锁)的指针 此时这一位不用于表示偏向锁 10
    GC标记 ----- 此时这一位不用于表示偏向锁 11

    可以看到,当对象状态为偏向锁时,Mark Word存储的是偏向的线程ID;当状态为轻量级锁时,Mark Word存储的是指向线程栈中锁记录的指针;当状态为重量级锁时,Mark Word为指向堆中的monitor对象的指针
    现在我们知道了对象的锁信息被存放在对象头的Mark Word里边,那么我们来看一下这个对象锁是怎么一步一步升级的。

    偏向锁

    Hotspot的作者经过以往的研究发现大多数情况下锁不仅不存在多线程竞争,而且总是由同一线程多次获得,于是引入了偏向锁
    偏向锁会偏向于第一个访问锁的线程,如果在接下来的运行过程中,该锁没有被其他的线程访问,则持有偏向锁的线程将永远不需要触发同步。也就是说,偏向锁在资源无竞争情况下消除了同步语句,连CAS操作都不做了,提高了程序的运行性能

    一个线程在第一次进入同步块时,会在对象头和栈帧中的锁记录里存储锁的偏向的线程ID。当下次该线程进入这个同步块时,会去检查锁的Mark Word里面是不是放的自己的线程ID。
    如果是,表明该线程已经获得了锁,以后该线程在进入和退出同步块时不需要花费CAS操作来加锁和解锁 ;如果不是,就代表有另一个线程来竞争这个偏向锁。这个时候会尝试使用CAS来替换Mark Word里面的线程ID为新线程的ID,这个时候要分两种情况:

    • 成功,表示之前的线程不存在了, Mark Word里面的线程ID为新线程的ID,锁不会升级,仍然为偏向锁;
    • 失败,表示之前的线程仍然存在,那么暂停之前的线程,设置偏向锁标识为0,并设置锁标志位为00,升级为轻量级锁,会按照轻量级锁的方式进行竞争锁。

    CAS: Compare and Swap
    比较并设置。用于在硬件层面上提供原子性操作。在 Intel 处理器中,比较并交换通过指令cmpxchg实现。 比较是否和给定的数值一致,如果一致则修改,不一致则不修改。

    线程竞争偏向锁的过程如下:
    偏向锁竞争流程
    图中涉及到了lock record指针指向当前堆栈中的最近一个lock record,是轻量级锁按照先来先服务的模式进行了轻量级锁的加锁。
    撤销偏向锁
    偏向锁使用了一种等到竞争出现才释放锁的机制,所以当其他线程尝试竞争偏向锁时, 持有偏向锁的线程才会释放锁。偏向锁升级成轻量级锁时,会暂停拥有偏向锁的线程,重置偏向锁标识,这个过程看起来容易,实则开销还是很大的,大概的过程如下:

    1. 在一个安全点(在这个时间点上没有字节码正在执行)停止拥有锁的线程。
    2. 遍历线程栈,如果存在锁记录的话,需要修复锁记录和Mark Word,使其变成无锁状态。
    3. 唤醒被停止的线程,将当前锁升级成轻量级锁。
      所以,如果应用程序里所有的锁通常处于竞争状态,那么偏向锁就会是一种累赘,对于这种情况,我们可以一开始就把偏向锁这个默认功能给关闭:
    -XX:UseBiasedLocking=false
    

    下面这个经典的图总结了偏向锁的获得和撤销:
    偏向锁的获得和撤销

    轻量级锁

    JVM会为每个线程在当前线程的栈帧中创建用于存储锁记录的空间,我们称为Displaced Mark Word。如果一个线程获得锁的时候发现是轻量级锁,会把锁的Mark Word复制到自己的Displaced Mark Word里面。
    然后线程尝试用CAS将锁的Mark Word中用来指向栈中锁记录的位置替换为指向锁记录的指针。如果成功,当前线程获得锁,那么当前线程的栈帧中存储着锁的记录,另外栈中还有一个用来指向当前对象的对象头Mark Word的一个owner存储空间,用来说明当前引用的是哪个对象锁。如果失败,表示Mark Word已经被替换成了其他线程的锁记录,说明在与其它线程竞争锁,当前线程就尝试使用自旋来获取锁。

    自旋:不断尝试去获取锁,一般用循环来实现。

    自旋是需要消耗CPU的,如果一直获取不到锁的话,那该线程就一直处在自旋状态,白白浪费CPU资源。解决这个问题最简单的办法就是指定自旋的次数,例如让其循环10次,如果还没获取到锁就进入阻塞状态。
    但是JDK采用了更聪明的方式——适应性自旋,简单来说就是线程如果自旋成功了,则下次自旋的次数会更多,如果自旋失败了,则自旋的次数就会减少。
    自旋也不是一直进行下去的,如果自旋到一定程度(和JVM、操作系统相关),依然没有获取到锁,称为自旋失败,那么这个线程会阻塞。同时这个锁就会升级成重量级锁。
    轻量级锁的释放
    在释放锁时,当前线程会使用CAS操作将Displaced Mark Word的内容复制回锁的Mark Word里面。如果没有发生竞争,那么这个复制的操作会成功。如果有其他线程因为自旋多次导致轻量级锁升级成了重量级锁,那么CAS操作会失败,此时会释放锁并唤醒被阻塞的线程。
    请参考下边的加锁和解锁流程图:
    轻量级锁加解锁流程图

    重量级锁

    当一个对象锁成为了重量级锁之后,它的Mark Word中就有一个位置用来指向一个用来监视线程状态的对象监视器,它是通过互斥量来实现的。重量级锁依赖于操作系统的互斥量(mutex) 实现的,而操作系统中线程间状态的转换需要相对比较长的时间,所以重量级锁效率很低,但被阻塞的线程不会消耗CPU。
    前面说到,每一个对象都可以当做一个锁,当多个线程同时请求某个对象锁时,对象锁会使用一个对象监视器Monitor来监视该对象。这个Monitor中设置几种状态用来区分请求的线程:

    • Contention List:所有请求锁的线程将被首先放置到该竞争队列
    • Entry List:Contention List中那些有资格成为候选人的线程被移到Entry List
    • Wait Set:那些调用wait方法被阻塞的线程被放置到Wait Set
    • OnDeck:任何时刻最多只能有一个线程正在竞争锁,该线程称为OnDeck
    • Owner:获得锁的线程称为Owner
    • !Owner:释放锁的线程
      当一个线程尝试获得锁时,如果该锁已经被占用,则会将该线程封装成一个ObjectWaiter对象插入到Contention List的队列的队首,然后调用park函数挂起当前线程。
      当线程释放锁时,会从Contention List或EntryList中挑选一个线程唤醒,被选中的线程叫做Heir presumptive即假定继承人,假定继承人被唤醒后会尝试获得锁,但synchronized是非公平的,所以假定继承人不一定能获得锁。这是因为对于重量级锁,线程先自旋尝试获得锁,这样做的目的是为了减少执行操作系统同步操作带来的开销。如果自旋不成功再进入等待队列。这对那些已经在等待队列中的线程来说,稍微显得不公平,还有一个不公平的地方是自旋线程可能会抢占了Ready线程的锁。
      如果线程获得锁后调用Object.wait方法,则会将线程加入到WaitSet中,当被Object.notify唤醒后,会将线程从WaitSet移动到Contention List或EntryList中去。需要注意的是,当调用一个锁对象的wait或notify方法时,如当前锁的状态是偏向锁或轻量级锁则会先膨胀成重量级锁

    锁升级的步骤总结

    每一个线程在准备获取共享资源时:

    第一步,检查MarkWord里面是不是放的自己的ThreadId ,如果是,表示当前线程是处于 “偏向锁” 。

    第二步,如果MarkWord不是自己的ThreadId,锁升级,这时候,用CAS来执行切换,新的线程根据MarkWord里面现有的ThreadId,通知之前线程暂停,之前线程将Markword的内容置为空。

    第三步,两个线程都把锁对象的HashCode复制到自己新建的用于存储锁的记录空间,接着开始通过CAS操作, 把锁对象的MarKword的内容修改为自己新建的记录空间的地址的方式竞争MarkWord。

    第四步,第三步中成功执行CAS的获得资源,失败的则进入自旋 。

    第五步,自旋的线程在自旋过程中,成功获得资源(即之前获的资源的线程执行完成并释放了共享资源),则整个状态依然处于 轻量级锁的状态,如果自旋失败 。

    第六步,进入重量级锁的状态,这个时候,自旋的线程进行阻塞,等待之前线程执行完成并唤醒自己。

    不同等级的锁优缺点对比

    下表来自《Java并发编程的艺术》:

    优点 缺点 适用场景
    偏向锁 加锁和解锁不需要额外的消耗,和执行非同步方法比仅存在纳秒级的差距。 如果线程间存在锁竞争,会带来额外的锁撤销的消耗。 适用于只有一个线程访问同步块场景。
    轻量级锁 竞争的线程不会阻塞,提高了程序的响应速度。 如果始终得不到锁竞争的线程使用自旋会消耗CPU。 追求响应时间。同步块执行速度非常快。
    重量级锁 线程竞争不使用自旋,不会消耗CPU。 线程阻塞,响应时间缓慢。 追求吞吐量。同步块执行时间较长。

    参考资料

    深入浅出Java多线程
    《Java并发编程的艺术》

  • 相关阅读:
    nginx配置404
    js修改浏览器url
    mysql DATE_ADD DATE_SUB
    centos6.5 ssh安全优化,修改默认端口名,禁止root远程登录
    关于mysql varchar 类型的最大长度限制
    IIS7多域名绑定同一物理目录,设置不同默认文档的解决方案
    获取某个数据所在数据列表中的行数 mysql
    安全模式不能删除使用SET SQL_SAFE_UPDATES = 0;
    Failed to run the WC DB work queue associated with 错误的解决
    mysql正则匹配解决查询一个字段是否在另一个字段中
  • 原文地址:https://www.cnblogs.com/mr-ziyoung/p/13085213.html
Copyright © 2011-2022 走看看