[JVM]-[深入理解Java虚拟机学习笔记]-第十三章 线程安全与锁优化

[JVM]-[深入理解Java虚拟机学习笔记]-第十三章 线程安全与锁优化,第1张

线程安全

以下定义来自《Java并发编程实战》:当多个线程同时访问一个对象,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调 *** 作,调用这个对象的行为都可以获得正确的结果,那就称这个对象是线程安全的

Java中的线程安全

对应着线程安全的“安全程度”由强到弱,Java中各种 *** 作共享的数据可以分为五类:不可变,绝对线程安全,相对线程安全,线程兼容和线程对立

  • 不可变
  • 绝对线程安全
  • 相对线程安全
  • 线程兼容
  • 线程对立
线程安全的实现 互斥同步

互斥同步是一种最常见,最主要的并发正确性保障手段
同步是指在多个线程并发贩卖访问共享数据时,保证共享数据在同一个时刻只被一条 (或者是一些,当使用信号量的时候) 线程使用
而互斥是实现同步的一种手段,临界区,互斥量,信号量都是常见的互斥实现方式
互斥是方法,同步是目的

synchronized关键字
  1. 这是一种块结构的同步语法
  2. 经过Javac编译之后,会在同步块的前后分别形成 monitorenter 和 monitorexit 这两个字节码指令。这两个字节码指令都需要一个 reference 引用类型参数来指明需要锁定和解锁的对象。如果Java源码中明确指定了对象参数 (即同步代码块的形式) ,那就以这个对象的引用作为 reference;再者,如果synchronized修饰的是实例方法,那就取代码所在的对象实例作为线程要持有的锁;如果修饰的是类方法,那就取代码所在的类的 Class对象 作为线程要持有的锁
  3. 在执行 monitorenter 指令时,首先要尝试获取对象的锁。如果这个对象没被锁定,或者当前线程已经持有了那个对象的锁,就把锁的计数器的值增加一,而在执行 monitorexit 指令时会将锁计数器的值减一。一旦计数器的值为零,锁随即就被释放了。如果获取对象锁失败,那当前线程就应当被阻塞等待,直到请求锁定的对象被持有它的线程释放为止
  4. 从上面的描述可以得到:被 synchronized 修饰的同步块对同一条线程来说是可重入的,即同一线程反复进入同步块也不会出现自己把自己锁死的情况 (如果持有锁的线程再次获得它,则将计数器的值加一,每次释放锁时计数器的值减一,当计数器的值为零时才能真正释放锁) ;
    被 synchronized 修饰的同步块在持有锁的线程执行完毕并释放锁之前,会无条件阻塞后面其它线程的进入,这意味着无法像处理某些数据库中的锁那样,强制已获取锁的线程释放锁,也无法强制正在等待锁的线程中断等待或超时退出
ReentrantLock

JDK 5开始提供了JUC包,其中的 java.util.concurrent.locks.Lock接口 能够以非块结构实现互斥同步,在API类库层面实现同步。其中 ReentrantLock 为最常见的一种实现,顾名思义它也是可重入的。与 synchronized 相比主要增加了以下三项功能:

  1. 等待可中断:指当持有锁的线程长期不释放锁的时候,正在等待的线程可以选择放弃等待,改为处理其他事情。可中断特性对处理执行时间非常长的同步块很有帮助
  2. 公平锁:是指多个线程在等待同一个锁时,必须按照申请锁的时间顺序来依次获得锁;而非公平锁则不保证这一点,在锁被释放的时候,任何一个等待锁的线程都有机会获得锁。synchronized 的锁就是非公平的,而 ReentrantLock 在默认情况下也是非公平的,可以通过带布尔值的构造方法来设置使用公平锁,不过一旦使用了公平锁,将会导致 ReentrantLock 的性能急剧下降,明显影响吞吐量
  3. 锁绑定多个条件:指一个 ReentrantLock 对象可以同时绑定多个 Condition 对象,在 synchronized 中,锁对象的 wait() 跟它的 notify() 或者 notifyAll() 方法配合可以实现一个隐含的条件,如果要和多于一个的条件关联的时候,就不得不额外添加一个锁;而 ReentrantLock 通过多次调用 newCondition() 方法,就可以绑定多个 Condition。这里的 Condition 对象以及 wait() ,notify() 等方法是用于实现等待/通知机制的,大概就是一个线程中完成了某些事情之后,通知其它在等待的线程继续执行
