【Java】多线程-Java 保证原子性、有序性、可见性

Posted by 西维蜀黍 on 2019-02-28, Last Modified on 2021-09-21

线程安全(Thread Safety)

概念

当一个可变(mutable)对象会被多个线程访问时,就要考虑这个线程是不是需要被设计成线程安全的。

一个类会被称为**线程安全(thread-safe)**的,当它被从多个线程访问,而且无论这些线程如何被调度(scheduling)和交叉(interleaving)执行,而且在调用代码(calling code)中不需要额外的同步(synchronization)或者其他协调(coordiantion)机制,它的行为仍然正确(若预期执行)。

换句话说,线程安全的类封装(encapsulate)了已经需要的同步机制,因此客户端或者说调用者不再需要关注或者提供这些同步机制。

Java 内存模型是围绕着并发编程中原子性(atomicity)、**可见性(visibility)以及有序性(ordering)**这三个特征来建立的。

那么, Java 语言本身对这三个特性提供了哪些保证呢?

Java 如何保证原子性(atomicity)

常用的保证 Java 操作原子性的工具是锁(Lock)synchronized 方法(或者 synchronized 代码块)原子类(Atomic Classes)

锁(Lock)

使用锁,可以保证同一时间只有一个线程能拿到锁,也就保证了同一时间只有一个线程能执行申请锁和释放锁之间的代码。

public void testLock () {
  lock.lock();
  try{
    int j = i;
    i = j + 1;
  } finally {
    lock.unlock();
  }
}

同步方法/块(synchronized 关键字)

与锁类似的是同步方法(synchronized 方法)或者同步代码块( synchronized 块)。

声明非静态 synchronized 方法时,锁住的是当前实例; 声明静态 synchronized 方法时,锁住的是该方法对应类的 Class 对象;使用 synchronized 代码块时,锁住的是synchronized关键字后面括号内的对象。

下面是 synchronized 代码块的示例:

public void testLock () {
  synchronized (anyObject){
    int j = i;
    i = j + 1;
  }
}

分析

值得一提的是,为了保证目标代码段(临界区)的原子性,当锁定了同一个对象时,在一个synchronized方法/块执行完成前,另一个synchronized方法/块永远不会被执行,即使前一个synchronized方法/块执行的时间非常长。

