java多线程进阶(二)同步锁

目录

1、原子性问题

2、锁

2.1、实例锁

2.2、类锁

2.3、代码块

3、锁的存储

4、锁的类型

4.1、乐观锁

4.2、悲观锁

5、同步锁状态转换

5.1、无锁

5.1.1、基础信息

5.1.2、流程分析

5.1.3、升级总结

5.2、偏向锁

5.2.1、基础信息

5.2.2、流程分析

5.2.3、升级总结

5.2.4、批量重定向

5.3、轻量级锁

5.3.1、基础信息

5.3.2、流程分析

​5.3.3、升级总结

5.3.4、重入计数

5.4、重量级锁

5.4.1、基础信息

5.4.2、流程分析

5.4.3、升级总结

6、synchronized的降级

7、synchronized的优化


1、原子性问题

一千个线程循环计数,最终结果总是小于等于1000的随机数。.

public class Count {

    public static int count = 0;

    public static void incr() {
        try {
            Thread.sleep(1);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        count++;
    }

    public static void main(String[] args) throws InterruptedException {
        for (int i = 0; i < 1000; i++) {
            new Thread(() -> Count.incr()).start();
        }
        Thread.sleep(2000);
        System.out.println("结果:" + count);
    }
}

java多线程进阶(二)同步锁_第1张图片

2、锁

在java中,加锁需要使用synchronized关键字,锁的本质就是对于共享资源访问的一个限制,它让同一时间内只有一个线程能访问这个共享资源,以此确保多线程并发的原子性操作,因此对于synchronized而言,加锁是有作用范围的,范围就是共享资源的使用范围。

2.1、实例锁

修饰实例方法,作用于当前实例加锁,进入同步代码前要获得当前实例的锁,只针对于当前对象实例有效。

public class SynchronizedDemo {

    synchronized void method1() {
        
    }

    void method2() {
        synchronized (this) {

        }
    }
}

2.2、类锁

静态方法,作用于当前类对象加锁,进入同步代码前要获得当前类对象的锁,针对所有对象都互斥,因为静态方法是唯一的,所以在静态方法上加锁也是类锁。

public class SynchronizedDemo {

    synchronized static void method3() {

    }

    void method4() {
        synchronized (SynchronizedDemo.class) {

        }
    }
}

2.3、代码块

修饰代码块,指定加锁对象,对给定对象加锁,进入同步代码库前要获得给定对象的锁,如单例模式给HashMap加锁。

public class SynchronizedDemo {
    Object object = new Object();
    