两种方式的选择

在 JDK 6 引入锁优化以前,多线程环境下 synchronized 的吞吐量下降严重,而 ReentrantLock 则能基本保持在一个稳定的水平;在引入锁优化后,synchronized 与 ReentrantLock 的性能就基本持平了
基于以下理由,在 synchronized 跟 ReentrantLock 都满足需要时优先使用 synchronized:

  1. synchronized 是Java语法层面的同步,清晰,简单,使用方便;而 ReentrantLock 相比则较为灵活,使用要求则更大
  2. Lock 应该确保在 finally 块中释放锁,否则一旦受同步保护的代码块中抛出异常,则有可能永远不会释放锁,而这一点就必须由程序员自己保证;而使用 synchronized 的话就可以由 JVM 来确保即使出现异常,锁也能被自动释放
  3. 从长远来看,Java虚拟机更容易针对 synchronized 进行优化,因为 JVM 可以在线程和对象的元数据中记录 synchronized 中锁的相关信息,而使用 JUC 中的 Lock 的话,是很难得知具体哪些锁对象是由特定线程锁持有的
非阻塞同步

互斥同步的主要问题是进行线程阻塞和唤醒所带来的性能开销,因此这种同步也被称为阻塞同步
从解决问题的方式上看,互斥同步属于一种悲观的并发策略,即总是认为只要不去做正确的同步措施 (例如加锁) ,那就肯定会出现问题,无论共享的数据是否真的会出现竞争,他都会进行加锁,这将会导致用户态到核心态转换维护锁计数器检查是否有被阻塞的线程需要被唤醒等开销
随着硬件指令集的发展,有了另外一个选择:基于冲突检测的乐观并发策略,不管风险先进行 *** 作,如果没有其他线程争用共享数据,那 *** 作就直接成功了;如果共享的数据的确被争用,产生了冲突,那再进行其它的补偿措施,最常用的补偿措施是不断地重试,直到出现没有竞争的共享数据为止。这种乐观并发策略的实现不再需要把线程阻塞起来,因此这种同步锁被称为非阻塞同步,使用这种措施的代码也常称为无锁 ( L o c k − F r e e Lock-Free LockFree) 编程
为什么乐观并发策略需要硬件指令集的发展呢?因为我们必须要求 *** 作和冲突检测这两个步骤具备原子性 (如果冲突检测跟 *** 作不是原子的,有可能冲突检测完,发现没有冲突,然后就进行 *** 作,而在 *** 作还没完成的时候,就出现冲突了,这样还是不符合同步) ,怎么具备原子性?如果再使用互斥同步就完全失去乐观策略的意义了,所以只能依靠硬件来实现这件事情,这类指令常用的有:
测试并设置 (Test-and-Set),获取并增加 (Fetch-and-Increment),交换 (Swap),比较并交换 (Compare-and-Swap,CAS),加载链接/条件储存 (Load-Linked/Store-Conditional,LL/SC)

CAS

