您的位置:  首页 > 技术 > java语言 > 正文

Java中保证线程安全的三板斧

2021-10-20 08:00 OSCHINA Horizon-x 次阅读 条评论

前言

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

测试环境

- JDK:

    - java version "1.8.0_202"

    - Java(TM) SE Runtime Environment (build 1.8.0_202-b08)

    - Java HotSpot(TM) 64-Bit Server VM (build 25.202-b08, mixed mode)

- OS:Windows 10

- IDE:

    - IntelliJ IDEA 2021.1.3 (Ultimate Edition)

三板斧之一:互斥同步

  • 互斥同步:使用互斥的手段来保证同步操作。互斥是方法,同步是目的。
  • 在 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_0

       1: invokespecial #1                  // Method java/lang/Object."<init>":()V

       4: return



  public void test();

    Code:

       0: aload_0

       1: dup

       2: astore_1

       3: monitorenter

       4: 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;)V

      12: aload_1

      13: monitorexit

      14: goto          22

      17: astore_2

      18: aload_1

      19: monitorexit

      20: aload_2

      21: athrow

      22: return

    Exception table:

       from    to  target type

           4    14    17   any

          17    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: ()V

    flags: ACC_PUBLIC, ACC_SYNCHRONIZED

    Code:

      stack=2, locals=1, args_size=1

         0: 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;)V

         8: return

      LineNumberTable:

        line 23: 0

        line 24: 8

      LocalVariableTable:

        Start  Length  Slot  Name   Signature

            0       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 变量的偏移量, 赋值给 valueOffset

            valueOffset = 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.

读书不觉已春深,一寸光阴一寸金。

  • 0
    感动
  • 0
    路过
  • 0
    高兴
  • 0
    难过
  • 0
    搞笑
  • 0
    无聊
  • 0
    愤怒
  • 0
    同情
热度排行
友情链接