串行的故事
起源是一个硬件的核心矛盾:CPU与内存、I/O的速度差异,系统软件(操作系统、编译器)在解决这个核心矛盾的同时,引入了可见性、原子性和有序性问题,这三个问题就是很多并发程序的Bug之源。剖析问题
Java语言提供了Java内存模型和互斥锁方案来解决这三个问题。
Java内存模型介绍了Java内存模型,以应对可见性和有序性问题;解决原子性问题和如何用一把锁保护多个资源用好互斥锁,来解决原子性问题。
互斥锁是解决并发问题的核心工具,但它也可能会带来死锁问题,引发的死锁的产生原因以及解决方案介绍了死锁的产生原因以及解决方案;同时还引出一个线程间协作的问题,线程间的协作机制,等待-通知介绍了线程间的协作机制:等待-通知。
前六篇文章,更多的是站在微观的角度看待并发问题。安全性、活跃性以及性能问题则是换一个角度,站在宏观的角度重新审视并发编程相关的概念和理论,同时也是对前六篇文章的查漏补缺。
管程介绍的管程,是Java并发编程技术的基础,是解决并发问题的万能钥匙。并发编程里两大核心问题-互斥和同步,都是可以由管程来解决的。学好管程,就相当于掌握了一把并发编程的万能钥匙。
以上,并发编程相关的问题,理论上能找到问题所在,并能给出理论上的解决方法。
Java线程的生命周期、创建多少线程才是合适的、为什么局部变量是线程安全的介绍了线程相关的知识,Java并发编程是要考多线程来实现的,有针对性的学习这部分知识很有必要,包括线程的生命周期、如何计算合适的线程数以及线程内部是如何执行的。
如何用面向对象思想写好并发程序介绍了如何用面向对象思想写好并发程序,因为在Java语言里,面向对象思想能够让并发编程变得更简单。
1.用锁的最佳实践
解决原子性问题和如何用一把锁保护多个资源这两篇文章中,思考题都是关于如何创建正确的锁,而思考题里的做法都是错误的。
1 | class SafeCalc { |
synchronized(new Object()),每次调用方法get()、addOne()都创建了不同的锁,相当于无锁。一个合理的受保护资源与锁之间的关联关系应该是N:1。只有共享一把锁才能起到互斥的作用。
JVM开启逃逸分析之后,synchronized(new Object())这行代码在实际执行的时候会被优化掉,也就是说在真实执行的时候,这行代码压根就不存在。
1 | class Account { |
如何用一把锁保护多个资源的思考题转换成代码,它的核心问题有两点:一个是锁有可能会变化,另一个是Integer和String类型的对象不适合做锁。如果锁发生变化,就意味着失去了互斥功能。Integer和String类型的对象在JVM里面是可能被重用的,除此之外,JVM里可能被重用的对象还有Boolean,重用意味着你的锁可能被其它代码使用,如果其他代码synchronized(你的锁),而且不释放,那你的程序就永远拿不到锁,这是隐藏的风险。
通过这两个反例,可以总结出这样一个基本的原则:锁,应是私有的、不可变的、不可重用的。
最佳实践:
1 | //普通对象锁 |
2.锁的性能要看场景
引发的死锁的产生原因以及解决方案的思考题是比较while(!actr.apply(this, target));这个方法和synchronized(Account.class)的性能哪个更好。
这个要看具体的应用场景,不同的应用场景它们的性能表现是不同的。如果转账操作非常费时,那么前者的性能优势就显示出来了,因为前者允许A->B、C->D这种转账业务的并行。不同的并发场景用不同的方案,这是并发编程里面的一项基本原则;没有通吃的技术和方案,因为每种技术和方案都是优缺点和适用场景的。
3.竞态条件需要格外关注
1 | void addIfNotExist(Vector v, Object o) { |
安全性、活跃性以及性能问题的思考题是一种典型的竞态条件问题。竞态条件问题非常容易被忽略,contains()和add()方法虽然都是线程安全的,但是组合在一起却不是线程安全的。所以程序里如果存在类似的组合操作,一定要小心。
这道思考题的解决方法,可以参考如何用面向对象思想写好并发程序,需要将共享变量v封装在对象的内部,而后控制并发访问的路径,这样就能有效防止对Vector v变量的滥用,从而导致并发问题。
1 | class SafeVector { |
4.方法调用是先计算参数
set(get()+1);方法的调用,是先计算参数,然后将参数压入调用栈之后才会执行方法体,方法调用的过程在为什么局部变量是线程安全的中做了详细的介绍。
1 | while(idx++ < 10000) { |
先计算参数这个事情也是容易被忽视的细节。
1 | logger.debug("The var1: " + var1 + ", var2: " + var2); |
5.InterruptedException异常处理需小心
Java线程的生命周期里的思考题需要注意InterruptedException的处理方式。当调用Java对象的wait()方法或者线程的sleep()方法时,需要捕获并处理InterruptedException异常,本意是通过isInterrupted()检查线程是否被中断了,如果中断了就退出while循环。当其他线程通过调用th.interrupt()来中断th线程时,会设置th线程的中断标志位,从而使th.isInterrupted()返回true,这样就能退出while循环了。
1 | Thread th = Thread.currentThread(); |
这段代码在执行的时候,大部分时间都是阻塞在sleep(100)上,当其他线程通过调用th.interrupt()来中断th线程时,大概率的会触发InterruptedException异常,在触发InterruptedException异常的同时,JVM会同时把线程的中断标志位清除,这个时候th.isInterrupted()返回的是false。
正确的处理方式应该是捕获异常之后重新设置中断标志位。
1 | try { |
6.理论值or经验值
创建多少线程才是合适的的思考题是:经验值为”最佳线程 = 2 * CPU的核数 + 1”。
从理论上来讲,这个经验值一定是靠不住的。但是经验值对于很多“I/O耗时/CPU耗时”不太容易确定的系统来说,是一个很好的初始值。
最佳线程数最终还是靠压测来确定的,实际工作中面临的系统,“I/O耗时/CPU耗时”往往都大于1,所以基本上都是在这个初始值的基础上增加。增加的过程中,应关注线程数是如何影响吞吐量和延迟的。一般来讲,随着线程数的增加,吞吐量会增加,延迟也会缓慢增加;但时当线程数增加到一定程度,吞吐量就会开始下降,延迟会迅速增加。这个时候基本上就是线程能够设置的最大值了。
实际工作中,不同的I/O模型对最佳线程数的影响非常大。如Nginx用的是非阻塞I/O,采用的是多进程单线程结构,Nginx本来是一个I/O密集型系统,但是最佳进程数设置的却是CPU的核数,完全参考的是CPU密集型的算法。理论需要活学活用。