CAS指令需要三个 *** 作数,分别是内存位置,旧的预期值,和准备设置的新值。指令执行时,当且仅当内存位置中的值符合旧的预期值时,处理器才会用新值来更新内存位置中的值。且不管是否更新了内存位置的值,都会返回内存位置上的旧值。这是一个原子 *** 作
例如JUC中的整数原子类就使用了CAS。CAS存在以下问题 :

  1. ABA问题。解决方法就是使用版本号,给变量加上版本号信息,每次变量更新就把版本号加一,那么 A->B->A 就变成 1A->2B->3A ,从而就算值相等也能判断出修改过。从 JDK 1.5 开始 JDK 的 Atomic 包提供了一个 AtomicStampedReference 类来解决ABA问题,这个类的 compareAndSet 方法除了会检查引用是否相等,还会检查当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用以及该标志的值设置为给定的更新值
  2. 循环时间长开销大。自旋CAS如果长时间不成功,会给CPU带来非常大的执行开销
  3. 只能保证一个共享变量的原子 *** 作。JDK 1.5开始提供了 AtomicReference 类来保证引用对象之间的原子性,就可以把多个变量放到一个对象里来进行CAS,保证多个变量的原子 *** 作
锁优化

JDK 6中实现了各种锁优化技术,如适应性自旋,锁消除,锁膨胀/锁粗化,轻量级锁,偏向锁等

自旋锁与自适应自旋

许多应用中,共享数据的锁定状态只会持续很短的一段时间,为了这段时间去挂起和恢复线程并不值得。如果机器能让两个或以上的线程同时并行执行,我们就可以让后面请求锁的那个线程“稍等一会”,但不放弃处理器的执行时间,看看持有锁的线程是否很快就会释放锁。为了让线程等待,我们只须让线程执行一个忙循环 (自旋),这项技术就是所谓的自旋锁
自旋锁在JDK 1.4.2中就已经引入,只不过是默认关闭的,可以使用-XX:+UseSpinning参数来开启,在JDK 6中就已经改为默认开启了。自选等待并不能代替阻塞,且先不说对处理器数量的要求,自选等待本身虽然避免了线程切换的开销,但它是要占用处理器时间的,所以如果锁被占用的时间很短,自旋等待的效果就会非常好;反之如果锁被占用的时间很长,那么自旋的线程只会白白消耗处理器资源,而不会做任何有价值的工作,这就会带来性能的浪费
因此自选等待的时间必须有一定的限度,如果自选超过了限定的次数仍然没有成功获得锁,就应当使用传统的方式去挂起线程。自选次数默认值为10次,也可以使用参数-XX:PreBlockSpin来修改
不过无论是默认值还是用户自定义自选次数,对整个JVM中所有的锁来说都是相同的。JDK 6中对自旋锁的优化加入了自适应的自旋。自适应意味着自旋的时间不再是固定的了,而是由前一次在同一个锁上的自选时间及锁的拥有者的状态来决定的
例如,如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也很有可能再次成功,进而允许自选等待持续相对更长的时间;如果对于某个锁,自旋很少成功获得锁,那在以后要获取这个锁时将有可能直接忽略掉自选过程,以避免浪费处理器资源
有了自适应自旋,随着程序运行时间增长以及性能监控信息的不断完善,虚拟机对程序锁的状况预测就会越来越精准

锁消除

锁消除是指对一些代码要求同步,但是对被检测到不可能存在共享数据竞争的锁进行消除。锁消除的主要判定依据来源于逃逸分析的数据支持,如果判断到一段代码中,在堆上的所有数据都不会逃逸出去被其他线程访问到,那就可以把它们当作栈上数据对待,认为它们是线程私有的,同步加锁自然就无须再进行
程序员应该是能知道代码是否存在数据争用情况的,所以自然不会在明知道不存在数据争用的情况下还进行同步 *** 作。很多时候同步措施不是程序员自己加入的。例如字符串拼接 *** 作,在JDK 5后会转化为 StringBuffer 对象的连续 append() *** 作,而 StringBuffer::append() 方法中都有一个同步块,锁就是实例对象。如果某个字符串拼接 *** 作只发生在单个方法内,虚拟机观察 StringBuffer 实例对象,经过逃逸分析发现它的动态作用域被限制在单个方法内,也就是这个实例对象的所有引用都永远不会逃逸到方法外,其他线程无法访问到它,那就会将这里的锁安全地消除掉

锁粗化

