synchronized 关键字

摘抄给自己留个笔记

 

1. Synchronized 简介

  一个栗子: 10个线程的 1000000 次 i++ 操作,结果并非 10000000,且每次运行结果都不同。(例子在脑海中,无图。。)

  线程安全的主要来源是 JMM 的设计中,主内存和线程的工作内存的可见性问题,以及重排序导致的问题。线程运行时拥有自己的栈空间,会在自己的栈空间运行,如果多线程间没有共享的数据,也就是说多线程并没协作的完成一件事,那么多线程就不能发挥优势,不能带来巨大的价值。那么,共享数据的线程安全问题要怎么处理呢?很自然的想法就是每个线程依次去读写这个共享变量,这样就会有任何的数据安全的问题,因为每个线程所操作的都是当前最新的版本数据。Java 关键字 Synchronized 就具有使每个线程依次排队操作共享变量的功能。很显然,这种同步机制效率很低,但是,Synchronized 是其他并发容器实现的基础,对它的理解会大大提升对并发编程的感觉。

 

2. Synchronized 实现原理

  在Java代码中使用 Synchronized 可以是在代码块中,也可以是在方法上。根据 Synchronized 用的位置可以有这些使用场景:

 

  • public synchronized void method() {.....}   // 实例方法锁住的是该类的实例对象
  • public static synchronized void method() {....}  // 静态方法锁住的是类对象
  • synchronized (this) {......}   // 同步代码块锁住的是该类的实例对象
  • synchronized (SynchronizedDemo.class){......}  // 同步代码块锁住的是该类的类对象
  • String lock='';  synchronized(lock) {......}  // 同步代码块锁住的是配置的实例对象(String对象作为锁)

 

  如上,synchronized 可以用在方法上也可以用在代码块中,其中方法是实例方法和静态方法,分别锁住的是该类的实例对象和该类的类对象。而使用在代码块中,分为三种。

  注意点是,如果锁的是类对象的话,尽管 new 多个实例对象,但他们仍然是属于同一个类,依然会被锁住,即,线程之间保证同步关系。

 

2.1 对象锁(monitor)机制

  小小的栗子:

public class SynchronizedDemo {
  public static void main (String args[]) {
     synchronized (SynchronizedDemo.class) {
      }
      method();
  }

  private static void method () {
  } }

  

  上面的代码中有同步代码块,锁住的是类对象,并且还有一个同步静态方法,锁住的依然是该类的类对象。编译后,切换到 SynchronizedDemo.class 的同级目录下,用 javap -v SynchronizedDemo.class 查看字节码文件。(没实际运行,就不发图了。但是这个命令可用)

  图要表达的重点是,添加 Synchronized 关键字之后独有的 monitorenter 和 monitorexit 指令。通过分析之后可以看出,使用 Synchronized 进行同步,其关键就是必须要对对象的监视器 monitor 进行获取,当线程获取 monitor 后才能继续往下执行,否则,就只能等待。而这个获取的过程是互斥的,即同一时刻只有一个线程能够获取到 monitor 。上面的 demo 在执行完同步代码块之后紧接着再会去执行一个静态方法,而这个方法锁的对象就是这个类对象,那么这个正在执行的线程还需要获取该锁吗?答案是不必的。 这就是锁的重入性,即在同一锁程中,线程不需要再次获取同一把锁。Synchronized 先天具有重入性。每个对象有一个计数器,当线程获取该对象锁后,计数器就会加一,释放锁后就会将计数器减一

  任意一个对象都有自己的监视器,当这个对象由同步块或者这个对象的同步方法调用时,执行方法的线程必须先获取该对象的监视器,才能进入同步块和同步方法,如果没有获取到监视器的线程,将会被阻塞在同步块和同步方法的入口处,进入到 blocked 状态。

  

   以下是,对象、对象监视器、同步队列、执行线程状态  之间的关系:

 

   该图可以看出,任意线程对 object 的访问,首先要获得 object 的监视器,如果获取失败,该线程就进入同步队列,线程状态变为 blocked ,当 object 的监视器占有者释放后,在同步队列中的线程就会有机会重新获取该监视器。

 

 

