上篇文章中提到了可以用 "多线程版本的if" 来理解 Guarded Suspension 模式,与单线程的 if 不同的是多线程版本的 if 需要等待,并且需要一直等待到条件满足时才继续进行,但是不是每个场景都需要这么执着的,有时候我们还需要快速放弃

常见的需要快速放弃的场景就是「编辑器提供的自动保存功能」。自动保存功能的实现逻辑一般都是相隔一定的时间自动执行存盘操作,存盘操作的前提是文件被u修改过,如果文文件没有执行过修改,则需要快速放弃存盘操作。

下面是示例代码:

public class AutoSavedEditor {
    // 文件是否被修改过
    boolean changed = false;
    // 定时任务线程池
    ScheduledExecutorService ses = Executors.newSingleThreadScheduledExecutor();

    // 定时自动执行保存
    void startAutoSave() {
        ses.scheduleWithFixedDelay(() -> autosave(), 5, 5, TimeUnit.SECONDS);
    }

    private void autosave() {
        // 如果没有修改,则快速返回
        if (!changed) {
            return ;
        }
        changed = false;
        // 执行存盘操作,省略具体逻辑
    }

    // 编辑操作
    void edit() {
        // 省略具体逻辑
        changed = true;
    }
}

可以看到 AutoSavedEditor 是非线程安全的,因为共享变量 changed 的读写并没有使用同步机制保证互斥,如何修改呢?

最简单的方法:给读写方法加互斥锁,但是这样对性能的影响比较严重,因为锁的范围太大了,将整个方法变成了串行方法,所以可以进一步将锁的范围缩小,使用细粒度的锁,保证只在读写共享变量 changed 的地方加锁,代码如下:

// 自动存盘操作
void autoSave() {
  synchronized(this) {
    if(!changed) {
      return;
    }
    changed = false;
  }
  // 执行存盘操作逻辑
  this.execSave();
}

void edit() {
  //省略编辑逻辑
  //...
  synchronized(this) {
    changed = true;
  }
}


示例中的共享变量是一个状态变量业务逻辑依赖这个状态变量的值:当状态满足某个条件时,执行某个业务逻辑,其本质就是一个 if,在多线程环境下,就是一种"多线程版本的 if"。这种"多线程版本的 if" 的应用场景很多,所以也被总结成了一种设计模式:Balking 模式

Balking 模式的经典实现

Balking 模式本质上是一种规范化解决"多线程 if"的方案,对于上面自动保存的例子,使用 Balking 模式规范化之后的写法如下:

boolean changed = false;
// 自动存盘操作
void autoSave() {
  synchronized(this) {
    if(!changed) {
      return;
    }
    changed = false;
  }
  // 执行存盘操作
  this.execSave();
}

// 编辑操作
void edit() {
  // 省略编辑操作
  // ...
  change();
}
// 改变共享变量的方法
void change() {
  synchronized(this) {
    changed = true;
  }
}

可以看到仅仅将 edit() 方法中对共享变量 changed 的赋值操作抽取到了单独的 change() 方法中,这样的好处是将并发逻辑和业务逻辑分开。

用 volatile 实现 Balking 模式

前面实现 Balking 模式使用的是互斥锁 synchronized ,这是最稳妥的实现方式,因为既可以保证原子性,又可以保证可见性,建议在实际工作中也选择这个方案。

但是在某些特定场景下,也可以使用 volatile 实现 Balking 模式,前提就是对原子性没有要求。【因为 volatile 无法保证原子性,只能保证可见性】

{% post_link 读书笔记/极客时间/Java并发编程实战/第三部分—并发设计模式/29|CopyOnWrite 29 | Copy-on-Write 模式 %} 中,有一个 RPC 框架路由表的案例:

在 RPC 框架中,本地路由表要和注册中心进行信息同步,应用启动的时候,将应用依赖服务的路由表从注册中心同步到本地路由表中。如果应用重启的时候注册中心宕机,会导致该应用依赖的服务均不可用,因为找不到依赖服务的路由表。

为了防止这种极端情况出现, RPC 框架可以将本地路由表自动保存到本地文件中,如果重启时注册中心宕机,则从本地恢复重启前的路由表,这其实也是一种降级方案。

自动保存路由表和前面介绍的编辑器自动保存的原理一样,也可以使用 Balking 模式实现,但是在这里我们采用 volatile 关键字来实现,代码如下:

/**
 * @author XuYanXin
 * @program javaconcurrency_learn
 * @description 使用 volatile 关键字实现的 Balking 模式的 路由表类
 * @date 2020/8/19 10:05 下午
 */
// 路由表信息
public class RouterTable {
    // Key:接口名
    // Value: 路由集合
    ConcurrentHashMap<String, CopyOnWriteArraySet<Router>> rt = new ConcurrentHashMap<>();

    // 路由表是否发生变化的状态判断 flag
    volatile boolean changed;

    // 将路由表写入本地文件的线程池
    ScheduledExecutorService ses = Executors.newSingleThreadScheduledExecutor();

