一晚上搞懂 | JVM 线程安全与锁优化

前言

本文已经收录到个人 Github 我的博客,欢迎大佬们光临寒舍:html

个人 GIthub 博客git

学习导图

学习导图

一.为何要学习内存模型与线程?

以前咱们学习了内存模型和线程,了解了 JMM 和线程,初步探究了 JVM 怎么实现并发,而本篇文章,咱们的关注点是 JVM 如何实现高效程序员

并发编程的目的是为了让程序运行得更快,提升程序的响应速度,虽然咱们但愿经过多线程执行任务让程序运行得更快,可是同时也会面临很是多的挑战,好比像线程安全问题、线程上下文切换的问题、硬件和软件资源限制等问题,这些都是并发编程给咱们带来的难题。github

其中线程安全问题是咱们最关心的问题之一,咱们接下来主要就围绕着线程安全的问题来展开。编程

二.核心知识点概括

2.1 线程安全

2.1.1 定义

当多个线程访问一个对象时,若是不用考虑这些线程在运行时环境下的调度和交替执行,也不须要进行额外的同步,或者在调用方进行任何其余的协调操做,调用这个对象的行为均可以得到正确的结果,那这个对象是线程安全的数组

要求线程安全的代码都必须具有一个特征: 代码自己封装了全部必要的正确性保障手段(如互斥同步等),令调用者无须关心多线程的问题,更无须本身采起任何措施来保证多线程的正确调用。安全

2.1.2 分类

下面将按照线程安全的程度由强至弱分红五类多线程

  • 不可变:外部的可见状态永远不会改变,在多个线程之中永远是一致的状态
  • 必定是线程安全的并发

  • 如何实现函数

    1.若是共享数据是一个基本数据类型,只要在定义时用 final 关键字修饰

    2.若是共享数据是一个对象,最简单的方法是把对象中带有状态的变量都声明为 final(例如 String 类的实现)

  • 绝对线程安全:彻底知足以前给出的线程安全的定义,即达到『无论运行时环境如何,调用者都不须要任何额外的同步措施』
  • 相对线程安全:能保证对该对象单独的操做是线程安全的,在调用时无需作额外保障措施,但对于一些特定顺序的连续调用,可能须要在调用端使用额外的同步措施来保证调用的正确性
  • 一般意义上所讲的线程安全
  • 大部分的线程安全类都属于这种类型,如 VectorHashTableCollections#synchronizedCollection() 包装的集合等
  • 线程兼容:对象自己非线程安全的,但能够经过在调用端正确地使用同步手段来保证对象在并发环境中能够安全地使用
  • 是一般意义上所讲的非线程安全
  • Java API 中大部分类都是属于线程兼容的,如 ArrayListHashMap
  • 线程对立:不管调用端是否采起了同步措施,都没法在多线程环境中并发使用的代码

例子:Thread 类的 suspend()resume() ,一个尝试中断线程,一个尝试恢复线程,在并发条件下,有可能会形成死锁

2.1.3 实现

可分红两大手段:

  • 经过代码编写实现线程安全
  • 经过虚拟机自己实现同步与锁

本篇重点在虚拟机自己

1.互斥同步
  • 含义:
  • 同步:在多个线程并发访问共享数据时,保证共享数据在同一个时刻只被一个线程使用

  • 互斥:是实现同步的一种手段,临界区、互斥量和信号量都是主要的互斥实现方式

  • 二者关系:互斥是因,同步是果;互斥是方法,同步是目的

  • 属于悲观并发策略悲观锁),即认为只要不作正确的同步措施就确定会出现问题,所以不管共享数据是否真的会出现竞争,都要加锁

  • 最大的问题是进行线程阻塞和唤醒所带来的性能问题,也称为阻塞同步

  • 使用方式:

    A.使用 synchronized 关键字:

  • 原理:编译后会在同步块的先后分别造成 monitorentermonitorexit 这两个字节码指令,并经过一个 reference 类型的参数来指明要锁定和解锁的对象

    注意:

    ​ 1.若明确指定了对象参数,则取该对象的 reference

    ​ 2.不然,会根据 synchronized 修饰的是实例方法仍是类方法去取对应的对象实例或 Class 对象来做为锁对象

    synchronized 处理逻辑

  • 过程:执行 monitorenter 指令时先要尝试获取对象的锁。若该对象没被锁定或者已被当前线程获取,那么锁计数器 + 1;而在执行 monitorexit 指令时,锁计数器 - 1;当锁计数器 = 0 时,锁就被释放;若获取对象锁失败,那当前线程会一直被阻塞等待,直到对象锁被另一个线程释放为止

  • 特别注意

    1.synchronized 同步块对同一条线程来讲是可重入的,不会出现自我锁死的问题

    2.同步块在已进入的线程执行完以前,会阻塞后面其余线程的进入

