前言
《Effective Java》是本很经典的Java语言技术解析的书。它的作者是从事过jdk开发的程序员。所以在本书里,有很多建议是站在api开发者的立场上讲的。普通的Jdk的使用者,包括我在内,可能很少需要遵守这些建议。因为压根碰不到那种场景咯。不过,这也不妨碍我们去了解它所设计的诸多思想和理论。在某些情况下,还是可以提醒我们做正确的事情的。然后,下面我们先一起看下它的第十一章,并发。总共有七个条目,如下
- 78.同步访问共享的可变数据
- 79.避免过度同步
- 80.executor,task和stream优先于线程
- 81.并发工具优先于wait和notify
- 82.线程安全性的文档化
- 83.慎用延迟初始化
- 84.不要依赖于线程调度器
大体来看,前两个条目78和79是讲共享可变数据的同步机制,80和81则是关于jdk并发框架推荐,即不建议你直接操作thread或者使用wait/notify,作者称之为并发汇编语言。82条是总结了前几条,重点强调线程安全性的显性表述。83和84则是提了一下延迟初始化和线程调度器这两个比较少用的技术点。整体的原则是要谨慎使用的。
同步访问共享的可变数据
线程并发,一个关键点就是对于临界区数据的竞争,换个角度就是该可变数据的共享。为了避免安全性失败(safety failure)和活性失败(liveness failure),必须做到同步。
安全性失败,说的是线程安全执行了,但是得到的结果是错误的。
活性失败,说的是线程一直在执行,不能按时给出正确结果。
这里的同步,作者说不能简单的认为是互斥。他认为,同步的意义不仅在于避免一个线程观察到对象处于不一致的状态中,也是要保证进入同步方法或者代码块的每个线程可以看到同一把锁保护的之前所有的修改结果。之后他给出两个案例分别介绍了如果利用sychronized和volatile来避免活性失败从而做到同步共享可变数据。在这个过程中,他提到,对于除long和double之外的变量,java进行读或者写时是原子的。但是如果你在这个变量上进行++运算符操作,就要注意会出现安全性失败了,因为++不是原子操作。这时他建议你使用atom包下的AtomicLong这样的原子类进行类似的计算。该类提供的自增方法是支持原子操作的。
最后作者总结到,尽量把可变的数据限制在单线程中。即不共享可变数据。或者建造高效不可变的对象,就是修改在短时间内进行,然后再与其他线程共享。然后把这个对象安全发布给其他线程。安全发布的方式有保存在静态域,volatile域,final域等,或者放在并发集合里。然后多个线程共享变量时,读和写的操作都必须要同步。
避免过度同步
同步不能没有,但是不能过度。其实我在翻看这本书多次后,能体会到作者对于
技术的谨慎态度。什么技术再好,但是也要考虑他的适用场景,要做到刚好。同步时为了并发,并发又是当前程序发挥cpu高性能的基础操作。但是做同步共享变量还是要小心,能不同步还是尽量不要同步。本来并发是为了提高性能,结果
过度的使用反而降低了性能,甚至导致程序死锁,宕机。这样岂不是搬起石头砸自己的脚了嘛?
作者为了能更清楚说明这一点,在书中给了一个实现了观察者模式的集合包装类。该类允许客户端即调用方将元素添加到集合后收到一个通知。然后通过试图取消一个观察者的方法,来呈现一个死锁的结果。通过这么一个例子,作者其实就是在强调,不要在临界区里做太多的同步操作。尽可能少的操作才能尽可能少的出现问题。
这个时候,作者也给出来的一个更好的解决办法,针对上面这个案例,作者推荐可以使用CopyOnWriteArrayList这个并发集合来实现案例需求。即从尝试实现外部同步换成直接使用满足内部同步的并发集合。这样既方便又安全。最后,作者提了一下,给并发行为写好文档的必要性。
executor,task和stream优先于线程
原始的thread既是工作单元也是执行机制。说白了就是职责太多。这在设计模式中首先就违反的单一职责的原则。对此,jdk提供一个executor framework。这个时候,我们就要提一下jdk的并发包了。如下
java.util.concurrent
jdk的这个并发包主要由三部分组成,Executor Framework,并发集合,同步器。
其中callable和runnable一样是线程的工作单元,Executor和ExecutorService代表执行机制。从ExecutorService基础上更是提供了ThreadPoolExecutor。作为java线程池的基础实现类。关于线程池的内容,可以参见我的另外一篇博客
并发集合则有ConcurrentMap(对应HashMap),BlockingDeque(阻塞队列,线程池重要的元素),ConcurrentLinkedDeque(对应LinkedList),还有上文提到的CopyOnWriteArrayList(对应ArrayList)等。并发集合的好处不言而喻。它可以使你的不支持并发的集合用最小的代价支持并发,而且性能上还很好。当然,这些并发集合也不是刚开始就性能很好。要知道,Collections也是提供了一系列的包装方法去支持集合的同步操作,例如Collections.synchronizedMap。但是这种做法只能做到所谓的外部同步。而并发集合可以在内部使用CAS这样的技术进行更好的并发优化。所以到目前为止,如果可以使用并发集合,就优先使用它们吧。
同步器下个条目会提到,就先不说。至于Stream,这个大家应该不陌生了。它使jdk8后一个对于集合操作的利器。并发上它也是有一定支持的。并发的stream使用Fork-Join池编写的。
可以说,jdk的并发包是对于线程并发操作的更高抽象,到了2020年了,就不要自己手撕线程的并发实现啦!
并发工具优先于wait和notify
这里的说的并发工具其实就是并发包里的同步器和并发集合。并发集合上文我已简单列举过了,同步器列举如下
同步器
比较常用的同步器有CountDownLatch和Semaphore。最不常用的同步器是CyclicBarrier和Exchanger。功能最强大的同步器是Semaphore。然后作者在文中使用CountDownLatch讲了一下为什么使用这些同步器会比你直接写wait/notify要好。从代码量来说,就节省了很多。而且还不容器出错。对于wait/notify的用法,其实像做好,是要考虑较多情况的。是使用notify还是notifyAll也是很有讲究的。虽然说大多数情况下都会建议你直接使用notifyAll。最后作者总结下来,他认为,使用wait/notify就像是使用'汇编语言'去编程。那么诸位Java程序员,肯定会选择更加面向对象的方式进行编程吧。
线程安全性的文档化
这里作者就是完全站在api的提供者角度说的了。上来,他先指出,代码里有synchronized并不足以说明线程安全。线程安全是有多种层次的。所以为了保证一个类被多个线程安全的使用,必须清楚的说明它所支持的线程安全级别
- 不可变(immutable),如 String
- 无条件的线程安全,如AtomicLong
- 有条件的线程安全,如Collections.synchronized包装返回的集合
- 非线程安全,如ArrayList
- 线程对立的
然后作者特别要求我们注意有条件的线程安全类。注意到底怎么获得锁,获得哪把锁。我认为这种类介于线程安全和非线程安全之间,可不可以达到线程安全,取决于使用者对于api的熟练程度。又回到了之前的观点,尽量使用并发容器类。
抛开并发不去谈,正常的业务代码也是要做好文档化的。它是可读性的一种,也是可用性的基础。
慎用延迟初始化
延迟初始化,是指只有到需要域值使才把它初始化的行为。与之对应的,就是正常的初始化。这是一种优化技术,作者建议,除非绝对必要,否则不要这么做。尤其使多线程并发的情况下,共享延迟初始化的域,如果没有做好某种形式的同步,可能会造成很严重的bug。之后,作者给出了三个延迟初始化的通用模式。
- lazy initialization holder class模式,适用于静态域
- 双重检查模式,适用于大多数实例域
- 单重检查模式,适用于可以接受重复初始化的实例域
具体实现大家可以自行查看该书此条目,作者讲的例子还是简单易懂的。
不要依赖于线程调度器
最后,作者提到了线程调度器。即Thread.yield和线程优先级。这里他主要使担心api的可移植性。不知道大家在使用某些api的时候会不会考虑它在不同机器上的表现结果。作者指出,任何依赖于线程调度器来达到正确性或者性能要求的程序,很可能是不可移植的。故,不要把自己程序的正确性依赖上线程调度器上。要知道
线程优先级可以用来提高一个已经正常工作的程序的服务质量,但永远不应该用来“修正”一个原本不能工作的程序。
网友评论