前言

现在,如果要使用 Java 实现一段线程安全的代码,大致有 synchronized 、 java.util.concurrent 包等手段。虽然大家都会用,但却不一定真正清楚其在 JVM 层面上的实现原理,因此,笔者在查阅了一些资料后,希望把自己对此的一些见解分享给大家。

三板斧之一:互斥同步

  • 互斥同步:使用互斥的手段来保证同步操作。互斥是方法,同步是目的。
  • 在 Java 的世界里,最基本的互斥同步手段就是使用 synchronized 关键字。

synchronized 关键字

  1. synchronized 能实现同步的理论基础是:Java 中的每一个对象都可以作为锁。
  2. synchronized 关键字在不同的使用场景下,作为锁的对象有所不同,主要分为以下三种情况:
    • 对于同步代码块,锁就是声明 synchronized 同步块时指定的对象(synchronized 括号中配置的对象);
    • 对于普通对象方法,锁就是当前的实例对象;
    • 对于静态同步块,锁就是当前类的 Class 对象。
  3. 我们可以通过一段代码来进一步说明 synchronized 是如何实现互斥同步的。
  • 示例代码
public class SynchronizedTest {public void test() {synchronized (this) {try {System.out.println("SynchronizedTest.test() method start!");} catch (Exception e) {}}}
}
  • 对上述代码生成的字节码使用 Javap 进行反编译,结果如下:
Compiled from "SynchronizedTest.java"
public class com.xxx.JVMTest.SynchronizedTest {public com.xxx.JVMTest.SynchronizedTest();Code:0: aload_01: invokespecial #1                  // Method java/lang/Object."<init>":()V4: returnpublic void test();Code:0: aload_01: dup2: astore_13: monitorenter4: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;7: ldc           #3                  // String SynchronizedTest.test() method start!9: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V12: aload_113: monitorexit14: goto          2217: astore_218: aload_119: monitorexit20: aload_221: athrow22: returnException table:from    to  target type4    14    17   any17    20    17   any
}
  • 我们可以看到反编译的代码中,存在两个由 Javac 编译器加入的指令,分别是插入到同步代码块开始位置的 monitorenter 指令和插入到同步代码块结束位置以及异常处的 monitorexit 指令。
  • 根据《Java 虚拟机规范》可知,每个 Java 对象都有一个监视器锁(monitor)。在执行 monitorenter 指令时,首先要去尝试获取对象的锁,如果这个对象没被锁定,或者当前线程已经持有了该对象的锁,就把锁的计数器的值加一,而在执行 monitorexit 指令时会将锁计数器的值减一。一旦锁计数器的值为零,锁随即被释放。如果其他线程已经占用了该对象的锁,则该线程进入阻塞状态,直到锁的计数器为零时,再重新尝试获取该对象的所有权。
  • 因此,本质上 JVM 就是通过进入 Monitor 对象(monitorenter)以及退出 Monitor 对象(monitorexit)来实现方法和代码块的同步操作。
  1. 通过对 monitorenter 指令和 monitorexit 指令的分析,我们可以推出 synchronized 的三条结论:
  • 被 synchronized 声明的同步代码块对同一线程而言是可重入的,所以同一线程重复进入同步块也不会出现被自己锁死的情况;
  • 被 synchronized 声明的同步块在持有锁的线程执行完毕并释放锁之前,会无条件地阻塞后面其他线程的进入。因此无法实现对已经获得锁的线程强制释放锁的操作,以及对等待锁的线程实现中断等待或超时退出的机制。
  • 由于 Java 线程是映射到操作系统的原生内核线程之上的,如果要阻塞或者唤醒一条线程,则需要操作系统来帮忙完成,这不可避免地陷入用户态到核心态的转变之中,因此在一些经典的 Java 并发编程资料中,synchronized 被形象地称为重量级锁。但它相对于利用 java.util.concurrent 包中 Lock 接口实现的锁机制仍有一个先天的优势,就是 synchronized 的锁信息是被 JVM 记录在线程和对象的元数据中的,可以很轻易的知道当前哪些锁对象是被哪些特定的线程所持有,从而更容易进行锁优化。
  1. 在这里需要补充一点的就是,同步方法虽然也可以使用 monitorenter 指令和 monitorexit 指令实现同步操作,但实际上目前的实现中并没有采用这种方案
  • 我们可以具体分析下面的代码