    // 启动定时任务,将变更后的路由表写入本地文件
    public void startLocalServer() {
        ses.scheduleWithFixedDelay(() ->
                autoSave(), 1, 1, TimeUnit.SECONDS);
    }

    // 将路由表保存到本地文件的业务逻辑
    void autoSave() {
        if (!changed) {
            return;
        }
        changed = false;
        // 将路由表写入本地文件,省略其方法实现
        this.save2Local();
    }

    // 删除路由
    public void remove(Router router) {
        CopyOnWriteArraySet<Router> set = rt.get(router.iface);

        if (set != null) {
            set.remove(router);

            // 此时路由表已经发生变化
            changed = true;
        }
    }

    // 增加路由
    public void add(Router router) {
        Set<Router> set = rt.computeIfAbsent(router.iface, r -> new CopyOnWriteArraySet<>());
        set.add(router);
        // 路由表已经发生变化
        changed = true;
    }
}

之所以这里可以使用 volatile ,是因为对共享变量 changedrt 的写操作不存在原子性要求,并且使用 scheduleWithFixedDelay 这种调度方式能保证同一时刻只有一个线程执行 atuoSave() 方法,所以不需要 synchronized 的互斥性。

Balking 模式有一个非常典型的应用场景 —— 单次初始化,下面是示例代码:

class InitTest{
  boolean inited = false;
  synchronized void init(){
    if(inited) {
      return;
    }
    // 生路 doInit 初始化的实现
    doInit();
    inited =true;
  }
}

在上面的实现中,将 init() 声明为了一个同步方法,这样同一时刻只有一个线程可以执行 init() 方法,并且执行这个方法的过程中会将是否初始化的标志 inited 置为 true,这样后续调用 init() 的方法不会再次执行 doInit()。

线程安全的单例模式本质上也是单次初始化,所以可以使用 Balking 模式来实现线程安全的单例类。

示例代码:

public class Singleton {
    private static Singleton singleton;
    // 私有化构造函数
    private Singleton() {
        
    }
    
    // 单例的获取 Singleton 实例的方法
    public synchronized  static Singleton getInstance() {
        if (singleton == null) {
            singleton = new Singleton();
        }
        return singleton;
    }
}

上面的代码使用互斥锁实现了单例类的创建,但是基本和互斥锁沾边都会存在性能问题,上边的示例代码中获取该类的实例对象因为互斥锁的存在变成了串行方法。

下面是优化后的代码,思路是使用经典的**双重检查(Double Check)**方案:


public class Singleton {
    // 使用 volatile 解决变量可见性问题
    private static  volatile Singleton singleton;
    // 私有化构造函数
    private Singleton() {

    }
    // 单例的获取 Singleton 实例的方法
    public   static Singleton getInstance() {
        // 第一次检查
        if (singleton == null) {
            synchronized (Singleton.class){
                // 获取锁后的二次检查
                if (singleton == null) {
                    singleton = new Singleton();
                }
            }
        }
        return singleton;
    }
}

在双重检查方案中,一旦 Singleton 对象被成功创建之后,就不会执行 synchronized(Singleton.class){} 相关的代码,所以这时 getInstance() 方法相当于无锁,解决了性能问题。

这里需要注意的是,使用 volatile 保证了 Singleton 变量的可见性,因为被 volatile 修饰的变量不会被编译器重排序,导致可见性问题,相关内容看 {% post_link 读书笔记/极客时间/Java并发编程实战/第一部分—基础/01|可见性、原子性和有序性问题:并发编程Bug的源头 01|可见性、原子性和有序性问题:并发编程Bug的源头 %}

获取锁后的二次检查,是为了确保安全性,因为在获取锁的间隙可能已经有别的线程成功创建了实例。

总结

Balking 模式 和 Guarded Suspension 模式从实现上看似乎没有多大关系,Balking 模式只需要使用 互斥锁就能解决,Guarded Suspension 则需要用到管程这种高级的并发原语。

但是从应用角度看,这两个模式解决的都是 "线程安全的 if" 问题,不同之处在于 Guarded Suspension 需要等待 if 条件为真之后再进行Balking 不需要等待。

Balking 模式的经典实现是使用互斥锁,可以使用 synchronized关键字,也可以用 JDK 实现的 Lock类,如果对互斥锁的性能不满意,也可以使用 volatile,但是需要确定好使用场景,因为 volatile 没有原子性语义

也可以尝试使用双重检查优化性能

  • 双重检查中的第一次检查是完全出于对性能的考量:避免执行加锁操作,因为加锁操作很耗时。
  • 第二次检查则是对安全性负责

双重检查方案在优化加锁性能方面经常用到,例如 {% post_link 读书笔记/极客时间/Java并发编程实战/第二部分—并发工具类/17|ReadWriteLock 17|ReadWriteLock:如何快速实现一个完备的缓存 %} 中实现按需加载功能时,也使用到了双重检查方案。

Q.E.D.

知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议

最是人间留不住,曾是惊鸿照影来。