原则上,我们编写代码的时候,总是推荐将同步块的作用范围限制得尽量小,只在共享数据的实际作用域中才进行同步,这样是为了使得需要同步的 *** 作数量尽可能变少,即使存在锁竞争,等待锁的线程也能尽可能快地拿到锁
但如果一系列连续的 *** 作都对同一个对象反复加锁和解锁,甚至加锁 *** 作出现在循环体之中的,那即使没有线程竞争,频繁地进行互斥同步 *** 作也会导致不必要的性能损耗
例如前面 锁消除 部分讲到的字符串拼接 *** 作会转化为一个 StringBuffer 对象的连续 append() 调用,每一次 append() 都会对同一个对象进行加锁。如果虚拟机探测到有这样一串零碎的 *** 作都对同一个对象加锁,将会把加锁同步的范围扩展 (粗化) 到整个连续 *** 作序列的外部,只需要加锁一次就可以了

轻量级锁

轻量级锁是 JDK 6 加入的新型锁机制,它名字中的 “轻量级” 是相对于使用 *** 作系统互斥量来实现的传统锁而言的,因此传统的锁机制就被称为 “重量级” 锁 。不过轻量级锁并不是来替代重量级锁的,他设计的初衷是在没有多线程竞争的前提下,减少传统的重量级锁使用 *** 作系统互斥量产生的性能消耗 (传统的加锁使用 *** 作系统互斥量,涉及到用户态核心态切换,维护锁计数器等 *** 作开销;有的时候,同步是必须要的,但多线程竞争的发生却不是经常性的,如果多线程竞争的发生没有那么多,但还是每次都要进行那样的加锁 *** 作,就显得些许浪费,消耗了性能)

加锁

轻量级锁的工作流程如下:在代码即将进入同步块的时候,如果此同步对象没有被锁定 (锁标志位为 “01” 状态) ,虚拟机首先将在当前线程的栈帧中建立一个名为锁记录 (Lock Record) 的空间,用于存储锁对象目前的 Mark Word 的拷贝 (称为Displaced Mark Word),这时线程堆栈与对象头的状态如下所示:

然后,虚拟机将使用CAS *** 作尝试把对象的 Mark Word 更新为指向 Lock Record 的指针。如果这个更新 *** 作成功了,即代表该线程拥有了这个对象的锁,并且对象 Mark Word 的锁标志位转变为 “00” ,表示此对象处于轻量级锁,这时候线程堆栈与对象头的状态如下:

如果这个更新 *** 作失败了,那就意味着至少存在一条线程与当前线程竞争获取该对象的锁。虚拟机首先会检查对象的 Mark Word 是否指向当前线程的栈帧,如果是,说明当前线程已经拥有了这个对象的锁,直接进入同步块执行代码就可以了;否则就说明这个锁对象已经被其它线程抢占了。而如果出现两条以上的线程争用同一个锁的情况,轻量级锁就不再有效了,必须膨胀为重量级锁,锁标志也变为 “10” ,此时 Mark Word 中存储的就是指向重量级锁 (互斥量) 的指针,后面等待锁的线程也必须进入阻塞状态

解锁

轻量级锁的解锁过程也同样是通过CAS *** 作来进行的,如果对象的 Mark Word 仍然指向线程的锁记录,那就用CAS *** 作把对象当前的 Mark Word 和线程中复制的 Displaced Mark Word 替换回来。假如能够替换成功,那整个同步过程就顺利完成了;如果替换失败,则说明有其它线程尝试过获取该锁,就要在释放锁的同时,唤醒被挂起的线程,如果替换失败,说明有其它线程尝试过获取该锁,导致锁升级为了重量级锁,那么此时的锁就已经是重量级锁了,Mark Word 中存储的就是指向重量级锁 (互斥量) 的指针而不是线程的锁记录,所以CAS就失败了

总结

