Java 并发编程基础:安全地维护对象的状态

696 阅读11分钟

在之前,我们讨论了同步的最基本话题 —— 如何通过锁或原子变量保护状态。同步还有一个重要却容易被忽略的方面:内存可见性 ( Memory Visibility )。我们不仅希望某个线程正在使用状态时另一个线程同时修改该状态,而且希望确保当一个线程修改了对象状态之后,其它线程能够立刻观察到发生的状态变化。如果没有同步,那么这种情况就无法实现。

可见性

为了提高执行效率,程序读写的数据实际上并非直接来自内存,而是优先来自于 CPU 缓存。比如,两个线程都用到了内存中的某个变量 a,在默认情况下它们均会将 a 的值读入到各自的 CPU 缓存内部。在单线程环境下,我们感知不到这一点,也对此毫不关心。毕竟到目前为止,我们编写过的所有单线程 Java 程序都是符合直觉的 —— 先把值写到一个变量中,然后在没有其它写入操作的情况下再读取它,那么总是会得到相同的值。

cache_memory.png

但在 ( 高 ) 并发环境中,可见性问题就因共享状态 ( 变量 ) 而暴露出来了。在下方的代码块部分中,令 fork 线程负责将 s.flag 状态修改为 true,而主线程反复读取 s.flag 的状态,直到读取到真值时退出。

class NoVisibility {
    // will block without 'volatile'
    private static class Stateful{ boolean flag;}
    public static void main(String[] args)  {
        var s = new Stateful();
        
        // fork thread
        new Thread(()->{
            try {Thread.sleep(50);} catch (InterruptedException e) {}
            s.flag = true;
        }).start();
        
        // main thread
        while (true){
            if(s.flag){System.out.println("finish");break;}
        }
    }
}

这里有意让 fork 线程停顿一会再执行,以保证主线程先读取到 s.flag 的初值并加载到缓存内部。这段代码永远都不会退出,因为主线程一直在高速执行 while 循环,这导致它只从自己的缓存中读取 s.flag 数据,殊不知 s.flag 的新值已被更新并刷到主存了。

这个违背直觉的运行结果就是可见性问题引发的。我们在这里提出两个方案:

首先,内置锁能够确保某个线程以一种可观测的方式查看到另一个线程的执行结果。言下之意,当 fork 线程释放 mutex 锁并被主线程获取时,主线程观察到的一定是修改后的值,如下:

var s = new Stateful();
var mutex = new Object();

new Thread(() -> {
    try {Thread.sleep(50);} catch (InterruptedException e) {}
    // 锁的形式任意,但必须是同一把锁。
    synchronized (mutex) {s.flag = true;}
}).start();

while (true) {
    synchronized (mutex){
        if (s.flag) {
            System.out.println("finish");
            break;
        }
    }
}

我们现在可以知道,加锁的意义不仅仅局限于互斥行为,还包括了内存可见性。为了确保所有的线程都能获取到共享状态的最新值,它们必须在同一个锁上进行同步。

第二种方式,使用 volatile 关键字修饰 Statefulflag 域。

private static class Stateful {volatile boolean flag;}

volatile 关键字会禁止 s.flag 被缓存,这保证了主线程能够立刻观察到 fork 线程的修改结果,从而及时退出循环。

不仅如此,volatile 关键字还有其它的用途。事实上,在没有同步的条件下,编译器,处理器,运行时都会对程序操作的顺序进行一些调整,称之 "指令重排序"。我们同样无法在单线程环境下感知到这一点,因为 Java 保证指令重排序不会影响到程序的正确性。设计这种机制的本意是提高代码的运行效率,但是在多线程环境中,这可能带来一些意想不到的结果。

编译器和运行时不会将 volatile 状态上的操作和其它内存操作一起进行重排序。Java 在字节码层面将 volatile 翻译为内存屏障,并保证位于屏障之后的指令不会被调整到前面。

