Effective Java 3rd Edition — 第十一章 并发编程

Item 78 : Synchronize access to shared mutable data

共享可变数据需要同步

synchronized 关键字可以保证同时只有一个线程可以执行或者阻塞该方法;
除了long和double之外,Java语言特性都能保证对变量的读写操作是原子的;
long和double读写要实现原子操作,可以volatile关键字来实现,或者使用AtomicLong来实现;

线程之间的可靠通信和互斥是需要同步的,这是Java内存模型规范的一部分,规定了一个线程所作的修改合适以及如何对其他线程可见。

从线程中停止另一个线程Java提供了Thread.stop方法,但是不要使用这个方法,因为其本身是不安全的,正确的方法是使用一个boolean值作为标志来进行操作,比如下面的代码:

// Broken! - How long would you expect this program to run? 
public class StopThread {
	private static boolean stopRequest;
	public static void main(String[] args) throws InterruptedException {
		Thread backgroundThread = new Thread(() -> {
			int I = 0;
			while (!stopRequested)
				i++;
		});
		backgroundThread.start();
		TimeUnit.SECONDS.sleep(1);
		stopRequested = true;
	}
}

上面代码预期是在程序启动1s之后停止后台线程,但事实上程序和后台线程都不会停止,因为缺少了同步操作,下面的代码是其中一中同步方式:

// Properly synchronized cooperative thread termination
public class StopThread {
	private static boolean stopRequest;
	private static synchronized void requestStop() {
		stopRequest = true;
	}
	private static synchronized void stopRequested() {
		return stopRequested;
	}
	public static void main(String[] args) throws InterruptedException {
		Thread backgroundThread = new Thread(() -> {
			int I = 0;
			while (!stopRequested)
				i++;
		});
		backgroundThread.start();
		TimeUnit.SECONDS.sleep(1);
		requestStop();
	}
}

如果不能保证读写都同步,那么同步并不能正常的工作;或者可以使用volatile关键字来修饰变量,保证变量的修改能够被其他线程及时读取到:

// Cooperative thread termination with a volatile field 
public class StopThread {
	private static volatile boolean stopRequest;
	public static void main(String[] args) throws InterruptedException {
		Thread backgroundThread = new Thread(() -> {
			int I = 0;
			while (!stopRequested)
				i++;
		});
		backgroundThread.start();
		TimeUnit.SECONDS.sleep(1);
		stopRequested = true;
	}
}

在使用volatile关键字的时候要小心,他只能保证线程间的变量修改可见性,并不能保证原子性;
最好的就是不要在线程间共享可变资源,也就是说在单线程中处理可变资源。
当多线程共享资源的时候需要对资源的读写操作进行同步,通常没有进行同步造成的bug都非常难以debug和排查,
volatile关键字能够保证多线程间的数据可见性,但是他不保证原子性,使用的时候需要多加注意。

Item 79 : Avoid excessive synchronization

避免过多同步

不要在同步块中设计可以被覆盖的方法,或者由客户端提供的一个方法,因为无法控制该方法被覆盖后的操作;作为规范,不要在同步块中做过多的操作,如果非要在同步块中进行耗时的操作,尽量在不破坏原有逻辑的情况下将耗时操作放到同步块之外。

在多核的情况下,消耗时间不是cpu获取锁的时间,而是对资源的竞争和保证每个核心享有相同的内存对象(对象同步);过度的同步同时也会限制jvm优化执行代码的能力;当不知道是否需要对类进行同步时,那就不要做任何同步,并且做出说明该类时线程不安全的。

总的来说,为了避免死锁和数据中断,尽量不要在同步块中调用耗时的方法,同时应该将这种耗时逻辑放到同步块之外,保证同步块中的逻辑最小化;同时在设计一个不变的类的时候考虑是否需要实现同步;多核情况下更重的是不要过度的同步。

Item 80 : Prefer executors, tasks, and streams to threads

在线程中多使用Java提供的线程池,任务工具类和流操作

Item 81:Prefer concurrency utilities to wait and notify

使用并发包工具来处理wait notify操作

直接使用wait和notify难度较高,可以考虑使用更高级的并发包来处理;并发包中的集合有很高的性能,对其加锁会减慢程序速度,同时在并发集合中排除并发操作时不可能的;

在代码内部计时中通常使用System.nanoTime而不是System.currentTimeMills,前者更加精确,并且不会被系统时钟所影响;精确的性能测试非常麻烦,通常使用一些框架来进行,比如JMH。

wait操作总是需要在循环中执行,循环在等待前后起了条件检测的作用。