轻量级锁能提升程序同步性能的依据是 “对于绝大部分的锁,在整个同步周期内都是不存在竞争的” 这一经验法则,如果没有竞争,轻量级锁便通过CAS *** 作成功避免了使用互斥量的开销;但如果确实存在锁竞争,轻量级锁也肯定会变为重量级锁 (相当于本来就应该直接加重量级锁,但非要在前面先加一次轻量级锁) ,那么除了互斥量的本身开销外,还额外发生了CAS *** 作的开销,所以在有竞争的情况下,轻量级锁反而会比传统的重量级锁更慢

偏向锁

偏向锁也是JDK 6引入的,它的目的是消除数据在无竞争情况下的同步原语,进一步提高程序的运行性能。与轻量级锁相比,如果说轻量级锁是在无竞争的情况下使用CAS *** 作去消除同步使用的互斥量,那偏向锁就是在无竞争的情况下把整个同步都消除掉,连CAS都不去做了
偏向锁中的 “偏”,就是偏心,偏袒的意思。意思是这个锁会偏向于第一个获得他的线程,如果在接下来的执行过程中,该锁一直没有被其它的线程获取,则持有偏向锁的将永远不需要再进行同步

工作流程

假设当前虚拟机启用了偏向锁 (启用参数-XX:+UseBiased Locking,这是JDK 6后的默认值),那么当锁对象第一次被线程获取的时候,虚拟机将会把对象头中的标志位设置为 “01”,把偏向模式设为 “1”,表示进入偏向模式。同时使用CAS *** 作把获取到这个锁的线程的ID记录在对象的 Mark Word 之中。如果CAS *** 作成功,持有偏向锁的线程以后每次进入这个锁相关的同步块时,虚拟机都可以不再进行任何同步 *** 作 (例如加锁,解锁,及对 Mark Word 的更新 *** 作等)
一旦出现另外一个线程去尝试获取这个锁的情况,偏向模式就马上宣告结束,偏向模式位设为0。撤销后根据对象当前是否被锁定,标志位恢复到未锁定 (标志位为"01") 或轻量级锁 (标志位为“00”) 的状态,后续的同步 *** 作就按照上面说的轻量级锁那样去执行

从上图可以看到,当对象进入偏向状态的时候,Mark Word 大部分空间 (23bit) 都用于存储持有锁的线程ID了 (就算该线程释放了锁 Mark Word 中还是会存这个ID,因为已经偏向于这个线程了,所以要一直维护着这个ID,直到偏向模式撤销),那原来的对象哈希码怎么办?
作为绝大多数对象哈希码来源的 Object::hashCode() 方法,返回的是对象的一致性哈希码 (Identity Hash Code),这个值是能强制保证不变的,它通过在对象头中存储计算结果,来保证第一次计算之后,再次调用该方法取到的哈希码值永远不会再发生改变。因此,当一个对象已经计算过一致性哈希码后,它就再也无法进入偏向锁状态了;而当一个对象当前正处于偏向锁状态,又收到 (第一次) 需要计算其一致性哈希码的请求时 (指调用 Object::hashCode() 或者 System::identityHashCode(Object) 方法,如果重写了对象的 hashCode() 方法,那计算哈希码时不会产生这种请求),它的偏向状态会被立即撤销,并且锁会膨胀为重量级锁,在重量级锁的实现中,对象头指向了重量级锁的位置,代表重量级锁的 ObjectMonitor 类里有字段可以记录非加锁状态下的 Mark Word,其中自然就可以存储原来的哈希码

总结

偏向锁可以提高带有同步但无竞争的程序性能,但它同样是一个带有效益权衡 (Trade Off) 性质的优化,它并非总是对程序运行有利。;如果程序中大多数的锁都总是被多个不同的线程访问,那偏向模式就是多余的。有时候用参数-XX:-UseBiasedLocking来禁止偏向锁优化反而可以提升性能

其它章节

草稿ing…

欢迎分享,转载请注明来源:内存溢出

原文地址: http://outofmemory.cn/langs/719920.html

(0)
打赏 微信扫一扫 微信扫一扫 支付宝扫一扫 支付宝扫一扫
上一篇 2022-04-25
下一篇 2022-04-25

发表评论

登录后才能评论

评论列表(0条)

保存