Java并发编程:12篇【干货】打包带走

到这里,并发编程的理论基础、已经讲解完了,总共 12 篇,不算少,但“跳出来,看全景”你会发现这 12 篇的内容基本上是一个“串行的故事”。

12 | 并发编程:如何用面向对象思想写好并发程序?

11 | Java线程(下):为什么局部变量是线程安全的?

10 | Java线程(中):创建多少线程才是合适的?

09 | Java线程(上):Java线程的生命周期

08-管程:并发编程的万能钥匙

07-并发编程:安全性、活跃性以及性能问题

06 | 用“等待-通知”机制优化循环等待

05 | 一不小心就死锁了,怎么办?

04 | 互斥锁(下):如何用一把锁保护多个资源?

03 | 互斥锁(上):解决原子性问题

02 | Java内存模型:看Java如何解决可见性和有序性问题

01 | 可见性、原子性和有序性问题:并发编程Bug的源头

并发编程“串行的故事”

起源是一个硬件的核心矛盾:CPU 与内存、I/O 的速度差异,出现了CPU缓存,系统软件(操作系统、编译器)在解决这个核心矛盾的同时,引入了可见性、原子性和有序性问题;

简言之就是,导致可见性的原因是CPU缓存,导致原子性问题的原因是线程切换,导致有序性的原因是编译优化。

这三个问题就是很多并发程序的 Bug 之源。这就是01 | 可见性、原子性和有序性问题:并发编程Bug的源头 的内容。

那如何解决这三个问题呢?Java 语言自然有招儿,它提供了 Java 内存模型和互斥锁方案。所以,在 02 | Java内存模型:看Java如何解决可见性和有序性问题 我们介绍了 Java 内存模型,以应对可见性和有序性问题;

那另一个原子性问题该如何解决?多方考量用好互斥锁才是关键,这就是 03 | 互斥锁(上):解决原子性问题04 | 互斥锁(下):如何用一把锁保护多个资源?的内容。

虽说互斥锁是解决并发问题的核心工具,但它也可能会带来死锁问题,所以 05 | 一不小心就死锁了,怎么办?就介绍了死锁的产生原因以及解决方案;同时还引出一个线程间协作的问题,这也就引出了06 | 用“等待-通知”机制优化循环等待这篇文章的内容,介绍线程间的协作机制:wait/notify 等待 - 通知。

你应该也看出来了,前六篇文章,我们更多地是站在微观的角度看待并发问题。而 07-并发编程:安全性、活跃性以及性能问题则是换一个角度,站在宏观的角度重新审视并发编程相关的概念和理论,同时也是对前六篇文章的查漏补缺。

08-管程:并发编程的万能钥匙 介绍的管程,是 Java 并发编程技术的基础,是解决并发问题的万能钥匙。并发编程里两大核心问题——互斥和同步,都是可以由管程来解决的。所以,学好管程,就相当于掌握了一把并发编程的万能钥匙。

至此,并发编程相关的问题,理论上你都应该能找到问题所在,并能给出理论上的解决方案了。

而后在09 | Java线程(上):Java线程的生命周期10 | Java线程(中):创建多少线程才是合适的?11 | Java线程(下):为什么局部变量是线程安全的?我们又介绍了线程相关的知识,毕竟 Java 并发编程是要靠多线程来实现的,所以有针对性地学习这部分知识也是很有必要的,包括线程的生命周期、如何计算合适的线程数以及线程内部是如何执行的。

最后,在12 | 并发编程:如何用面向对象思想写好并发程序?我们还介绍了如何用面向对象思想写好并发程序,因为在 Java 语言里,面向对象思想能够让并发编程变得更简单。

image

经过这样一个简要的总结,相信你此时对于并发编程相关的概念、理论、产生的背景以及它们背后的关系已经都有了一个相对全面的认识。至于更深刻的认识和应用体验,还是需要你“钻进去,看本质”,加深对技术本身的认识,拓展知识深度和广度。

一些文章留了思考题,这些思考题虽然大部分都很简单,但是隐藏的问题却很容易让人忽略,从而不经意间就引发了 Bug;再加上留言区的一些热门评论,所以我想着将这些隐藏的问题或者易混淆的问题,做一个总结也是很有必要的。

