Java 如何解决可见性、有序性和原子性问题(二)

在上一篇文章中,你可能已经知道了,导致可见性的原因是缓存,导致有序性的原因是编译优化,那解决可见性、有序性最直接的办法就是禁用缓存和编译优化,但是这样问题虽然解决了,我们程序的性能可就堪忧了。

合理的方案应该是按需禁用缓存以及编译优化。那么,如何做到“按需禁用”呢?对于并发程序,何时禁用缓存以及编译优化只有程序员知道,那所谓“按需禁用”其实就是指按照程序员的要求来禁用。所以,为了解决可见性和有序性问题,只需要提供给程序员按需禁用缓存和编译优化的方法即可。

Java 内存模型是个很复杂的规范,可以从不同的视角来解读,站在我们这些程序员的视角,本质上可以理解为,Java 内存模型规范了 JVM 如何提供按需禁用缓存和编译优化的方法。具体来说,这些方法包括 volatile、synchronized 和 final 三个关键字,以及六项 Happens-Before 规则

volatile 解决可见性、有序性问题

以我零散的知识可以知道,被 volatile 修饰的共享变量,读写操作是直接操作内存,相当于禁用了CPU缓存,可以解决可见性问题。很多时候,扯到 volatile ,就得扯一下 Java 的内存模型,我这里就不详细展开。

另外,volatile 还可以解决有序性问题比如上一章中提到的那个创建单例的例子,实际上,只要在 static Singleton instance; 前加个 volatile 关键字,那么这个单例就是安全的了。

final 可定义安全对象,禁止指令重排

final 修饰变量时,初衷是告诉编译器:这个变量生而不变,可以可劲儿优化。Java 编译器在 1.5 以前的版本的确优化得很努力,以至于都优化错了。

public class Singleton {
  final static finalSingleton instance;
  static Singleton getInstance(){
    if (instance == null) {
      synchronized(Singleton.class) {
        if (instance == null)
          instance = new Singleton();
        }
    }
    return instance;
  }
}

问题类似于利用双重检查方法创建单例的例子,构造函数的错误重排导致线程可能看到 final 变量的值会变化(之前 1.5 )
在 1.5 以后,对 final 类型变量的重排进行了约束(禁止指令重排)。所以上面这个例子是安全的。

再举个例子:

public class CommonExample{
    String x;
	public CommonExample(String p) { 
  		x = p;
	}
}

这样的代码在编译的时候,也会被重排序,将赋值操作重排序到构造方法之外,那么也就是,构造方法结束了,初始化完成了,这时候,别的线程先后可能会读取到俩个不同的值,一个没初始化的,一个初始化后的值。要避免这种情况的发生,就要用 fianl。

public class CommonExample{
    final String x;
	public CommonExample(String p) { 
  		x = p;
	}
}

final修饰的变量,可以保证,不会被抽排序到构造方法之外,那么就保证了,只要别的线程拿到该对象CommonExample的引用,那么该对象 fianl 修饰的变量,一定是初始化完成了,避免了上面同一变量,读取值俩次不一样的问题。

使用final需要注意溢出的问题。

“逸出”有点抽象,我们还是举个例子吧,在下面例子中,在构造函数里面将 this 赋值给了全局变量 global.obj,这就是“逸出”,线程通过 global.obj 读取 x 是有可能读到 0 的。因此我们一定要避免“逸出”。

final int x;
// 错误的构造函数
public FinalFieldExample() { 
  x = 3;
  y = 4;
  // 此处就是讲this逸出,
  global.obj = this;
}

在构造方法中将 this 复制给别的变量,意味着,构造方法还没结束,对象 this 就给别人使用了。对象还没初始化完成,别人就使用,这个有点类似于双层校验创建单例的例子。

所以,我们平时使用final关键字时,不要溢出,就不会出问题了。也就是说,所有线程读到的值都是一致的。

Happens-Before 规则解决可见性问题

如何理解呢?它要表达的是:前面一个操作的结果对后续操作是可见的。比如 A Happens-Before B ,意思就是 A 发生在 B 之前,A 的结果,B 可见。Happens-Before 规则就是要保证线程之间的这种“心灵感应”。

比较正式的说法是:Happens-Before 约束了编译器的优化行为,虽允许编译器优化,但是要求编译器优化后一定遵守 Happens-Before 规则。

首先看个例子(很好的一个例子):

1.5 后,假设线程 A 执行 writer() 方法,按照 volatile 语义,会把变量 v=true 写入内存;假设线程 B 执行 reader() 方法,同样按照 volatile 语义,线程 B 会从内存中读取变量 v,如果线程 B 看到 v == true 时,那么线程 B 看到的变量 x 是多少呢?