可以认为 volatile 关键字是一个比加锁更轻量级的同步机制,访问 volatile 状态并不会执行加锁操作,因此不会导致线程阻塞。加锁机制一定能保证可见性和原子性,但是相比之下 volatile 状态只保证可见性。因此,volatile 关键字无法作为同步锁的替代,更不应该被滥用。它只适用于以下场景:

  1. 最多只有一个线程负责对 volatile 状态进行写操作,其它线程均为读操作,或者对 volatile 状态的修改不基于它原有的状态 ( 如取反,自增这类操作都违反了这一点 )。
  2. volatile 状态不被其它的状态依赖 ( 或者说不会和其它状态一同构成不变性条件 )。

当涉及到多个线程对关键字进行 "读 — 改 — 写" 操作时,我们通常使用原子变量来作为 "更好的 volatile"。

发布与逸出

发布 ( publish ) 一个对象,指对象能够在当前作用域之外的代码中被访问。比如:

class Secret {
    private HashMap<String,String> v1;
    public String v2;
    public HashMap<String, String> getV1() {return v1;}
}

v2 是公有属性,外界可以通过 . 操作符直接访问,这是直接发布的形式。而 v1 虽然被声明为是私有属性,但它的引用却可以被公有的 getV1 方法传递出去。

class Secret {
    private HashMap<String,String> v1;
    public String v2;
    public HashMap<String, String> getV1() {return v1;}

    public Secret(String v2) {
        this.v1 = new HashMap<>(Map.of("k1","v1","k2","v2"));
        this.v2 = v2;
    }

    public static void main(String[] args) {
        var secret = new Secret("s");
        secret.v2 = "k";
        var map =secret.getV1();
        map.put("k3","v3");
        System.out.println(secret.getV1());
    }
}

因此,v1v2 事实上全是公开的,这与修饰符无关。同时需要注意到, v1 内部保存的各种状态也顺带着发布了:外部程序仍然可以自由地在 s.v1 内添加键值对。

如果一个对象在不恰当的时机 ( 或者以意料外的方式 ) 被发布,导致它毫无保护地暴露在外部环境并面临着被篡改的风险,这种情况下称之为逸出 ( escape )。尤其在多线程环境下,我们要对这些被发布的内容保持警惕。

其中一个典型是 this 引用逸出,指某个对象在构造之前,它的状态就通过 this 关键字被共享了。比如下面的例子:

class ThisEscape{
    String date;
    public ThisEscape() {
        new Thread(()->{System.out.println(this.date);}).start();
        this.date = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date());
    }
}

构造器内部新创建的线程可以通过 this 观察到这个正被初始化的对象。由于构建日期字符串需要一定的时间,因此该线程很可能拿到空指针 null,但该状态显然等到构造完毕之后就失效了。

解决此问题的一个最简方法,那就是不在构造完成之前通过 this 关键字共享内部状态。比如,将构造器内部的其它动作迁移到 newInstance 方法,然后等到构造完成之后的某个时机再进行调用。总而言之,不要在一个对象的初始化完成之前就泄漏其引用

class ThisEscape{
    String date;

    private ThisEscape() {
        this.date = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date());
    }

    public static ThisEscape newInstance() {
        var ref = new ThisEscape();
        new Thread(()->{System.out.println(ref.date);}).start();
        return ref;
    }
}

线程封闭

避免同步问题的方式之一就是不共享数据。如果仅在单线程内部访问数据,那就不需要同步。这种方式称之为 线程封闭 ( Thread Confinement ),这是实现线程安全性的最简单方式之一。

当某个对象被封闭到一个线程内时,这种用法将自动实现线程安全性,哪怕对象本身并不是线程安全的。如果维护线程封闭完全由程序负责,这种称 Ad-hoc 线程封闭。Ad-hoc 是十分脆弱的,因为 Java 不提供任何修饰符或语法将某个对象封闭到指定的目标线程,一切需要开发者来小心翼翼地进行维护。在某些情况下,由单线程子系统提供的简便性要胜过 Ad-hoc 线程封闭的脆弱性,比如早先版本的 Redis。

对于 volatile 修饰的状态有一种特殊的线程封闭。如果将全局状态的写入操作只交给一个线程去维护,那么它就可以安全地对此状态进行 "读 — 改 — 写" 的操作。在这种情况下,不仅能够避免发生竞态条件,并且 volatile 的可见性还能够保证其它线程能够观察到最新的值。