    void method5() {
        synchronized (object) {
            
        }
    }
}

3、锁的存储

synchronize关键字是对某一个对象进行加锁,那么肯定会在某个位置上具有标记,线程可以根据标记判断是否加锁,那么可以确定的就是,标记应该在加锁对象上。

在java中对某一个对象加锁之后,在这个对象的JVM层面的存储结构中,对象头保存有关于锁的信息。

java多线程进阶(二)同步锁_第2张图片

下表是32位存储的内容,64位与32位的存储是几乎没有差别的,可以看到在表中,除了无锁状态之外,还有三种锁,那么一个synchronized具有三种锁类型吗?
java多线程进阶(二)同步锁_第3张图片

4、锁的类型

当两个线程抢占资源的时候,会经历三种类型的锁:偏向锁,轻量级锁,重量级锁,它们是同一把锁的三种状态。
一定要意识到,锁本身就意味着额外的性能消耗,因此为了提升性能,最好的办法是无锁,因此java对synchronized做出了一些优化,三种锁类型就是优化结果。

但是在了解synchronized之前,需要了解一下乐观锁与悲观锁

4.1、乐观锁

乐观锁的预期是乐观的,它默认不会有人修改数据,但是它又无法避免真的可能会有人修改数据,所以它将会比较预期数据和原始数据是否一致,如果一致就修改,不一致就修改失败,下文中CAS就是乐观锁的思想,意为比较并替换。

乐观锁会出现ABA的情况,即数据A被改为B,又被改为A,乐观锁无法发现数据被修改,为解决此问题,可以使用版本迭代的方式来检测是否被篡改过数据,在进行修改时版本上升,在进行比较时同时比较数据和版本

CAS在许多的实现下,其操作必须是原子性的,因为其使用在多线程情况下,进行的本质是两个步骤,比较并替换,这个步骤必须被合成一个原子性操作以保证线程安全。在JAVA实现中,常采用四个参数,object,offset,A,B。offset是偏移量,和Object组合获得内存中的lock_flag,A是预期值,B是要更新的数值,以此做到原子操作。

在CAS上,依旧会有lock,这个lock并非JAVA的锁,而是类似总线锁的操作,以确保多CPU下的安全,此与可见性问题有关。

4.2、悲观锁

悲观锁的预期是悲观的,它默认会有人修改数据,所以它会先加锁,然后修改数据。

5、同步锁状态转换

5.1、无锁

5.1.1、基础信息

无锁状态的锁标记为01,偏向锁标记为0,因此mark word的后三位为001。

5.1.2、流程分析

在关闭偏向锁时,新创建的对象都属于无锁不可偏向状态。

在开启偏向锁时,在启动项目的前4秒内,新建的对象都是无锁不可偏向状态,因为jvm会有4秒的偏向锁开启的延迟时间,在此期间,偏向锁不会开启,4秒钟后创建的对象才是具有偏向锁的对象。

为什么要强调是无锁不可偏向状态,为什么偏向锁会延迟启动,其实原因是一致的,因为刚刚开启JVM时,大量对象生成后一定会产生竞争,添加偏向锁的对象性能而会严重下降,既然竞争一定会发生,不如直接添加轻量级锁。

5.1.3、升级总结

无锁状态可以详细称为无锁不可偏向状态,如果加锁将会直接跳过偏向锁,进入到轻量级锁状态。

匿名偏向锁可以详细称为无锁可偏向状态,因为此时还没有线程抢占到这把锁。

5.2、偏向锁

5.2.1、基础信息

偏向锁的锁标记为01,偏向锁标记为1,因此mark word的后三位为101,在存储hashcode的位置,将会存储ThreadId。

偏向锁属于乐观锁,会在加锁但没有产生竞争的情况下使用,进行一次CAS,在1.6与1.7默认开启的偏向锁在1.8默认关闭。

偏向锁的思想是,记录第一个进入偏向锁的线程ID,当第二个线程进入时,进行线程ID的比较,如果一致就可以继续持有锁,如果不一致,将发生偏向锁的撤销,膨胀或是重偏向等动作。

5.2.2、流程分析

当一个线程进入到synchronized的代码块时,就会从这个线程当前的栈找到一个空闲的BasicObjectLock(Lock Record列表)。这是一个基础的锁对象,在后续的轻量级锁和重量级锁中都会用到。BasicObjectLock中包含两个属性:

  • BasicLock:该属性中有一个字段markOop(对象标记),用于保存指向lock锁对象的对象头数据。对象标记存储对象本身运行时的数据,如哈希码、GC标记、锁信息、线程关联信息等,这部分数据在64位JVM上占用8个字节,称为Mark Word(对象头),详细情况可以查看锁的存储这一节。
  • oop:指向lock锁对象的指针

将BasicObjectLock的oop指针指向当前的锁对象

获得当前锁对象的对象头,通过对象头判断是否可以进行偏向。

对于偏向锁,对象头内的信息无非就是三个,ThreadId,偏向锁标记,锁标记,锁标记固定为01,因此只需要查看ThreadId与偏向锁标记即可。

  • 当偏向锁标记为0,此时为无锁状态,不可偏向,需要轻量级锁来完成抢占
  • 偏向锁标记为1,此时为偏向锁,有线程尝试获取锁时,执行CAS操作

接下来,就需要使用CAS进行判断并比较,此时会发生两种情况

  • 如果一致就只需要在lock record上产生一次记录,增加重入次数。
  • 如果不一致,将执行CAS操作,将mark word中的ThreadId替换为当前线程的ID,添加时间戳epoch

只有两种情况下才能成功替换