class VolatileExample {
  int x = 0;
  // 注意:v已经被volatile修饰
  volatile boolean v = false;
  public void writer() {
    x = 42;
    v = true;
  }
  public void reader() {
    if (v == true) {
      // 注意:这里的x只是普通变量
      // 这里x会是多少呢?
    }
  }
}
// 答案:x 就是等于 42

在了解 Happens-Before 规则 之前,我的 Java 基础告诉我,变量 x 没有被 volatile 修饰,只是写在了缓存,可能还没 fresh 到内存。所以这个时候 有两个可能,就是线程 B 看到的变量 x = 0 或者 x = 42

其实,在 1.5 之前确实是这样子的。这个问题在 1.5 版本已经被圆满解决了。Java 内存模型在 1.5 版本对 volatile 语义进行了增强。怎么增强的呢?答案 Happens-Before 规则。

1. 程序的顺序性规则

这条规则是指在一个线程中,按照程序顺序,前面的操作 Happens-Before 于后续的任意操作。这还是比较容易理解的,比如刚才那段示例代码,按照程序的顺序,第 6 行代码 “x = 42;” Happens-Before 于第 7 行代码 “v = true;”,这就是规则 1 的内容,也比较符合单线程里面的思维:程序前面对某个变量的修改一定是对后续操作可见的

//(为方便你查看,我将那段示例代码在这儿再呈现一遍)
class VolatileExample {
  int x = 0;
  volatile boolean v = false;
  public void writer() {
    x = 42;
    v = true;
  }
  public void reader() {
    if (v == true) {
      // 这里x会是多少呢?
    }
  }
}

2. volatile 变量规则

这条规则是指对一个 volatile 变量的写操作, Happens-Before 于后续对这个 volatile 变量的读操作

感觉没啥不一样,但是如果我们关联一下规则 3,就有点不一样的感觉了。

3. 传递性

这条规则是指如果 A Happens-Before B,且 B Happens-Before C,那么 A Happens-Before C

再看上面那个例子,1.5之后。为什么线程 B读到 x 变量的值一定是 42。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-2DlgbTa0-1637076804039)(C:\Users\pengyu\Desktop\我的\b1fa541e98c74bc2a033d9ac5ae7fbe1.jpg)]

从图中,我们可以看到:

  • x = 42 Happens-Before 写变量 v = true ,这是规则 1 的内容;
  • 写变量 v = true Happens-Before 读变量 v = true,这是规则 2 的内容 。

再根据这个传递性规则,我们得到结果:x = 42 Happens-Before 读变量 v = true

这意味着什么呢?

如果线程 B 读到了 v = true,那么线程 A 设置的 x = 42 对线程 B 是可见的。也就是说,线程 B 能看到 x == 42,有没有一种恍然大悟的感觉?

这就是 1.5 版本对 volatile 语义的增强,这个增强意义重大,1.5 版本的并发工具包(java.util.concurrent) 就是靠 volatile 语义来搞定可见性的。

4. 锁的规则

这条规则是指对一个锁的解锁 Happens-Before 于后续对这个锁的加锁

synchronized (this) { //此处自动加锁
  // x是共享变量,初始值=10
  if (this.x < 12) {
    this.x = 12; 
  }  
} //此处自动解锁

可以这样理解:假设 x 的初始值是 10,线程 A 执行完代码块后 x 的值会变成 12(执行完自动释放锁),线程 B 进入代码块时,能够看到线程 A 对 x 的写操作,也就是线程 B 能够看到 x==12。这个也是符合我们直觉的,应该不难理解。

5. 线程 start() 规则

这条是关于线程启动的。它是指主线程 A 启动子线程 B 后,子线程 B 能够看到主线程在启动子线程 B 前的操作

Thread B = new Thread(()->{
  // 主线程调用B.start()之前
  // 所有对共享变量的修改,此处皆可见
  // 此例中,var==77
});
// 此处对共享变量var修改
var = 77;
// 主线程启动子线程
B.start();

6. 线程 join() 规则

如果在线程 A 中,调用线程 B 的 join() 并成功返回,那么线程 B 中的任意操作 Happens-Before 于该 join() 操作的返回

Thread B = new Thread(()->{
  // 此处对共享变量var修改
  var = 66;
});
// 例如此处对共享变量修改,
// 则这个修改结果对线程B可见
// 主线程启动子线程
B.start();
B.join()
// 子线程所有对共享变量的修改
// 在主线程调用B.join()之后皆可见
// 此例中,var==66

互斥锁 解决原子性问题

你已经知道,原子性问题的源头是线程切换。那禁止线程切换?

