学习《java并发编程的艺术》Chapter2

Synchronized关键字

Synchronized原理:

    从JVM规范出发,Synchronized关键字的实现原理是通过控制JVM进入和退出Monitor对象来实现方法和代码块的同步。

Java对象头:

    以64位系统为例,Java对象头通常是用16个字节存储的,如果该对象是数组的话,那么对象头会增加8个字节用来存储数组长度。如下图

Java对象头
长度内容说明
8 ByteMark Word存储对象的hashCode和锁信息
8 ByteClass Metadata Address存储对象类型数据的指针
8 ByteArray Length数组长度(如果对象是数组的话)

    Mark Word部分说明:

        当偏向锁标志位为0的时候,01表示无锁,00表示轻量级锁,10表示重量级锁,11表示GC标记;当偏向锁标志位为1且锁的标志位为01的时候,表示偏向锁。

                                                         

  锁的升级

        锁有4种状态,级别由低到高是无锁、偏向锁、轻量级锁、重量级锁,锁的初始状态是无锁,但锁的状态会随着竞争情况逐渐升级,且在锁只能升级不能降级。

        1.偏向锁

            大多数情况下,锁不仅不存在多线程竞争,且总是由同一线程获得。偏向锁的存在是为了降低该情况下线程获得锁的代价。当一个线程获取锁时,会在对象头和栈帧中的锁记录里存储该线程的地址,以后该线程再来获取和释放该锁时,只需要简单的测试一下对象头里的Mark Word存储的线程地址是否是自己本身的,不需要再通过CAS操作来加锁解锁。如果测试成功,则线程获取锁成功;如果测试失败,这里需要则需要细说一下。
            引入一下批量重偏向的概念:如果对象虽然被多个线程访问,但没有竞争,这时偏向了线程 T1 的对象仍有机会重新偏向 T2,重偏向会重置对象的 Thread ID;当撤销偏向锁达到设定阈值后,jvm 会认为自己可能偏向错了,当再给这些对象加锁时重新偏向至t2。

            场景1:假设当前有一个类A和40个对象a(即A a = new A()),且所有对象a的对象头中存储了线程t1的地址,而线程t2来遍历获取前30个对象a的锁。为什么这里要假设这么多对象呢?因为在hotspot JVM中,触发批量重偏向的默认阈值是20。也就是说,当线程t2试图来获取任一对象a的锁时,前19个对象a的情况都是,偏向锁升级为轻量级锁,执行完同步代码块后释放锁,该对象a变为无锁不可偏向状态;从第20个对象a到第30个,触发了批量重偏向机制,这11个对象a的锁都是偏向锁,且他们的对象头中存储的都是线程2的地址;而30个之后,也没有触发批量重偏向机制,对象头中存储的仍是线程1的地址。

            原理:在类A和所有对象a中,他们都各自维护了一个叫做epoch的值(对象初始化时的epoch值等于类A当前的epoch值)。epoch本质是一个时间戳,代表了偏向锁的有效性,我们可以理解为第几代偏向锁。当发生批量重偏向时,将类A的epoch值+1,同时遍历JVM中所有线程栈, 找到类A所有正处于加锁状态的偏向锁实例对象,并将对象的epoch字段改为类A中epoch的新值(这一操作是为了保证当前持有偏向锁并且已加锁的线程不至于因此丢锁,Java 虚拟机需要遍历所有线程的 Java 栈,找出该类已加锁的实例,并且将它们标记字段中的 epoch 值加 1。该操作需要所有线程处于安全点状态。)。所以在场景1中,线程t2访问第21个对象a的锁时,由于第21个对象并未加锁,只是无锁可偏向t1的状态,故该对象的epoch值并没有被JVM修改,也就是说,该对象的epoch值比类A的epoch值小1。所以实际上,当线程t2来获取锁时,执行的操作应该是:1.检查对象头存储的线程地址是否是本身;2.如果不是的话检查类A的epoch值是否和该对象的epoch值相等,如果不相等的话,则不需要撤销当前对象的锁,而是直接将当前对象的偏向锁指向线程t2的地址。

            再引入一下批量撤销的概念:当一个偏向锁撤销次数到达设定阈值的时候,JVM就会认为这个对象存在多线程竞争,不适合使用偏向锁,于是JVM会把这个对象所对应的类所有的对象都撤销偏向锁,并且新实例化的对象也是不可偏向的。

            场景2:假设当前有一个类A和40个对象a(即A a = new A()),且所有对象a的对象头中存储了线程t1的地址,而线程t2来遍历获取所有对象a的锁,遍历完成后休眠,线程t3再来遍历获取前20个对象a的锁。在hotspot JVM中,触发批量撤销的默认阈值是40。也就是说,在线程t2执行后,前19个对象a都变为无锁不可偏向状态;从第20个对象a到第40个,触发了批量重偏向机制,这些对象a的锁都是偏向线程t2的偏向锁;而线程t3试图去获取循环中最后一个对象a的锁时,由于线程t2前20次操作都是撤销,再加上t3的20次撤销,就刚好达到了触发批量撤销的阈值,该对象a的锁膨胀为轻量锁,而且之后类A所有刚new出来的对象实例都是无锁不可偏向的状态。

            原理:以类为单位,例如类A,JVM为其维护了一个偏向锁撤销计数器,每执行一次偏向锁撤销操作时,该计数器+1,当达到第一个阈值,也就是批量重偏向阈值时,JVM会对该类执行批量重偏向操作;当达到重偏向阈值后,假设该class计数器继续增长,当其达到批量撤销的阈值后,JVM会对该类执行批量撤销操作。

        2.轻量级锁(自旋锁)

            加锁:线程在获取锁执行同步代码块前,JVM会在当前线程的栈帧中创建一块内存区域,将对象头的Mark Word复制到锁记录中,然后线程尝试用CAS将对象头中的Mark Word替换为指向锁记录的指针。如果替换成功,表明当前线程获取锁成功,栈帧中的锁记录修改为00表示轻量级锁,对象头中的Mark Word也修改为指向锁记录的指针;如果替换失败,则说明其它线程正占有该锁,当前线程将会不断自旋的去尝试获取锁。如果自旋一定次数后仍未获得锁,轻量级锁将会膨胀升级为重量级锁;如果锁对象处于加锁状态,并且锁对象的 Mark Word 指向当前线程的栈帧范围内,说明当前线程已经持有该轻量级锁,再次获取到该锁,也就是锁重入,每次线程获取轻量级锁时都会创建一个 锁记录,锁重入时会创建多个指向同一个对象的锁记录,但只有第一个锁记录会复制Mark Word ,后面均设置为 null。

            解锁:解锁就是使用 CAS 操作把当前线程的栈帧中锁记录存储的Mark Word 替换回锁对象中去,如果替换成功,则解锁成功;如果替换失败,轻量级锁膨胀成重量级锁后再解锁。

        3.重量级锁(互斥锁)

            加锁:每个对象的重量级锁维护了三个队列:entry_list、cxq_list、wait_set,他们存放的都是线程包装成的对象。entry_list是等待被唤醒的队列,当某一线程释放了对象的重量级锁,该线程会执行C++层面的exit方法,并唤醒该队列的头节点;cxq_list中存放的是获取锁失败的线程,竞争失败的线程都会进入睡眠状态,被放入该队列中,当执行exit方法后发现entry_list是空的,则会把cxq_list队列中的节点放到entry_list中,放入顺序默认是把cxq_list直接插到entry_list后面;wait_set是Java中调用了wait()方法的线程所存放的队列,只有调用了notify()或notifyAll()方法后,wait_set队列中的节点才能放到cxq_list队列中,放入顺序默认是把 cxq_list 插入到 wait_set 后面。也就是说,如果线程t1获取到锁后调用wait()方法解锁并进入wait_set队列,线程t2获取到锁并暂不释放,线程t3获取锁失败进入cxq_list队列,在线程t2释放锁之前调用了notify()方法,则线程t1必定优先于线程t3唤醒并获取到锁。

    原子操作

        原子操作指的是不可被中断的一个或一系列操作。

        在处理器层面,是通过总线锁和缓存锁两种方式来实现的。

        在Java层面,是通过CAS自旋和锁两种方式来实现的。

        CAS实现原子操作的最著名的问题就是ABA问题。假设int i = 0,线程t1读入栈帧的i的值就是0,线程t2将其修改为1,线程t3又将i修改回0,那么线程t1在使用CAS检查时会发现i的值没有发生变化。解决办法就是在变量前面追加版本号,每次更新版本号+1,也可以加上时间戳。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值