  1. 对象的偏向锁为匿名偏向锁
  2. 批量重偏向时,对象处于可重偏向的状态,新线程可以使用CAS将ThreadId替换为自己的ID

当ThreadId为0,偏向锁标记为1,此时为匿名偏向锁。在关闭jvm偏向锁延迟或4秒后创建的对象将会具有偏向锁,但是此时并无线程可以持有这个锁,因此此时的偏向锁为匿名偏向锁匿名偏向锁是偏向锁的初始状态。

批量重偏向放在之后讲解。

如果不符合这两种情况,CAS操作会失败,证明当前线程存在竞争,替换失败后,会执行偏向锁撤销操作。偏向锁撤销需要等到全局安全点,在全局安全点,所有线程都会暂停,此时获得偏向锁的线程会被挂起。

在安全点,所有线程都会被遍历,检查持有偏向锁的线程是否保持存活。

  1. 如果依旧存活,且线程正在执行同步代码块中的代码,则升级成为轻量级锁
  2. 如果已经销毁,或持有偏向锁的线程未执行同步代码块中的代码,则进行校验,判断是否允许重偏向
  3. 如果不允许重偏向,则撤销偏向锁,升级为轻量级锁,继续CAS自旋竞争。
  4. 如果允许重偏向,则将其设置为匿名偏向锁状态,再通过CAS重新指向新的线程

完成操作后,从安全点继续执行代码。

代码执行完成,退出同步块,同步锁释放。此时线程将会逐条删除记录,释放Lock Record,但是需要注意,所谓的偏向锁释放并不是真正的释放,线程ID依然保存在ThreadID之内,只是记录消失,该偏向锁依旧偏向此线程

java多线程进阶(二)同步锁_第4张图片

5.2.3、升级总结

向锁升级为轻量级锁之后,执行完同步代码块,锁被释放,对象状态将变为无锁不可偏向状态,即001,符合锁只要升级膨胀就不能回退的要求。

偏向锁升级为重量级锁也是可能的。

  • 当对象调用wait时,偏向锁将直接升级为重量级锁,因为wait的使用必须释放锁,所谓释放锁就是要先获得锁,这个锁指的就是重量级锁ObjectMonitor,其中包含了wait对应指令内容,这个到重量级锁再行阐述。
  • 当对象调用hashcode()方法,会导致偏向锁回退至无锁不可偏向状态,以后再加锁也都是重量级锁,因为原本mark word中存储ThreadId的位置就是存HashCode的位置,在偏向锁情况下仅能存储一个,只有重量级锁ObjectMonitor才会存储hashcode的数值。

5.2.4、批量重定向

在未禁用偏向锁的情况下,假设一个线程针对大量对象设置了偏向锁,之后其他线程来访问这些对象,在不触发锁竞争的情况下,也需要对这些对象进行偏向锁的撤销和锁升级膨胀,因此虚拟机会认为,此时对象的偏向是有问题的,当偏向锁针对一个线程的撤销发生20次之后,就会触发批量重偏向,将其余被访问对象的偏向锁重新指向新线程,即前19次的偏向锁依旧会发生偏向锁撤销和膨胀为轻量级锁,第20个之后被访问的对象就会直接重偏向至新线程,未被新线程访问的对象偏向锁保持不变。

当此对象的偏向锁继续被撤销,达到40次之后,就会触发批量撤销,JVM认为该class的使用场景存在多线程竞争,将其余具有偏向锁的对象执行偏向锁撤销,同时标记该class为不可偏向,之后再创建此class的对象就直接为无锁不可偏向状态,即直接走轻量级锁的逻辑。

下面是几个重偏向的参数