栈封闭

栈封闭是线程封闭的一个特例。局部变量的一个固有属性就是被封闭在执行线程内部栈帧中,每个方法调用的栈帧都是独立的,局部状态表互不共享。栈封闭比 Ad-hoc 线程封闭更易于维护,可靠性也更高。

public static void localValue(){
    var count = 0;
    for(int i = 0;i<=1;i++){count ++;}
}

public static void localRef(){
    var map = new HashMap<String,String>();
    for(int i = 0,j= 0;
        i<=1;
        i++,j++){
        map.put(String.valueOf(i),String.valueOf(j));
    }
}

对于基本类型的局部变量,如上例中的 count,无论如何都不会破坏栈封闭性,因为任何方法都不能获取到基本类型的引用。而对于引用类型,如上例中的 map,则需要确保不会将 map 以及内部的数据引用共享到外部。

ThreadLocal 类

维持线程封闭的一个现成工具是 ThreadLocal。它将某个类型的值封装起来,并对外提供 getset 等访问方法。然而,每一个线程访问到的均为值的副本。比如:

ThreadLocal<Integer> v = ThreadLocal.withInitial(() -> 0);

new Thread(()->{
    var sum =0;
    for(;v.get()<=10;v.set(v.get()+1)) sum += v.get();
    System.out.printf("t1 sum : %d\n",sum);
}).start();

new Thread(()->{
    var sum =0;
    for(;v.get()<=10;v.set(v.get()+1)) sum += v.get();
    System.out.printf("t2 sum : %d\n",sum);
}).start();

这两个线程都能得到正确的结果,因为它们之间不共享同一个 v 的值。这个典型的应用是:JDBC 的 Connection 本身不是线程安全的,因此将它封装到 ThreadLocal 内,从而保证每个线程仅使用自己的连接。

不变性

避免同步问题的方式之二是使共享的数据不可变。不变的状态可以被任意地共享,且不需要创建保护性的副本。当满足以下条件时,可认为对象是不可变的:

  1. 对象在创建之后就不可更改其引用。
  2. 对象的所有域都是 final
  3. 对象在创建过程中不会发生 this 引用逸出。

Java 提供 final 关键字描述不可变对象,这里特指引用不可变。而诸如 MapList 这类容器,即使它们自身的引用不变,但其内部元素的引用仍然可以改变,访问这样的对象仍然需要同步。Java 的基本数据类型不存在引用一说,因此经 final 修饰后一定是完全不可变的。

Java 内存模型天然保证 final 域初始化的安全性。正如 "除非需要更多的可见性,否则应该将域声明为私有的" 一样,"除非某个域是可变的,否则应将其声明为 final" 也是一个良好的编程习惯。比如:纯粹用于容器的数据结构,它所有的属性均应该是不可变的。

事实上,只要能保证对象在发布之后就不会被更改,哪怕从技术角度来看是可变的,我们仍能够将其认定为事实不可变对象 ( Effectively immutable Object )。但是,Java 不保证事实不可变对象的发布是安全的,这要由开发者自行处理。

安全地共享对象

在多线程环境下,当获取对象的一个引用 ( 或者对象被发布 ) 时,我们应该明确这个对象是只读还是可写的。只读的对象一旦发布之后便可在各个线程中共享,但可写的对象则需要额外引入同步机制。除了使用原子状态,或者 volatile,锁这些已有的同步机制之外,下面列举了其它实用的策略,比如:

  1. 参考线程封闭,将对象某些状态的写权限只交给一个线程维护,而对象本身不需要是多线程安全的。这个思路后来衍生出了基于消息的 Actor 并发模型。
  2. 在静态域中初始化一个对象的引用,该方法借助了 JVM 内部的同步机制:静态初始化器只会在类被首次加载时执行一次。
  3. 内部通过锁等实现安全机制,让这个对象本身就是多线程安全的。
  4. 将对象本身声明为不可变的。

参考链接

Java中的volatile_浮云6363的博客-CSDN博客

JDK源码——volatile类_庄小焱的博客-CSDN博客

Java 引用逃逸那些事 - 云+社区 - 腾讯云 (tencent.com)