2.2 Synchronized 的 happens-before 关系

  Synchronized 的 happens-bofre 规则,即监视器锁规则:对同一个监视器的解锁 happens-before 于 对该监视器的加锁。

 

  根据 happens-before 的定义,如果 A happens-before B,则 A 的执行结果对 B 可见,并且 A 的执行顺序先于 B。线程 A 先对共享变量 a 加一,线程 A 的执行结果对线程 B 可见,即线程 B 所读取到的 a 值为 1。

 

 2.3 锁获取和锁释放的内存语义

 

  基于 java 内存抽象模型的 Synchronized 的内存语义。

  

 

 

  从上图可以看出,线程 A 会首先从主内存中读取共享变量 a=0 的值,然后将该变量拷贝到自己的本地内存,进行 加一 操作后,再将该值刷新到主内存,整个过程即为线程 A 加锁 -> 执行临界区代码 -> 释放锁相对应的内存语义。

  线程 B 获取锁的时候同样会从主内存中获取共享变量 a 的值,这个时候就是最新的值 1,然后将该值拷贝到线程 B 的工作内存中去,释放锁的时候同样会重写到主内存中。

 

  从整体上看,线程 A 的执行结果 (a=1)对线程 B 是可见的,实现原理为:释放锁的时候会将值刷新到主内存中,其他线程获取锁时,会强制从主内存中获取最新的值。

  从横向来看,这就像线程 A 通过主内存中的共享变量和线程 B 进行通信, A 告诉 B 我们的共享数据是 1 了,这种线程间的通信机制正好吻合 java 的内存模型,正好是共享内存的并发模型结构。

  

  

 3. synchronized 优化

  以上,我们最大的感受是,在同一时刻只有一个线程能够获得对象的监视器(monitor),从而进入到同步代码块或者同步方法中,即表现为 互斥性(排他性)。这种方式肯定效率低下,每次只能通过一个线程,这种形式不能改变的话,我们能不能让每次通过的速度快一点?可以缩短获取锁的时间。

  在说到锁的优化也就是锁的几种状态前,有两个知识点需要先关注:CAS 操作,Java 对象头。这是理解下面知识的前提。

 

3.1 CAS 操作

 

3.1.1 什么是 CAS?

  使用锁时,线程获取锁是一种悲观锁策略,即,假设每次执行临界代码都会产生冲突,所以当前线程获取到锁的时候,同时也会阻塞其他线程获取该锁。而 CAS 操作(又称无锁操作)是一种乐观锁策略,它假设所有线程访问共享资源时,都不会出现冲突,既然不会出现冲突自然就不会阻塞其他线程的操作。因此,线程就不会出现阻塞停顿的状态。那么,如果出现了冲突了怎么办?无锁操作是使用 CAS(compare and swap),又叫做比较交换来鉴别线程是否出现冲突,出现冲突就重试当前操作直到没有冲突为止。

 

3.1.2 CAS 的操作过程

 

  CAS 比较交换的过程可以通俗的理解为 CAS(V, O, N),包括三个值,分别为:V 内存地址存放的实际值;O 预期的值(旧值);N 更新的新值。当 V 和 O 相同时,也就是说旧值和内存中实际的值相同,表明该值没有被其他线程更改过,即,该旧值 O 就是目前来说最新的值了,自然可以将新值 N 赋值给 V 。反之,V 和 O 不相同,表明该值已经被其他线程改过了,则该旧值 O 不是最新版本的值了,所以不能将新值 N 赋给 V,返回 V 即可。当多个线程使用 CAS 操作一个变量时,只有一个线程会成功,并成功更新,其余会失败。失败的线程会重新尝试,也可以选择挂起线程。

  CAS 的实现,需要硬件指令集的支持,在 JDK1.5 后,虚拟机才可以使用处理器提供的 CMPXCHG 指令实现。

  

  元老级的 Synchronized(未优化前)最主要的问题是:在存在线程竞争的情况下会出现线程阻塞和唤醒锁带来的性能问题,因为这是一种互斥同步(阻塞同步)。而 CAS 并不是武断的将线程挂起,当 CAS 操作失败后,会进行一定的尝试,而非进行耗时的挂起唤醒的操作,因此,也叫做非阻塞同步。这是两者主要的区别。

 

3.1.3 CAS 的应用场景

  在 J.U.C 包中利用 CAS 实现的类有很多,可以说是支撑起整个 concurrency 包的实现,在 lock 实现中会有 CAS 改变 state 变量,在 atomic 包中的实现类也几乎都是用 CAS 实现。

 

3.1.4 CAS 的问题

 

  1. ABA 问题

  因为 CAS 会检查旧值有没有变化,这里存在一个问题:在 CAS 算法中,需要取出内存中某时刻的数据(由用户完成),在下一时刻比较并交换(CPU保证原子操作),这个时间差会导致数据的变化。假设有一下顺序事件:1)线程 1 从内存位置 V 中取出 A   2)线程 2 从内存位置 V 中取出 A  3)线程 2 进行了写操作,将 B 写入内存位置 V  4)线程 2 将 A 再次写入内存位置 V  5)线程 1 进行 CAS 操作,发现 V 中仍然是 A,交换成功。尽管线程 1 中的 CAS 操作成功,但线程 1  并不知道内存位置 V 的数据发生过改变。

  解决方案可以沿袭数据库中常用的乐观锁方式,添加一个版本号可以解决。原来的变化路径 A -> B -> A 就变成了 1A -> 2B -> 3A。Java 1.5 后的 atomic 包中提供了 AtomicStampedReference 来解决 ABA 问题,思路就是如此。

 

  2. 自旋时间过长

  使用 CAS 是非阻塞同步,也就是说不会将线程挂起,会自旋(无非就是一个死循环)进行下一次尝试,如果这里自旋时间过长对性能是很大的消耗。如果 JVM 能支持处理器提供的 pause 指令,那么在效率上会有一定的提升。

 

  3. 只能保证一个共享变量的原子操作

  当对一个共享变量执行操作时,CAS 能保证其原子性,如果对多个共享变量进行操作,CAS 就不能保证其原子性。有一个解决方案是利用对象整合多个共享变量,即一个类中的成员变量就是这几个共享变量,然后将这个对象做 CAS 操作就可以保证其原子性。atomic 中提供了 AtomicReference 来保证引用对象之间的原子性。

 

 

 3.2 Java 对象头

  在同步的时候是获取对象的 monitor,即获取到对象的锁。对象的锁的理解,类似是对象的一个标志,这个标志存放的就是 Java 对象的对象头。Java 对象头里的 mark word 里默认的存放的对象的 hashcode,分代年龄和锁标记位。32 位 JVM mark word 默认存储结构为