  • BiasedLockingBulkRebiasThreshold:偏向锁批量重偏向阈值,默认为20次
  • BiasedLockingBulkRevokeThreshold:偏向锁批量撤销阈值,默认为40次
  • BiasedLockingDecayTime:重置计数的延迟时间,默认值为25000毫秒(即25秒)

在JVM中,以class为单位,为每一个class维护了一个偏向锁撤销计数器,当这个对象发生偏向锁撤销时,计数器会进行累加,当超过阈值,就会触发批量重偏向。

一般情况下,class中的epoch与对象的epoch是一致的,当发生批量重偏向时,首先会将class的epoch值+1,接着遍历所有当前活着的的线程的栈,找到该class所有正处于偏向锁状态的锁实例对象,将epoch值修改为新值。此时其他未被线程持有的锁对象epoch会比class的epoch小,在其他线程获取到该锁对象时,会尝试使用CAS执行替换重偏向。

距离上次批量重偏向的25秒内,如果撤销计数达到40,就会发生批量撤销,如果超过25秒,那么就会重置计数。

如果超过25秒没有达到批量撤销累加计数,证明上次的批量重偏向效果显著,将会重置计数器计数,如果超过了,证明这个class不适合偏向锁。

5.3、轻量级锁

5.3.1、基础信息

轻量级锁的锁标记为00,因此mark word的后两位为00。

轻量级锁属于乐观锁,会在共享资源被抢占的时候使用,当线程发现锁已经被抢占,将导致该线程的锁膨胀,成为重量级锁。

5.3.2、流程分析

当一个线程进入到synchronized的代码块时,就会从这个线程当前的栈找到一个空闲的BasicObjectLock(Lock Record列表),在BasicLock中有一个成员属性markOop_displaced_header,这个属性专门用来保存对象的原始对象头Mark Word。

将对象的无锁状态Mark Word保存到_displaced_header中。

检测是否为无锁状态,如果是,通过CAS将对象的Mark Word 替换为指向Lock Record 的指针,如果替换成功,就表示抢占成功。

如果CAS失败,就表示此对象不为无锁状态,需要判断对象指向Lock Record 的指针

  • 不一致,将会触发锁膨胀,升级为重量级锁。
  • 一致,将会进行一次重入

轻量级锁的释放同样使用了CAS操作。

尝试将Lock Record的displaced_header存储的 mark word 替换回对象mark word,此操作使用CAS进行,这时需要检查锁对象的mark word中lock record指针是否指向当前线程的锁记录:

  • 如果替换成功,则表示没有竞争发生,整个同步过程就完成了
  • 如果替换失败,则表示当前锁资源存在竞争,触发锁膨胀,完成锁膨胀之后,调用重量级锁的解锁方式,完成锁的释放

为什么会释放失败?因为轻量级锁在被某个线程占有时,会被其他线程尝试抢占,如果无法获得轻量级锁,就会触发锁膨胀。锁膨胀的逻辑是抢占线程会在判定此时为轻量级锁的情况下,修改锁对象的Mark Word,设置状态为inflating状态。这个操作是通过自旋实现的,多个线程触发膨胀也只会有一个线程修改状态。

因为被修改了状态,所以轻量级锁释放必然会导致失败,对象的锁升级膨胀为重量级锁,其他线程也因为是重量级锁而被阻塞,所以释放时还要唤醒被阻塞的线程。

java多线程进阶(二)同步锁_第5张图片5.3.3、升级总结

轻量级锁的设计集中在Lock Record属性markOop中存储的锁对象Mark Word和锁对象头存储的指向Lock Record的指针,这样设计的目的是因为,轻量级锁一定是有多个线程进行竞争的,锁对象在竞争时可能会发生状态的变化,但是Lock Record中存储的Mark Word肯定不会发生变化,这样通过对比Lock Record与锁对象的Mark Word,就可以判断锁对象是否被其他线程抢占过,如果有,就要在释放轻量级锁的过程中唤醒被阻塞的线程。

5.3.4、重入计数

轻量级锁和偏向锁的重入计数是很类似的,所以在这里详细讲述一下

在线程的Lock Record中储存着锁对象的Mark Lock和指向锁对象的owner,但这是首次分配时保存的。

之后再有的Lock Record中只保存指向锁对象的owner,displaced mark word为null

因此重入计数就由Lock Recod的数量来表示,需要解锁就删除一条,直到只剩下最初的那一条时,进行常规解锁操作。

5.4、重量级锁

5.4.1、基础信息

轻量级锁的锁标记为10,因此mark word的后两位为10。

重量级锁属于悲观锁,非公平锁(允许插队),线程进入阻塞状态。每一个java对象都有一个monitor,每一个线程都有一个监视器Monitor Record,当线程想要获取一个加锁资源时就必须获取到它的monitor,然后将所有权据为己有,直到线程运行完毕才会释放所有权,唤醒被阻塞的线程抢占该资源。准备抢占锁的线程会进入同步队列,没有抢占到资源的线程将进入阻塞状态。

5.4.2、流程分析

获取重量级锁之前,要先进行锁膨胀。锁膨胀需要创建一个对象ObjectMonitor,然后把ObjectMonitor对象的指针保存到锁对象的Wark Word之中。锁膨胀分为四种情况:

