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

线程安全

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

Java中的线程安全

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

  • 不可变
  • 绝对线程安全
  • 相对线程安全
  • 线程兼容
  • 线程对立

线程安全的实现

互斥同步

互斥同步是一种最常见,最主要的并发正确性保障手段

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

synchronized关键字
  1. 这是一种 块结构 的同步语法

  2. 经过 Javac 编译之后,会在同步块的前后分别形成 monitorentermonitorexit 这两个字节码指令。这两个字节码指令都需要一个 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 实例对象,经过逃逸分析发现它的动态作用域被限制在单个方法内,也就是这个实例对象的所有引用都永远不会逃逸到方法外,其他线程无法访问到它,那就会将这里的锁安全地消除掉。当然,JDK 5 后字符串的拼接操作是被转化为 StringBuilder 对象的连续 append 操作,而 StringBuilder 是线程不安全的,不会加锁,也是一种锁消除的思想

锁粗化

原则上,我们编写代码的时候,总是推荐将同步块的作用范围限制得尽量小,只在共享数据的实际作用域中才进行同步,这样是为了 使得需要同步的操作数量尽可能变少,即使存在锁竞争,等待锁的线程也能尽可能快地拿到锁

但如果 一系列连续的操作对同一个对象反复加锁和解锁,甚至加锁操作出现在循环体之中的,那即使没有线程竞争,频繁地进行互斥同步操作也会导致不必要的性能损耗
例如前面 锁消除 部分讲到的字符串拼接操作会转化为一个 StringBuffer 对象的连续 append() 调用,每一次 append() 都会对同一个对象进行加锁。如果虚拟机探测到有这样一串零碎的操作都对同一个对象加锁,将会把加锁同步的范围扩展 (粗化) 到整个连续操作序列的外部,只需要加锁一次就可以了

轻量级锁

轻量级锁是 JDK 6 加入的新型锁机制,名字中的 “轻量级” 是相对于 使用操作系统互斥量来实现的传统锁 而言的,因此传统的锁机制就被称为 “重量级” 锁

不过轻量级锁并不是来替代重量级锁的,他设计的初衷是 在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗 (传统的加锁使用操作系统互斥量,涉及到用户态核心态切换,维护锁计数器等操作开销;有的时候,同步是必须要的,但多线程竞争的发生却不是经常性的,如果多线程竞争的发生没有那么多,却还是每次都要进行那样的加锁操作,就显得些许浪费,消耗了性能)
对象头

加锁

轻量级锁的 工作流程 如下:在代码即将进入同步块的时候,如果此同步对象没有被锁定 (锁标志位为 “01” 状态) ,虚拟机首先将在当前线程的栈帧中建立一个名为 锁记录 (Lock Record) 的空间,用于存储锁对象目前的 Mark Word 的拷贝 (称为 Displaced Mark Word),这时线程堆栈与对象头的状态如下所示:
CAS前
然后,虚拟机将使用 CAS 操作尝试把对象的 Mark Word 更新为指向 Lock Record 的指针。如果这个更新操作成功了,即代表该线程拥有了这个对象的锁,并且对象 Mark Word 的锁标志位转变为 “00” ,表示此对象处于轻量级锁,这时候线程堆栈与对象头的状态如下:
CAS后
如果这个更新操作失败了,那就意味着至少存在一条线程与当前线程竞争获取该对象的锁。
虚拟机首先会检查对象的 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 来禁止偏向锁优化反而可以提升性能

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值