在单核 CPU 场景下,同一时刻只有一个线程执行,禁止 CPU 中断,意味着操作系统不会重新调度线程,也就是禁止了线程切换,获得 CPU 使用权的线程就可以不间断地执行,所以两次写操作一定是:要么都被执行,要么都没有被执行,具有原子性。(我觉得这样是不现实的,单核 CPU 的多线程在某些场景还是非常的有意义。所以,如果是单核CPU的多线程,没有禁止线程切换,也还是存在原子性问题的)

多核场景下,同一时刻,有可能有两个线程同时在执行,一个线程执行在 CPU-1 上,一个线程执行在 CPU-2 上,此时禁止 CPU 中断,只能保证 CPU 上的线程连续执行,并不能保证同一时刻只有一个线程执行

“同一时刻只有一个线程执行” 这个条件非常重要,我们称之为互斥。如果我们能够保证对共享变量的修改是互斥的,那么,无论是单核 CPU 还是多核 CPU,就都能保证原子性了。

在 Java 中,synchronized 和并发包中的 Lock 都是互斥锁的实现。

比如下面这个例子:count+=1

class SafeCalc {
  long value = 0L;
  long get() {
    return value;
  }
  synchronized void addOne() {
    //受保护资源value
    value += 1;
  }
}

无论是单核 CPU 还是多核 CPU,只有一个线程能够执行 addOne() 方法,所以一定能保证原子操作,那是否有可见性问题呢?要回答这问题,就要重温一下前面提到的管程中锁的规则:对一个锁的解锁 Happens-Before 于后续对这个锁的加锁,所以前一个线程的解锁操作对后一个线程的加锁操作可见。

所以一个锁保护一个受保护资源是可行的。

如何理解这个模型呢?

比如现实世界里面球赛门票的管理,一个座位只允许一个人使用,这个座位就是“受保护资源”,球场的入口就是 Java 类里的方法,而门票就是用来保护资源的“锁”,Java 里的检票工作是由 synchronized 解决的。

那两个锁保护一个资源可行吗?

其实上面的例子还存在一个问题,get() 方法时,value的可读性问题。管程中锁的规则,是只保证后续对这个锁的加锁的可见性,而 get() 方法并没有加锁操作,所以可见性没法保证

看一段代码:

//两个锁保护一个资源value
class SafeCalc {
  static long value = 0L;
  synchronized long get() {
    return value;
  }
  synchronized static void addOne() {
    value += 1;
  }
}

两个锁分别是 this 和 SafeCalc.class。临界区 get() 和 addOne() 是用两个锁保护的,因此这两个临界区没有互斥关系,临界区 addOne() 对 value 的修改对临界区 get() 也没有可见性保证,这就导致并发问题了。

这就类似于一个座位,我们只能用一张票来保护,如果多发了重复的票,那就要打架了,所以是不可能的。

一个锁保护两个资源呢?

答案是可以,这个对应到现实世界就是我们所谓的“包场”了。也就是一张包场的凭证,保护球赛里面的所有座位(多个资源)

所以我们可以得到一个合理的关系是:受保护资源和锁之间的关联关系是 N:1 的关系。

例如我们可以用 this 这一把锁来管理账户类里所有的资源:账户余额和用户密码。具体实现很简单,所有的方法都增加同步关键字 synchronized 就可以了,实例代码如下:

class Account {
  // 锁:保护账户余额
  private final Object balLock
    = new Object();
  // 账户余额  
  private Integer balance;
  // 锁:保护账户密码
  private final Object pwLock
    = new Object();
  // 账户密码
  private String password;

  // 取款
  void withdraw(Integer amt) {
    synchronized(balLock) {
      if (this.balance > amt){
        this.balance -= amt;
      }
    }
  } 
  // 查看余额
  Integer getBalance() {
    synchronized(balLock) {
      return balance;
    }
  }

  // 更改密码
  void updatePassword(String pw){
    synchronized(pwLock) {
      this.password = pw;
    }
  } 
  // 查看密码
  String getPassword() {
    synchronized(pwLock) {
      return password;
    }
  }
}

但是用一把锁锁多个资源有个问题,就是性能太差,会导致取款、查看余额、修改密码、查看密码这四个操作都是串行的。而我们用两把锁,取款和修改密码是可以并行的。用不同的锁对受保护资源进行精细化管理,能够提升性能。这种锁还有个名字,叫细粒度锁

在实际工作编程中,是否通过一个锁来保护多个资源,关键是要分析多个资源之间的关系。如果资源之间没有关系,很好处理,每个资源一把锁就可以了。如果资源之间有关联关系,就要选择一个粒度更大的锁,这个锁应该能够覆盖所有相关的资源

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值