1. 用锁的最佳实践

思考题的示例代码如下,synchronized (new Object()) 这行代码很多同学已经分析出来了,每次调用方法 get()、addOne() 都创建了不同的锁,相当于无锁。这里需要你再次加深一下记忆,“一个合理的受保护资源与锁之间的关联关系应该是 N:1”。只有共享一把锁才能起到互斥的作用。

另外,很多同学也提到,JVM 开启逃逸分析之后,synchronized (new Object()) 这行代码在实际执行的时候会被优化掉,也就是说在真实执行的时候,这行代码压根就不存在。不过无论你是否懂“逃逸分析”都不影响你学好并发编程,如果你对“逃逸分析”感兴趣,可以参考一些 JVM 相关的资料。

重用对象不适合加锁

04 | 互斥锁(下):如何用一把锁保护多个资源?的思考题转换成代码,是下面这个样子。


class Account {
  // 账户余额  
  private Integer balance;
  // 账户密码
  private String password;
  // 取款
  void withdraw(Integer amt) {
    synchronized(balance) {
      if (this.balance > amt){
        this.balance -= amt;
      }
    }
  } 
  // 更改密码
  void updatePassword(String pw){
    synchronized(password) {
      this.password = pw;
    }
  } 
}

它的核心问题有两点:一个是锁有可能会变化,另一个是 Integer 和 String 类型的对象不适合做锁。如果锁发生变化,就意味着失去了互斥功能。Integer 和 String 类型的对象在 JVM 里面是可能被重用的,除此之外,JVM 里可能被重用的对象还有 Boolean,那重用意味着什么呢?意味着你的锁可能被其他代码使用,如果其他代码 synchronized(你的锁),而且不释放,那你的程序就永远拿不到锁,这是隐藏的风险。

通过这两个反例,我们可以总结出这样一个基本的原则:锁,应是私有的、不可变的、不可重用的。

我们经常看到别人家的锁,都长成下面示例代码这样:

// 普通对象锁
private final Object lock = new Object();
// 静态对象锁
private static final Object lock = new Object(); 

这种写法貌不惊人,却能避免各种意想不到的坑,这个其实就是最佳实践。最佳实践这方面的资料推荐你看《Java 安全编码标准》这本书,研读里面的每一条规则都会让你受益匪浅。

2. 锁的性能要看场景

05 | 一不小心就死锁了,怎么办?的思考题是比较while(!actr.apply(this, target));这个方法和synchronized(Account.class)的性能哪个更好。

这个要看具体的应用场景,不同应用场景它们的性能表现是不同的。在这个思考题里面,如果转账操作非常费时,那么前者的性能优势就显示出来了,因为前者允许 A->B、C->D 这种转账业务的并行。不同的并发场景用不同的方案,这是并发编程里面的一项基本原则;没有通吃的技术和方案,因为每种技术和方案都是优缺点和适用场景的。

3. 竞态条件需要格外关注

07-并发编程:安全性、活跃性以及性能问题 里的思考题是一种典型的竞态条件问题(如下所示)。

void addIfNotExist(Vector v, 
    Object o){
  if(!v.contains(o)) {
    v.add(o);
  }
}

竞态条件问题非常容易被忽略,contains() 和 add() 方法虽然都是线程安全的,但是组合在一起却不是线程安全的。所以你的程序里如果存在类似的组合操作,一定要小心。

这道思考题的解决方法,可以参考12 | 并发编程:如何用面向对象思想写好并发程序?,你需要将共享变量 v 封装在对象的内部,而后控制并发访问的路径,这样就能有效防止对 Vector v 变量的滥用,从而导致并发问题。你可以参考下面的示例代码来加深理解。

class SafeVector{
  private Vector v; 
  // 所有公共方法增加同步控制
  synchronized 
  void addIfNotExist(Object o){
    if(!v.contains(o)) {
      v.add(o);
    }
  }
}

4. 方法调用是先计算参数

有对07-并发编程:安全性、活跃性以及性能问题 文中所举的例子有疑议,认为set(get()+1);这条语句是进入 set() 方法之后才执行 get() 方法,其实并不是这样的。方法的调用,是先计算参数,然后将参数压入调用栈之后才会执行方法体,方法调用的过程在11这篇文章中我们已经做了详细的介绍,你可以再次重温一下。