​ B.使用重入锁 ReentrantLock

​ 以前在 进阶之路 | 奇妙的 Thread 之旅中也提到太重入锁的使用,相信看过的读者还有一些印象

  • synchronized 的相同:用法与 synchronized 很类似,且均可重入

  • synchronized不一样

    1.等待可中断:当持有锁的线程长期不释放锁的时候,正在等待的线程能够选择放弃等待,改成处理其余事情

    2.公平锁:多个线程在等待同一个锁时,必须按照申请锁的时间顺序来依次得到锁。而 synchronized 是非公平的,即在锁被释放时,任何一个等待锁的线程都有机会得到锁。ReentrantLock 默认状况下也是非公平的,但能够经过带布尔值的构造函数改用公平锁

    3.锁绑定多个条件:一个 ReentrantLock 对象能够经过屡次调用 newCondition() 同时绑定多个 Condition 对象。而在 synchronized 中,锁对象的 wait()notify()notifyAl() 只能实现一个隐含的条件,若要和多于一个的条件关联不得不额外地添加一个锁

  • 选择:在 synchronized 能实现需求的状况下,优先考虑使用它来进行同步。理由以下:
  • synchronizedJava语法层面的同步,足够清晰简单
  • Lock 必须由程序员确保在 finally 块中释放锁,而 synchronized 能够由 JVM 确保锁的自动释放
2.非阻塞同步
  • 定义:基于冲突检测的乐观并发策略(乐观锁),即先进行操做,若无其余线程争用共享数据,操做成功;反之产生了冲突再去采起其余的补偿措施
  • 为了保证操做冲突检测这两步具有原子性,须要用到硬件指令集,好比:
  • 测试并设置
  • 获取并增长
  • 交换
  • 比较并交换CAS
  • 加载连接 / 条件存储
3.无同步方案
  • 定义:不用同步的方式保证线程安全,由于有些代码天生就是线程安全的。
  • 例子:

A.可重入代码/ 纯代码

  • 含义:可在代码执行的任什么时候刻中断它去执行另一段代码,当控制权返回后原来的程序并不会出现任何错误
  • 共同特征:不依赖存储在堆上的数据和公用的系统资源、用到的状态量都由参数中传入、不调用非可重入的方法
  • 断定依据:若是一个方法,它的返回结果是可预测的,只要输入相同的数据就都能返回相同的结果,就知足可重入性
  • 注意:知足可重入性的代码必定是线程安全的,反之,知足线程安全的代码不必定是可重入的

B.线程本地存储

  • 含义:把共享数据的可见范围限制在同一个线程以内,无须同步就能保证线程之间不出现数据争用的问题
  • 想详细了解 ThreadLocal 的读者,能够看下笔者以前写的一篇文章:进阶之路 | 奇妙的 Handler 之旅

2.2 锁优化

一图带你看遍锁

解决并发的正确性以后,为了能在线程之间更『高效』地共享数据、解决竞争问题、提升程序的执行效率,下面介绍五种锁优化技术

2.2.1 适应性自旋

  • 背景:互斥同步在实现阻塞和唤醒时须要挂起线程和恢复线程的操做,都须要转入内核态中完成,很影响系统的并发性能;同时,在许多应用上共享数据的锁定状态只是暂时,不必去挂起和恢复线程
  • 自旋锁:当物理机器有多个处理器使得多个线程同时并行执行时,先让后请求锁的线程等待,但不放弃处理器的执行时间,看看持有锁的线程是否很快就会释放锁,这时只需让线程执行一个忙循环,即自旋

注意:自旋等待不能代替阻塞,它虽然能避免线程切换的开销,但会占用处理器时间,所以自旋等待的时间必需要有必定的限度,若是自旋超过了限定的次数(默认10次)仍未成功获锁,就须要挂线程了

  • 自适应自旋锁:自旋的时间再也不固定,而是由该锁上的上次自旋时间及锁的拥有者的状态共同决定。具体表现是:
  • 若是对于某个锁,自旋等待刚刚成功得到,且持有锁的线程正在运行中,那么虚拟机极可能容许自旋等待的时间更久点
  • 若是对于某个锁,自旋不多成功得到过,那么极可能之后将省略自旋等待这个锁,避免浪费处理器资源

2.2.2 锁消除

  • 定义:指虚拟机即时编译器在运行时,对一些代码上要求同步,可是被检测到不可能存在共享数据竞争的锁进行消除
  • 断定依据:若是一段代码中上的全部数据都不会逃逸出去被其余线程访问到,可把它们当作上数据对待,即线程私有的,无须同步加锁