public class SynchronizedTest {public synchronized void testTwo() {System.out.println("SynchronizedTest.testTwo() method start!");}
}
  • 对上述代码生成的字节码使用 Javap 进行反编译,结果如下:
  public synchronized void testTwo();descriptor: ()Vflags: ACC_PUBLIC, ACC_SYNCHRONIZEDCode:stack=2, locals=1, args_size=10: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;3: ldc           #6                  // String SynchronizedTest.testTwo() method start!5: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V8: returnLineNumberTable:line 23: 0line 24: 8LocalVariableTable:Start  Length  Slot  Name   Signature0       9     0  this   Lcom/xxx/JVMTest/SynchronizedTest;
  • 从反编译的结果来看,方法的同步并没有通过指令 monitorenter 和 monitorexit 来完成。相对于普通方法,其常量池中多了 ACC_SYNCHRONIZED 标示符。实质上 JVM 是根据该标示符来实现方法的同步的,当方法被调用时,调用指令将会检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先获取 Monitor 锁,获取成功之后才去执行方法体,并在方法执行完后释放 Monitor 锁。同时,在方法执行期间,其他任何线程都无法再获得同一个 Monitor 锁对象。
  • 方法的同步和代码块的同步没有本质区别,只是其用一种隐式的方式来实现,无需通过字节码来完成。

三板斧之二:非阻塞同步

  • 根据上一小节我们可以知道,在进行互斥同步时,无论共享的数据是否真的存在竞争,它都会进行加锁操作,从而导致用户态与核心态的转换、维护锁计数器以及检查是否有等待锁的线程需要被唤醒等额外开销,因此互斥同步属于一种悲观的并发策略。
  • 那么是否存在一种乐观的并发策略呢?答案是有的,目前在 Java 中实现了一种基于冲突检测的加锁策略 ———— CAS 操作。
  • 通俗的说就是先不管是否存在竞争,先进行操作,一旦产生了冲突,再通过其他补偿手段进行修正。最常见的就是通过不断地重试,直到没有竞争为止。
  • 这种策略地好处在于全程是处于用户态中进行操作,从而避免了频繁地用户态与核心态之间的切换操作。
  1. 直到 JDK 5 ,在 java.util.concurrent.atomic 包中才提供了一些类支持原子级别的 CAS 操作,包括 AtomicBoolean、AtomicInteger、AtomicLong 等,而这些类的方法大多数又是调用的 sun.misc.Unsafe 类里面的 compareAndSwapInt() 和 compareAndSwapLong() 等几个保证原子操作的方法。
  • 以 java.util.concurrent.atomic.AtomicInteger 类的 getAndIncrement() 方法为例:
public class AtomicInteger extends Number implements java.io.Serializable {static {try {//获取 value 变量的偏移量, 赋值给 valueOffsetvalueOffset = unsafe.objectFieldOffset(AtomicInteger.class.getDeclaredField("value"));} catch (Exception ex) { throw new Error(ex); }}/*** Atomically increments by one the current value.** @return the previous value*/public final int getAndIncrement() {return unsafe.getAndAddInt(this, valueOffset, 1);}...other methods...
}/*==========================================*/public final class Unsafe {public final int getAndAddInt(Object var1, long var2, int var4) {int var5;do {//通过对象和偏移量获取变量的值//由于 volatile 的修饰, 因此所有线程看到的 var5 都是一样的var5 = this.getIntVolatile(var1, var2);} while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));return var5;}...other methods...
}
  • 我们可以看到 Unsafe 类的 getAndAddInt() 方法中存在一个 do while 循环,而循环条件中的 compareAndSwapInt() 方法会以原子的方式尝试修改 var5 的值。
  • 具体而言,该方法通过 obj 和 valueOffset 获取变量的值,如果这个值和 var5 不一样,说明其他线程已经先一步修改了 obj + valueOffset 地址处的值,此时 compareAndSwapInt() 返回 false,继续循环;如果这个值和 var5 一样,说明没有其他线程修改 obj + valueOffset 地址处的值,此时可以将 obj + valueOffset 地址处的值改为 var5 + var4 ,compareAndSwapInt() 返回 true,退出循环。由于 compareAndSwapInt() 方法是原子操作, 所以compareAndSwapInt() 修改 obj + valueOffset 地址处的值时不会被其他线程中断。
  1. 通过上面的例子我们可以发现,使用 CAS 来实现同步操作也引发了一些新的问题:
  • 如果自旋 CAS 长时间不成功,就会白白浪费本来就宝贵的 CPU 时间;
  • 理论上而言,CAS 也只能保证一个共享变量的原子操作,功能上并没有 synchronized 同步代码块丰富;
  • ABA问题:我们可以假设这样一种场景,如果一个值原来是A,变成了B,之后又变回了A,那么在使用 CAS 操作进行检查时会出现以为它的值没有发生变化,而实际上已经变化了的情况。不过实际上即使出现了 ABA 问题在大部分并发情况下也不会影响程序的并发正确性,如果证实确实存在影响,那么最好改用 synchronized 同步代码块来实现同步操作。

三板斧之三:无同步线程安全

  • 其实,同步与否与是否线程安全没有必然联系,同步只是实现线程安全的一种手段,如果存在有竞争的共享数据那么使用同步手段来保证线程安全也不失为一种好的方案,但如果本来就不存在竞争的可能,那它本身就有隐式的线程安全保证。
  1. 可重入代码(纯代码)

是一种允许多个进程同时访问的代码。程序在运行过程中可以被打断,并由开始处再次执行,并且在合理的范围内(多次重入,而不造成堆栈溢出等其他问题),程序可以在被打断处继续执行,且执行结果不受影响。(可重入代码 | 百度百科)

  1. 可重入代码拥有一些共同的特征:
  • 不依赖全局变量;
  • 不依赖存储在堆上的数据和公用的系统资源;
  • 使用到的状态量都由参数传入;
  • 不调用其他非可重入的方法; …
  1. 因此,如果一段代码中存在与其他代码的共享变量,只要能保证这些变量的可见范围只在同一个线程内,那么无需同步也能保证线程之间的数据安全性。
  2. 在 Java 中,使用了 java.lang.ThreadLocal 类来实现线程本地存储的功能,每个线程的 Thread 对象中都有一个 ThreadLocalMap 对象,这个对象存储了一组以 ThreadLocal.threadLocalHashCode 为键,以本地线程变量为值的 K - v 键值对。由于每个线程的 ThreadLocal.threadLocalHashCode 的值都是独一无二的,因此所映射的值也只能该线程自己才能访问到,也就实现了线程安全。

总结

  1. 可以使用互斥同步(阻塞同步)的方式,实现共享变量的线程安全,典型例子包括:synchronized 等;
  2. 可以使用自旋 CAS 的方式,实现共享变量的线程安全,典型例子包括:sun.misc.Unsafe 类、java.util.concurrent.atomic 包中的 AtomicBoolean、AtomicInteger、AtomicLong 等;
  3. 如果可以保证共享变量的可见范围均在同一个线程之内,那么其本身就带有隐式的线程安全性,不需要再做其他显式的同步操作。

参考文献

  1. 方腾飞, 魏鹏, 程晓明.Java并发编程的艺术 [M]. 北京:机械工业出版社,2015:11-20.
  2. 周志明.深入理解Java虚拟机: JVM高级特性与最佳实践(3 版)[M]. 北京:机械工业出版社,2019:471-478.

Java中保证线程安全的三板斧相关推荐

  1. 万字图文 | 学会Java中的线程池,这一篇也许就够了!

    来源:一枝花算不算浪漫 线程池原理思维导图.png 前言 Java中的线程池已经不是什么神秘的技术了,相信在看的读者在项目中也都有使用过.关于线程池的文章也是数不胜数,我们站在巨人的肩膀上来再次梳理一 ...

  2. java sleep唤醒_详解Java中的线程让步yield()与线程休眠sleep()方法

    Java中的线程让步会让线程让出优先级,而休眠则会让线程进入阻塞状态等待被唤醒,这里我们对比线程等待的wait()方法,来详解Java中的线程让步yield()与线程休眠sleep()方法 线程让步: ...

  3. JAVA中创建线程池的五种方法及比较

    之前写过JAVA中创建线程的三种方法及比较.这次来说说线程池. JAVA中创建线程池主要有两类方法,一类是通过Executors工厂类提供的方法,该类提供了4种不同的线程池可供使用.另一类是通过Thr ...

  4. Java中的线程基础知识

    Java中的线程基础知识 1.线程概念 线程是程序运行的基本执行单元.当操作系统(不包括单线程的操作系统,如微软早期的DOS)在执行一个程序时,会在系统中建立一个进程,而在这个进程中,必须至少建立一个 ...

  5. java中的线程安全是什么?

    java中的线程安全是什么: 就是线程同步的意思,就是当一个程序对一个线程安全的方法或者语句进行访问的时候,其他的不能再对他进行操作了,必须等到这次访问结束以后才能对这个线程安全的方法进行访问. 什么 ...

  6. 关于Java中的线程安全(线程同步)

    java中的线程安全是什么:就是线程同步的意思,就是当一个程序对一个线程安全的方法或者语句进行访问的时候,其他的不能再对他进行操作了,必须等到这次访问结束以后才能对这个线程安全的方法进行访问 什么叫线 ...

  7. 四十七、面试前,必须搞懂Java中的线程池ThreadPoolExecutor(上篇)

    @Author:Runsen @Date:2020/6/9 人生最重要的不是所站的位置,而是内心所朝的方向.只要我在每篇博文中写得自己体会,修炼身心:在每天的不断重复学习中,耐住寂寞,练就真功,不畏艰 ...

  8. Java 中的线程安全的类

    Java 中的线程安全的类 3 个线程安全的类 它们对应的非线程安全的类 它们延伸的类 其它 它们对应的非线程安全的类 3 个线程安全的类   Java 中的 3 个基本的线程安全的类为:Hashta ...

  9. 不允许使用java方式启动_细品 Java 中启动线程的正确和错误方式

    细品 Java 中启动线程的正确和错误方式 前文回顾详细分析 Java 中实现多线程的方法有几种?(从本质上出发) start 方法和 run 方法的比较 代码演示:/** * * start() 和 ...

最新文章

  1. linux mysql换成_把 SQL Server 迁移到 Linux?不如换成 MySQL
  2. 优化案例(part2)--Fragmentary label distribution learning via graph regularized maximum entropy criteria
  3. SAP Spartacus i18n 的文本,和翻译相关的话题:internationalization
  4. 阿里当初50亿美元收购UC,现在看来是不是亏大了?
  5. 【软件工程】用例间的关系
  6. spring-retry小结
  7. linux svn官网,linux svn
  8. 前景检测算法(十五)--LOBSTER算法
  9. 【JSP内置对象】之9大内置对象(JavaWeb必背必掌握)
  10. Mugeda(木疙瘩)H5案例课—交互视频类H5-岑远科-专题视频课程
  11. 快手投放广告,快手广告优势有哪些呢?
  12. [半监督学习] In Defense of Pseudo-Labeling: An Uncertainty-Aware Pseudo-label Selection Framework for SSL
  13. c语言哑铃,使用一副哑铃,做好8个动作,就能练遍全身肌肉
  14. 高压直流电源为什么要“接地”?如何“接地”?
  15. 计算机专业毕业论文怎么写够字数,本科生毕业论文要求多少字
  16. 【Windows】电脑蓝牙突然无法使用,解决办法来了
  17. CentOS 7静态IP在主机重启后失效解决
  18. Maven配置中央仓库
  19. 腾讯新闻电脑客户端 v4.3.2 官方pc版
  20. 计算机维修商业计划书,电脑维修店创业计划书

热门文章

  1. PCA(explained_variance_ratio_与explained_variance_)
  2. html5+canvas+javascript开发打灰机小游戏
  3. 什么是兴趣点 (POI)
  4. VirtualApp中静默安装App
  5. 一名数据分析师的SQL学习历程
  6. ROSwiki更正 — 创建ROS消息和ROS服务
  7. 2022年重氮化工艺考题及在线模拟考试
  8. 如何判断是否被网关或ISP劫持了DNS或HTTP流量?
  9. android 几个麦克风,在Android中同时在多个应用中使用麦克风
  10. 解决 树莓派ping: baidu.com: 域名解析暂时失败