锁状态 25bit 4bit 1bit 是否是偏向锁 2bit 锁标志位
无锁状态 对象的 hashcode 对象分代年龄 0 01

 

 ---  以上据作者说是摘自《java 并发编程的艺术》。很好的书,被我放弃了,有空回头捡。

 

  如图,在mark word 里会默认存放 hashcode,年龄值,以及 锁标志位等信息。

 

  Java SE 1.6 中,锁一共有 4 种状态,级别从低到高依次是:无锁状态偏向锁状态轻量级锁状态重量级锁状态,这几个状态会随着竞争情况逐渐升级。锁可以升级但不能降级,意味着偏向锁升级成轻量级锁后不能降级成偏向锁。这种锁升级却不能降级的策略,目的是为了提高获得锁和释放锁的效率。

 

 

 

 3.3 偏向锁

  

  大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁。

 

  偏向锁的获取

  当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程 ID,以后该线程再进入和退出同步块时,不需要进行 CAS 操作来加锁和解锁,只需简单地测试一下对象头的 mark word 里是否存储着指向当前线程的偏向锁。如果测试成功,表示线程已经获得了锁。如果测试失败,则需要再测试一下 mark word 中偏向锁的标识是否设置成 1 (表示当前是偏向锁):如果没有设置,则使用 CAS 竞争锁,如果设置了,则尝试使用 CAS 将对象头的偏向锁指向当前线程。

 

 

  偏向锁的撤销

  偏向锁使用了一种等到竞争出现才释放锁的机制,所以当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。

 

 

  如图,偏向锁的撤销,需要等待全局安全点(在这个时间点上没有正在执行的字节码)。它会首先暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否还存活, 如果线程不处于活动状态,则将对象头设置成无锁状态;如果线程仍然活着,拥有偏向锁的栈会被执行,遍历偏向对象的锁记录,栈中的锁记录和对象头的 mark word 要么重新偏向于其他线程,要么恢复到无锁或者标记对象不适合作为偏向锁,最后唤醒暂停的线程

  

 

 

 

   如何关闭偏向锁

  偏向锁在 Java6 和 Java 7 中是默认启用的,但是它在应用程序启动几秒钟后才激活,如果必要可以使用 JVM 参数来关闭延迟: -XX:BiasedLockingStartupDelay=0。如果你确定应用程序里所有的锁通常情况下处于竞争状态,可以通过 JVM 参数关闭偏向锁: -XX:-UseBiasedLocking=false,那么程序默认进入轻量级锁状态。

 

3.3 轻量级锁

 

  加锁

  线程在执行同步块之前,JVM 会先在当前线程的栈帧创建用于存储锁记录的空间,并将对象头中的 mark word 复制到锁记录中,官方称为 Displaced Mark Word。然后线程尝试使用 CAS 将对象头中的 mark word 替换为指向锁记录的指针。如果成功,当前线程获得锁,如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁。

 

  解锁

  轻量级解锁是,会使用原子的 CAS 操作将 Displaced Mark Word 替换回到对象头,如果成功,则表示没有竞争发生。如果失败,表示当前锁存在竞争,锁就会膨胀成重量级锁。

  下图是两个线程同时争夺锁,导致锁膨胀的流程图。

 

 

  因为自旋会消耗 CPU,为了避免无用的自旋(比如,获得锁的线程被阻塞住了),一旦锁升级成了重量级锁,就不会再恢复到轻量级锁状态。当锁处于这个状态下,其他线程试图获取锁时,都会被阻塞住,当持有锁的线程释放锁之后会唤醒这些线程,被唤醒的线程就会进行新一轮的争夺锁。

 

3.4 各种锁的比较

 

优点 缺点 适用场景
偏向锁 加锁和解锁不需要额外的消耗,和执行非同步方法相比仅存在 纳秒级 的差距 如果线程间存在锁竞争,会带来额外的锁撤销的消耗 适用于只有一个线程访问同步块的场景
轻量级锁 竞争的线程不会阻塞,提高了程序的响应速度 如果始终得不到锁竞争的线程,使用自旋会消耗 CPU 追求响应时间,同步块执行速度非常快
重量级锁 线程竞争不使用自旋,不会消耗CPU 线程阻塞,响应时间缓慢 追求吞吐量,同步块执行速度长

 

posted @ 2020-03-28 16:53  停不下的时光  阅读(233)  评论(0编辑  收藏  举报