while(idx++ < 10000) {
  set(get()+1);   
}

先计算参数这个事情也是容易被忽视的细节。例如,下面写日志的代码,如果日志级别设置为 INFO,虽然这行代码不会写日志,但是会计算"The var1:" + var1 + ", var2:" + var2的值,因为方法调用前会先计算参数。

logger.debug("The var1:" + 
  var1 + ", var2:" + var2);

更好地写法应该是下面这样 :

logger.debug("The var1:" + var1 + ", var2:" + var2);

这种写法仅仅是讲参数压栈,而没有参数的计算。使用{}占位符是写日志的一个良好习惯。

5. InterruptedException 异常处理需小心

09 | Java线程(上):Java线程的生命周期 的思考题主要是希望你能够注意 InterruptedException 的处理方式。

Thread th = Thread.currentThread();
while(true) {
  if(th.isInterrupted()) {
    break;
  }
  // 省略业务代码无数
  try {
    Thread.sleep(100);
  }catch (InterruptedException e){
    e.printStackTrace();
  }
}

当你调用 Java 对象的 wait() 方法或者线程的 sleep() 方法时,需要捕获并处理 InterruptedException 异常,在思考题里面(如下所示),本意是通过 isInterrupted() 检查线程是否被中断了,如果中断了就退出 while 循环。当其他线程通过调用th.interrupt().来中断 th 线程时,会设置 th 线程的中断标志位,从而使th.isInterrupted()返回 true,这样就能退出 while 循环了。

这看上去一点问题没有,实际上却是几乎起不了作用。原因是这段代码在执行的时候,大部分时间都是阻塞在 sleep(100) 上,当其他线程通过调用th.interrupt().来中断 th 线程时,大概率地会触发 InterruptedException 异常,在触发 InterruptedException 异常的同时,JVM 会同时把线程的中断标志位清除,所以这个时候th.isInterrupted()返回的是 false。

正确的处理方式应该是捕获异常之后重新设置中断标志位,也就是下面这样:

try {
  Thread.sleep(100);
}catch(InterruptedException e){
  // 重新设置中断标志位
  th.interrupt();
}

6. 线程数目:理论值 or 经验值

10 | Java线程(中):创建多少线程才是合适的?的思考题是:经验值为“最佳线程 =2 * CPU 的核数 + 1”,是否合理?

从理论上来讲,这个经验值一定是靠不住的。但是经验值对于很多“I/O 耗时 / CPU 耗时”不太容易确定的系统来说,却是一个很好到初始值。

我们曾讲到最佳线程数最终还是靠压测来确定的,实际工作中大家面临的系统,“I/O 耗时 / CPU 耗时”往往都大于 1,所以基本上都是在这个初始值的基础上增加。增加的过程中,应关注线程数是如何影响吞吐量和延迟的。一般来讲,随着线程数的增加,吞吐量会增加,延迟也会缓慢增加;但是当线程数增加到一定程度,吞吐量就会开始下降,延迟会迅速增加。这个时候基本上就是线程能够设置的最大值了。

实际工作中,不同的 I/O 模型对最佳线程数的影响非常大,例如 Nginx 用的是非阻塞 I/O,采用的是多进程单线程结构,Nginx 本来是一个 I/O 密集型系统,但是最佳进程数设置的却是 CPU 的核数,完全参考的是 CPU 密集型的算法。所以,理论我们还是要活学活用。

总结

这个模块,内容主要聚焦在并发编程相关的理论上,但是思考题则是聚焦在细节上,我们经常说细节决定成败,在并发编程领域尤其如此。理论主要用来给我们提供解决问题的思路和方法,但在具体实践的时候,还必须重点关注每一个细节,哪怕有一个细节没有处理好,都会导致并发问题。这方面推荐你认真阅读《Java 安全编码标准》这本书,如果你英文足够好,也可以参考https://wiki.sei.cmu.edu/confluence/display/java/2+Rules

欢迎关注 公众号 架构道与术(ToBeArchitecturer),学习更多干货~

image

12篇全文,路过请打包带走