2.2.3 锁粗化

  • 通常状况下,会将同步块的做用范围限制到只在共享数据的实际做用域中才进行同步,使得须要同步的操做数量尽量变小,保证就算存在锁竞争,等待锁的线程也能尽快拿到锁
  • 但若是反复操做对同一个对象进行加锁和解锁,即便没有线程竞争,频繁地进行互斥同步操做也会致使没必要要的性能损耗,此时,虚拟机将会把加锁同步的范围粗化到整个操做序列的外部,这样只需加一次锁

2.2.4 轻量级锁

  • 目的:在没有多线程竞争的前提下减小传统的重量级锁使用操做系统互斥量产生的性能消耗,注意不是用来代替重量级锁的

首先先理解 HotSpot 虚拟机的对象头的内存布局:分为两部分

  • 第一部分用于存储对象自身的运行时数据,这部分被称为 Mark Word,是实现轻量级锁和偏向锁的关键。如哈希码、GC 分代年龄等
  • 另一部分用于存储指向方法区对象类型数据的指针,若是是数组对象还会有一个额外的部分用于存储数组长度

Mark Word 结构

  • 加锁过程:

    1.代码进入同步块时,若是同步对象未被锁定(锁标志位为 01),虚拟机会在当前线程的栈帧中创建一个名为 Lock Record 的空间,用于存储锁对象 Mark Word 的拷贝。以下图

2.以后虚拟机会尝试用 CAS 操做将对象的 Mark Word 更新为指向 Lock Record 的指针。若更新动做成功,那么当前线程就拥有了该对象的锁,且对象 Mark Word 的锁标志位变为 00,即处于轻量级锁定状态;反之,虚拟机会先检查对象的 Mark Word 是否指向当前线程的栈帧,如果,则当前线程已有该对象的锁,可直接进入同步块继续执行,不然说明改对象已被其余线程抢占。以下图:

CAS后堆栈与对象的状态

另外,若是有两条以上的线程争用同一个锁,那轻量级锁就再也不有效,要膨胀为重量级锁,锁标志位变为 10Mark Word 中存储的就是指向重量级锁的指针,后面等待锁的线程也要进入阻塞状态

加锁流程

  • 解锁过程:若对象的 Mark Word 仍指向着线程的 Lock Record,就用 CAS 操做把对象当前的 Mark Word 和线程中复制的 Displaced Mark Word 替换回来。若替换成功,那么就完成了整个同步过程;反之,说明有其余线程尝试获取该锁,那么就要在释放锁的同时唤醒被挂起的线程

    解锁过程

  • 优势:由于对于绝大部分的锁,在整个同步周期内都是不存在竞争的,因此轻量级锁经过使用 CAS 操做消除同步使用的互斥量

  • 自旋锁和轻量级锁的关系:

  • 自旋锁是为了减小线程挂起次数
  • 轻量级锁是在加锁的时候,如何使用一种更高效的方式来加锁

Q:处于轻量级锁状态时,会不会使用自旋锁这个竞争机制

A:线程首先会经过 CAS 获取锁,失败后经过自旋锁来尝试获取锁,再失败锁就膨胀为重量级锁。因此轻量级锁状态下可能会有自旋锁的参与(CAS 将对象头的标记指向锁记录指针失败的时候)

2.2.5 偏向锁

  • 目的:消除数据在无竞争状况下的同步原语,进一步提升程序的运行性能
  • 若是说轻量级锁是在无竞争的状况下使用 CAS消除同步使用的互斥量

  • 偏向锁就是在无竞争状况下把整个同步都消除掉

  • 含义:偏向锁会偏向于第一个得到它的线程,若是在后面的执行中该锁没有被其余的线程获取,则持有偏向锁的线程将永远不须要再进行同步

  • 加锁过程:启用偏向锁的锁对象在第一次被线程获取时,Mark Word 的锁标志位会被设置为 01,即偏向模式,同时使用 CAS 操做把获取到这个锁的线程 ID 记录在对象的 Mark Word 中。若操做成功,持有偏向锁的线程之后每次进入这个锁相关的同步块时均可再也不进行任何同步操做

  • 解锁过程:当有另外的线程去尝试获取这个锁时,根据锁对象目前是否处于被锁定的状态,撤销偏向后恢复到未锁定 01 或轻量级锁定 00 的状态,后续的同步操做就如轻量级锁执行过程。以下图:

  • 优势:可提升带有同步但无竞争的程序性能,但若程序中大多数锁总被多个线程访问,此模式就不必了

解放啦

三.碎碎念

可以写出高性能、高伸缩性的并发程序是一门艺术,而了解并发在底层是如何实现的,则是掌握这门艺术的前提,也是成长为高级程序员的必备知识!

加油吧!骚年!以梦为马,不负韶华!

冲鸭


若是文章对您有一点帮助的话,但愿您能点一下赞,您的点赞,是我前进的动力

本文参考连接:

相关文章
相关标签/搜索