条件没有满足但是线程却被唤醒了有下面几种情况:

  • 另一个线程可以获得锁,并且在线程调用notify和等待线程唤醒的时候改变了保护状态;
  • 另一个线程没有达到条件却意外或者恶意的执行了notify方法;
  • 通知线程在等待唤醒中过于“慷慨”,比如尽管只有一些等待线程满足条件通知线程也会执行notifyAll方法;
  • 等待线程缺少通知的条件下小几率会被唤醒,被称作伪唤醒;

通常使用notifyAll来代替notify,虽然前者会唤醒所有线程,但是都会去检测是否符合条件,如果不符合则会继续等待;同时,使用notifyAll可以避免一些不相关对象偶然或者恶意的等待,否则的话这种等待可能忽略一些关键信息导致一直等待。

总的来说在循环中使用nofity和wait,并且通常使用nofityAll来唤醒线程,如果使用notify的话就需要格外的关注是否能够一定被唤醒。

Item 82 :Document thread safety

文档标注清楚线程是否安全

为了安全的并发使用,一个类必须清楚的注释其提供什么级别的线程安全:

  • Immutable–类都是常量,不需要额外的同步操作,比如String,Long BigInteger;
  • Unconditionally thread-safe–这种类的实例都是不可变的,但是其内部使用的同步来确保不需要额外的同步操作,比如AtomicLong;
  • Conditionally thread-safe–这种类需要外部的同步操作来保证并发安全,比如Collections.synchonized返回的集合包装类,他们的迭代操作都需要外部同步;
  • Not thread-safe–这种类的实例都是不可变的,想要并发的使用必须同步每一个操作,比如ArrayList和HashMap;
  • Thread-hostile–就算被外部同步操作包裹这种类也是不安全的;

客户端可以通过长时间持有公共锁对象发起拒绝服务攻击(denial-of-service),这种情况就需要一个私有的对象锁:

private final Object lock = new Object();
public void foo() {
	synchronized(lock) {
		…
	}
}

上面的锁对象声明为final,所有的锁属性(对象)都应该声明为final;私有锁对象只适用于Uncondionally thread-safe类,而无法使用在Condionally thread-safe类,因为必须在文档中声明客户端在执行操作时应该获取什么锁,而这个锁是私有的,无法获取。

总的来说每个类都应该在文档中仔细说明其线程安全相关信息;Condionally thread-safe需要说明哪些操作需要外部同步,需要获取哪些锁;Uncondionally thread-safe则需要声明一个私有的对象锁来在类内部实现同步。

Item 83 :Use lazy initialization judiciously

有选择的使用懒加载

只有在需要的时候使用懒加载,大部分的场景都推荐使用正常的初始化方式,同时使用了懒加载应该作性能对比(和不使用懒加载)在多线程的场景下使用懒加载需要进行同步,否则会出现严重的问题。

对于静态属性,使用容器类来进行懒加载:

// Lazy initialization holder class idiom for static fields
private static class FieldHolder {
	static final FieldType field = computeFieldValue();
}
private static FieldType getField() { 
	return FieldHolder.field; 
}

普通属性懒加载时使用double-check来提高性能,避免了在初始化后锁所带来的性能消耗:

// Double-check idiom for lazy initialization of instance fields
private volatile FieldType field;
private FieldType getField() {
	FieldType result = field;
	if (result == null) { // First check (no locking)
		synchronized(this) {
		if (field == null) // Second check (with locking)
			field = result = computeFieldValue();
		}
	}
	return result;
}

field声明为volatile非常重要。
single-check :

// Single-check idiom - can cause repeated initialization!
private volatile FieldType field;
private FieldType getField() {
	FieldType result = field;
	if (result == null)
		field = result = computeFieldValue();
	return result;
}

如果重复的初始化可以接受,可以使用上面的方式初始化,同样field会被声明为volatile。总的来说如果需要使用懒加载来提高性能,静态属性使用容器类来进行懒加载,普通属性使用double-check,如果不介意重复初始化属性则可以使用single-checke。

Item 84 : Don’t depend on the thread scheduler

不要依赖线程调度器

在多线程的情况下线程调度器决定着线程的启动和运行时间,任何依赖线程调度器来获取正确结果或者提升性能的程序基本都不可移植。应该尽量保证运行的线程不要超过处理器核心数量,控制运行线程数量可以让每个线程处理有用的工作并且待处理更多,线程在不处理有用工作的时候不应该运行。

同时线程不应该处理忙等待,反复检查共享资源是否可用。忙等待会增加处理器负载,减少其他线程可完成有用工作的数量。

当遇到程序由于其他线程占用导致很少运行时,不要使用 Thread.yield 来’修复’程序,最好是减少并发线程数量。尽管Java有线程优先级可以进行设置,但是最好不要通过降低线程优先级来解决严重的线程存活问题。

  
BugHome版权所有丨转载请注明出处:https://minei.me/archives/538.html
  

发表评论

电子邮件地址不会被公开。 必填项已用*标注