public class Test {
    public static void main(String[] args) {
        new Thread(() -> {
            try {
                Thread.sleep(100);
                testA();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }).start();

        new Thread(() -> {
            try {
                testB();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }).start();
        ;
    }

    public static synchronized void testA() {
        System.out.println("aaa");
    }

    public static synchronized void testB() throws InterruptedException {
        Thread.sleep(30000);
        System.out.println("bbb");
    }
}

比如在上面的例子中,testB() 方法会被执行长达30秒,虽然这个方法对应的执行线程在执行过程中,肯定会因为CPU时间片被用完从而被置为就绪态。但是,由于执行testB() 方法的线程先获得Monitor,执行testA() 方法的线程也只有等待前者执行完成后,才有机会执行(即使testB() 方法会被执行长达30秒)。

换句话说,这段代码的输出永远是:

bbb
aaa

总结

无论使用锁还是 synchronized 关键字,本质都是一样,通过锁来实现资源的排它访问(mutual exclusion),从而实现**临界区(critical regions)**在同一时间只能被一个线程执行,进而保证了目标代码段(临界区)的原子性。这是一种以牺牲性能为代价的方法。

原子类(Atomic Classes)

Java 中提供了对应的原子操作类来实现该操作,并保证原子性,其本质是利用了 CPU 级别的指令来实现 CAS (compare and swap)操作。由于是 CPU 级别的指令,其开销比需要操作系统参与的锁的开销小。

比如,AtomicInteger 的使用方法如下:

AtomicInteger atomicInteger = new AtomicInteger();
for(int b = 0; b < numThreads; b++) {
  new Thread(() -> {
    for(int a = 0; a < iteration; a++) {
      atomicInteger.incrementAndGet();
    }
  }).start();
}

Java 如何保证有序性(ordering)

上文讲过编译器和处理器对指令进行重新排序时,会保证重新排序后的执行结果和代码顺序执行的结果一致,所以重新排序过程并不会影响单线程程序的执行,却可能影响多线程程序并发执行的正确性。

Java 中可通过 volatile 在一定程序上保证顺序性,另外还可以通过 synchronized 和锁(lock)来保证顺序性。

synchronized 和锁保证顺序性的原理和保证原子性一样,都是通过保证同一时间只会有一个线程执行目标代码段来实现的。


除了从应用层面保证目标代码段执行的顺序性外,JVM 还通过被称为 happens-before 原则隐式地保证顺序性。两个操作的执行顺序只要可以通过 happens-before 推导出来,则 JVM 会保证其顺序性,反之 JVM 对其顺序性不作任何保证,可对其进行任意必要的重新排序以获取高效率。

Java 如何保证可见性(visibility)

一个变量具有可见性(visibility),是指,当一个线程修改了这个变量的值,新值(或者是说这个修改操作)对于其他线程是可以立刻感知的。所谓立刻感知,是指只要在这个修改操作完成后,其他线程在任意时刻去读取这个变量时,都能获取到这个这个变量的新值。

而普通的共享变量并不能被保证可见性,因为在一个线程中修改了普通共享变量后,这个修改操作什么时候被同步更新到主存是不确定的。因此,当其他线程去读取这个变量时,主存中可能还是原来的旧值,因此自然读取到旧值(即使线程A的修改操作的执行在时间上早于线程B的读取操作),所以无法保证变量的可见性。

volatile

Java 提供了 volatile 关键字来保证变量的可见性。

当一个共享变量被 volatile 修饰时,JVM 会保证当其被修改时,会立即(从工作内存)被更新到主存中,且当有其他线程需要读取时,它会去内存中读取新值(触发缓存行失效)。

换句话说,volatile变量对所有线程是立即可见的,即对volatile变量是所有的写操作都能立刻反应到其他线程之中。

synchronized 关键字和 Lock

另外,通过 synchronized 关键字和 Lock 也能够保证可见性,synchronized 关键字和 Lock 能保证同一时刻只有一个线程获取锁然后执行同步代码,并且在释放锁之前会将对变量的修改刷新到主存当中。因此可以保证可见性。

JMM 关于 synchronized 的两条规定:

  • 线程解锁前,必须要共享变量的最新值刷新到主内存中;
  • 线程加锁前,将清空工作内存中共享变量的值,从而使用共享变量时需要从主内存中重新读取最新的值。

final 关键字

final 关键字的可见性是指:被 final 修饰的域(fiele)在构造器中一旦初始化完成,并且构造器没有把“this”引用传递出去(即没有出现“this”逸出),就具有可见性(即在其它线程中,能够看见这个 final 域的被初始化后的值)。

例子

public class FinalDemo {
    private int a;  //普通域
    private final int b; //final域
    private static volatile FinalDemo finalDemo;

    public FinalDemo() {
        a = 1; // 1. 写普通域
        b = 2; // 2. 写final域
    }

    public static void writer() {
        finalDemo = new FinalDemo();
    }

    public static void reader() {
      if (finalDemo != null){
        FinalDemo demo = finalDemo; // 3.读对象引用
        int a = demo.a;    //4.读普通域
        int b = demo.b;    //5.读final域
      }
    }
}

假设线程A先执行writer()方法,在此之后线程B执行reader()方法。

注意,这里我们假设"线程A先执行writer()方法,在此之后线程B执行reader()方法",而(在实践中)如果只是将这两个方法分别传入两个Thread对象,这两个线程的先后执行顺序是完全未知的。

因此,在进行以下讨论时,我们暂且认为假设的执行顺序已经得到了保障。

规则

JVM 禁止将final域的写操作,重排序到构造函数之外。这个规则的实现主要包含了两个方面:

  • JMM禁止编译器把final域的写重排序到构造函数之外;
  • 编译器会在final域的写操作之后,构造函数return之前,插入一个storestore屏障。这个屏障可以禁止处理器把final域的写操作,重排序到构造函数之外。

分析

由于a,b之间没有数据依赖性,普通域(普通变量)a可能会被重排序到构造函数之外,线程B就有可能读到普通变量a初始化之前的值(零值),这样就可能出现错误。而对于final域变量b,根据重排序规则,会禁止final修饰的变量b重排序到构造函数之外(意味着变量b能够在FinalDemo对象实例的构造函数执行完成前被赋值),因而线程B就能够读到final变量初始化后的值。

因此,final域写操作的重排序规则可以确保:在对象引用为任意线程可见之前,对象的final域已经被正确初始化过了,而普通域就不具有这个保障

Reference