  1. 当前已经是重量级锁的状态,不需要继续膨胀,直接从锁对象的Mark Word中获取ObjectMonitor对象的指针返回。
  2. 如果有其他线程正在进行锁膨胀,那么就通过自旋的方式不断重试直到其他线程完成锁膨胀(即创建ObjectMonitor)
  3. 当前有其他线程获取了轻量级锁,那么当前线程会完成锁膨胀
  4. 如果当前是无锁状态,也就是说之前获取到的锁资源刚好把锁释放了,那么当前线程完成锁膨胀

以上过程都是通过自旋完成的,避免了线程竞争导致CAS失败的问题,因此轻量级锁没有自旋,在锁膨胀之后的自旋也是在重量级锁中实现的,只有重量级锁有自旋

 锁膨胀完成后,锁对象的Mark Word会保存指向ObjectMonitor的指针,重量级锁的竞争都在ObjectMonitor中完成,下面介绍一些ObjectMonitor中常见的字段

  • owner:标识拥有该monitor的线程,初始时和锁被释放后都为null
  • cxq (ConnectionList):竞争队列,没有获得锁的线程都会被放入这个队列中
  • EntryList:候选者列表,当owner解锁时会将cxq队列中的线程移动到该队列中
  • OnDeck:在将线程从cxq移动到EntryList时,会指定某个线程为Ready状态(即OnDeck),表明它可以竞争锁,如果竞争成功那么称为owner线程,如果失败则放回EntryList中
  • WaitSet:因为调用wait()或wait(time)方法而被阻塞的线程会被放在该队列中
  • count:monitor的计数器,数值加1表示当前对象的锁被一个线程获取,线程释放monitor对象时减1
  • recursions:线程重入次数

接下来就是重量级锁的获取,很简单

  1. 首先,判断当前线程是否重入,如果是重入,则增加重入次数
  2. 然后,通过自旋完成锁的抢占,通过CAS来判断owner内的线程是否为null,如果为null证明锁已经被释放,可以进行获得到锁。如果不为null证明需要自旋重试,此处的自旋即自适应自旋
  3. 最后,如果自旋失败,当前线程会构建一个ObjectWaiter节点,插入cxq队列的队首,再使用park()方法来阻塞该线程。

重量级锁的释放同样非常简单,当同步代码块执行完毕后,会触发重量级锁的释放

  1. 将ObjectMonitor中持有锁的对象owner置为null
  2. 从EntryList/cxq中唤醒头结点线程,这个线程被称为successor假定继承者
  3. 被唤醒的线程会加入到抢占之中,但是synchronized是非公平锁,能不能抢占到无法保证,如果未能成功抢占,则需要重回cxq之中等待

java多线程进阶(二)同步锁_第6张图片

5.4.3、升级总结

park()方法是用来阻塞竞争队列线程的方法,需要注意的是,park()方法是需要系统调用完成的,用户态无法完成系统调用,因此会发生用户态到内核态之间的切换,重量级锁消耗性能的主要原因就是这个。

6、synchronized的降级

同步锁存在降级吗?存在的。

在全局安全点,执行清理任务的时候会触发尝试降级锁

  1. 恢复锁对象的mark word对象头
  2. 重置ObjectMonitor,然后将该ObjectMonitor放入全局空闲列表,等待后续使用。

7、synchronized的优化

jdk1.6中对锁的实现引入了大量的优化

  • 锁粗化:将紧紧连接在一起的lock指令合成一个
  • 锁消除:清除掉没有竞争资源的锁
  • 增加轻量级锁和自旋
  • 自适应自旋:从原本的10次自旋尝试变成了自适应,即上次等待时间长的将会缩短时间,上次时间短的可以放宽时间。

你可能感兴趣的:(进阶,多线程,java,开发语言)