本文转载自微信公众号「怀梦追码」,可以通过以下二维码关注。转载本文请联系怀梦追码公众号。
1. 同步访问共享数据
问题
并发程序要比单线程程序的设计更加复杂,并且失败难以重现。但是又无法避免采用多线程,因为采用多线程并发是能够从多核计算机获得最佳性能的一个有效途径。在并发时,如果涉及到可变数据的时候,就是我们需要着重去思考的地方,在面对可变数据的并发访问的时候,有哪些方式可以保证线程安全性?
答案
- 当一个对象被一个线程修改的时候,可以阻止另一个线程观察到对象内部不一致的状态;
- 同步不仅可以阻止一个线程看到对象处于不一致的状态,还可以保证进入同步方法或者同步代码块的每个线程,都看到由同一个锁保护的之前所有的修改效果。
关键字synchronized:synchronized是保证线程安全的一大利器,它可以保证同一时刻,只有一个线程可以执行某个方法和修改某一个可变数据,但是仅仅将它理解成是互斥的也是不完全正确的,它主要有两种意义:
另外,java语言规范保证读写一个变量是原子的,除非这个变量是double或者long,即使没有在保证同步的情况下也是如此。
考虑到这样一个示例,线程通过轮询标志位而达到优雅的停止线程的功能,示例代码如下:
- private static boolean stopRequested;
- private static synchronized void requestStop() {
- stopRequested = true;
- }
- private static synchronized boolean stopRequested() {
- return stopRequested;
- }
- public static void main(String[] args) throws InterruptedException {
- Thread backgroundThread = new Thread(new Runnable() {
- @Override
- public void run() {
- int i = 0;
- while (!stopRequested()) {
- i++;
- }
- }
- });
- backgroundThread.start();
- TimeUnit.SECONDS.sleep(1);
- requestStop();
- }
可变数据也就是状态变量stopRequested,被同步方法修改,这里也就是保证stopRequested被修改后,能够被其他线程立即可见。
关键字volatile:volatile最重要的功能是能够保证数据可见性,当一个线程修改可变数据后,另一个线程会立刻知道最新的数据。在上面的例子中,因为stopRequested变量的读写本身就是原子的,因此利用synchronized只是利用到它的数据可见性,但是由于synchronized会加锁,如果想性能更优的话,上面的例子就可以采用volatile进行修改:
- private static volatile boolean stopRequested;
- public static void main(String[] args) throws InterruptedException {
- Thread backgroundThread = new Thread(new Runnable() {
- @Override
- public void run() {
- int i = 0;
- while (!stopRequested) {
- i++;
- }
- }
- });
- backgroundThread.start();
- TimeUnit.SECONDS.sleep(1);
- stopRequested = true;
- }
但是需要注意到volatile并不能保证原子性,例如下面的例子:
- private static volatile int nextSerialNumber = 0;
- public static int generateSerialNumber() {
- return nextSerialNumber++;
- }
尽管使用了volatile,但是由于++运算符不是原子的,因此在多线程的时候会出错。++运算符执行两项操作:1、读取值;2、写回新值(相当于原值+1)。如果第二个线程在第一个线程读取旧值和写会新值的时候读取了这个域,就会产生错误,他们会得到相同的SerialNumber。这个时候就需要使用synchorized来使得线程间互斥访问,从而保证原子性。
总结
解决这一问题的最好办法其实是尽量避免在线程间共享可变数据,将可变数据限制在单线程中。如果想要多个线程共享可变数据,那么读写都需要进行同步。
2.慎用创建线程的方式
问题
由于并发程序很容易出现线程安全的问题,并且线程的管理也是件很复杂的事情,所以当创建一个线程时,不要通过Thread的方式手动创建,可以使用Executor框架进行管理。Executor的优点是什么?
答案
- 等待任务执行完成的方式多样:当前线程可以等待提交到executor中的线程集合全部执行完成(invokeAll()或invokeAny()),也可以优雅的等待结束(awaitTermination()),也可以在任务完成时逐个获取这些任务的结果(利用ExecutorCompletionService)等等;
- 创建多种类型的线程池:可以创建单个线程、固定的多个线程以及线程个数可变的线程池,也可以通过ThreadPoolExecutor类创建适合应用场景的线程池;
- 线程和执行间的解耦:使用executor最大的好处在于将线程执行机制和任务解耦开,之前的Thread类既充当了工作单元又是执行机制,更好管理和使用起来更加安全可靠。
结论
在涉及到多线程程序时,不要使用Thread的方式创建线程,应该使用executor来管理和创建线程,它最大的好处在于工作单元(线程)和任务之间的解耦。
3.优先使用并发工具
问题
高并发程序既很难保证线程安全的问题,而且一旦出现问题之后,也很难排错和分析出来原因。而j.u.c包中提供了很多线程安全的工具,应该在实际开发中多使用这些性能已经得到了验证的工具,这使得我们的开发能够十分方便又能保证我们代码的稳定性。常用的并发工具有哪些?
答案
j.u.c包下的并发工具分为三类:1.负责管理线程的executor框架;2.并发集合;3.同步器。其中,负责管理线程的executor在第68条已经说过,不再单独描述。
- 并发集合:并发集合针对标准的集合接口(如List、Queue和Map)做了进一步的处理,提供了高性能的并发实现,常用的有CourrentHashMap,它就扩展了Map接口并保证了线程安全。另外,BlockingQueue实现了可阻塞的操作,即当队列为空的时候,会阻塞“取数据”线程,直至队列不为空位置,当队列满时,会阻塞“插入数据”的线程,直至队列未满。BlockingQueue被广泛的应用在“生产者-消费者”中;
- 同步器:同步器能够完成线程之间的协调,最常用的有CountdownLatch和Semaphore,较不常用的有CyclicBarrier和Exechanger。
结论
j.u.c包下跟我们提供了多种保证线程安全的数据结构,在实际开发中应该使用这些性能和安全性已经得到保证的工具,而不是重复造轮子,并且很难保证安全性。比如,在之前的代码中“生产者-消费者”使用wait和notify的方式去实现,代码就很难维护,如果使用可阻塞操作的BlockingQueue代码更加简洁,逻辑也更加清晰。
4.线程安全文档化
问题
有这样几种错误的说法:
这是两种普遍错误的观点,事实上,线程安全性是有多种级别的,那么,应该如何建立线程安全性的文档?
- 通过查看文档是否出现synchronized修饰符,来判断当前方法是否是安全的。这种说话的错误在于,synchronized并不会通过javadoc输出,成为api文档的一部分,这是因为synchronized是方法具体的实现细节,并不属于导出API和外界模块通信的一部分;
- “只要是加了synchronized关键字的方法或者代码块就一定是线程安全的,而没有加这个关键字的代码就不是线程安全的”。这种观点将synchronized于线程安全等同起来,并且认为线程安全只有两种极端的情况,要么是线程安全的,要么是线程不安全的。
答案
- 不可变的(Immutable):类的实例不可变(不可变类),一定线程安全,如String、Long、BigInteger等。
- 无条件的线程安全(Unconditionally ThreadSafe):该类的实例是可变的,但是这个类有足够的的内部同步。所以,它的实例可以被并发使用,无需任何外部同步,如Random和ConcurrentHashMap。
- 有条件的线程安全(Conditionally ThreadSafe):某些方法需要为了线程安全需要在外部使用的时候进行同步。如Collection.synchronized返回的集合,对它们进行迭代时就需要外部同步。如下代码,当对synchronizeColletcion返回的 collection进行迭代时,用户必须手工在返回的 collection 上进行同步,不遵从此建议将导致无法确定的行为:
- Collection c = Collections.synchronizedCollection(myCollection);
- synchronized(c) {
- Iterator i = c.iterator(); // Must be in the synchronized block
- while (i.hasNext())
- foo(i.next());
- }
- 非线程安全(UnThreadSafe):该类是实例可变的,如需安全地并发使用,必须外部手动同步。如HashMap和ArrayList;
- 线程对立的(thread-hostile):即便所有的方法都被外部同步保卫,这个类仍不能安全的被多个线程并发使用。这种类或者方法非常少,比如System.runFinalizersOnExit方法是线程队里的,但已经废除了。
- 线程的安全性级别:
- 在文档中描述有条件的线程安全类要特别小心,必须指明哪个调用方法需要外部同步,并且需要获得哪一把锁;
- 如果使用类使用的是“一个可公有访问的锁对象”的话,很可能被其他线程超时地保持公有可访问锁,而造成当前线程一直无法获得锁对象,这种行为被称为“拒绝服务攻击”,为了避免这种攻击可以采用 私有锁对象,例如:
- private final Object lock = new Object();
- public void foo(){
- synchronized(lock){
- ...
- }
- }
这时,私有锁对象只能被当前类内部访问到,并不能被外部访问到,因此不可能妨碍到当前类的同步,就可以避免“拒绝服务攻击”。但是,这种方式只适合“无条件线程安全”级别,并不能适用于“有条件性的线程安全”的级别,有条件的线程安全级别,必须在文档中说明,在调用方法时应该获得哪把锁。
总结
每个类都应该利用严谨的说明或者线程安全注解,清楚地在文档中说明它的线程安全属性。有条件的线程安全类,应该说明哪些方法需要同步访问,以及获得哪把锁。无条件的线程安全类可以采用私有锁对象来防止“拒绝服务攻击”。涉及到线程安全的问题,应该严格按照规范编写文档。
5.慎用延迟初始化
- 问题
延迟初始化(lazy initialization)是延迟到需要域的值时才将它初始化的这种行为。如果永远不需要这个值,这个域就永远不会被初始化。这种方法既适用于静态域,也适用于实例域。和大多数优化一样,不成熟的优化是大部分错误的源头。那么针对线程安全的延迟初始化有哪些可靠的方式?
- 答案
下面是正常初始化实例域的方式,但是要注意采用了final修饰符:
- private final FildType field= computeFieldValue();
现在要对这个实例域进行延迟初始化,有这样几种方式:
同步方法:在实例化域值得时候,可以使用同步方法从而保证线程安全性,如:
- private FieldType field;
- synchronized FieldType getField(){
- if(field == null){
- field = computeFieldValues();
- }
- return field;
- }
静态内部类:为了减小上面这种方式的同步访问成本,可以采用静态内部类的方式,被称之为lazy initialization holder class 模式。在jvm的优化下,这种方式不仅可以达到延迟初始化的效果,也能保证线程安全。示例代码为:
- private static class FieldHolder{
- static final FieldType field = computeFieldValue();
- }
- static FieldType getField(){
- return FieldType.field;
- }
双重检测:这种模式避免了在初始化之后,再次访问这个域时的锁定开销(在普通的方法里面,会使用synchronized对方法进行同步,每次访问方法的时候都要进行锁定)。这种模式的思想是:两次检查域的值,第一次检查时不锁定,看看其是否初始化;第二次检查时锁定。只用当第二次检查时,表明其没有被初始化,才会调用computeFieldValue方法对其进行初始化。如果已经被初始化了,就不会锁定了,另外该域被声明为volatile非常重要,示例代码为:
- private volatile FieldType field;
- public FieldType getField() {
- FieldType result = field;
- if (result == null) {
- synchronized (this) {
- result = field;
- if (result == null) {
- field = result = computeFieldValue();
- }
- }
- }
- return result;
- }
结论
大多数正常的初始化都要优于延迟初始化。如果非要进行延迟初始化的话,针对实例域采用双重检测方式,针对静态域,可以利用静态内部类的第一次访问才进行初始化的特性,使用静态内部类来完成延迟初始化。
6.不要依赖线程调度器
- 问题
当有多个线程运行时,由线程调度器决定哪些线程将会运行,分配CPU时间片。但是,在大多数系统采用的调度策略都是不太相同的,因此,任何依赖于线程调度器来达到程序性能和正确性的并发程序都是不安全和不可移植的。那么,在编写可移植的,健壮性强的并发程序有哪些好的方法?
- 答案
- 最好的方式是,保证可运行的线程尽可能少,或不明显高于处理器的数量。如果,可运行的线程足够少,对线程调度器而言就不需要“纠结”为哪个线程分配时间片,只需要让多核处理器处理这些线程就好了。从侧面来说,就降低了对线程调度器的调度策略的依赖。那么,保证尽可能少的线程数唯一的方法就是,让每个线程都做有意义任务,从整体而言,就会降低总线程的个数;
- 当程序不正确的时候,是因为线程无法获得足够的时间片的话,不要企图使用Thread.yield的方式,让其他线程让出时间片,来满足自身的需求。这是因为,不同的JVM上对Thread.yield语义的是不相同的,这样就失去了可移值性。另外,在测试期间,使用Thread.yield人为地来增加线程并发性,应该由Thread.sleep(1)来代替Thread.yield;
- 千万不要企图通过调整线程优先级来达到程序的正确性,线程的优先级是最不可移植的特性。
- 结论
千万不能让程序依赖线程调度器,这样会失去健壮性和可移植性。而Thread.yield和线程优先级这些特性,是最不具有可移植性,程序中不应该使用它们。
7.避免使用线程组
- 问题
除了线程、锁和监视器外,线程系统还提供了另外一个抽象单元:线程组。线程组的设计初衷是作为隔离applet的机制,达到安全性。但是,实际上并未达到所期待的安全性,甚至都差到在JAVA安全模型上都未提及。除了安全性的糟点外,还有哪些缺陷?
- 答案
除了安全性没有达到预期外,可用的基本功能很少;
ThreadGroup的API非常脆弱;
- 结论
线程组并没有提供太多有用的功能,而且它们提供的许多功能还都是有缺陷的。当管理线程或处理线程组逻辑时,应该考虑使用executor。