《Java并发编程实战》笔记

这个假期读了《Java并发编程实战》,因为暂时没有相关项目的实践所以读来的作用也只是了解居多。这里记录一下阅读过程中的笔记,到后面要用到的时候可以回来看看,再结合这里的总结去翻翻书里相关部分的原文。

第一部分 基础知识

第二章 线程安全性

竞态条件

竞态条件(race condition)表示由于不恰当的执行时许而出现的不正确的结果。大多数竞态条件的本质是基于一种可能失效的观察结果来做出判断或者执行某个计算,如:先判断是否存在一个对象,若为null则new一个,这就是一种先检查后执行的模式。

Java内置锁

Java提供了一种内置的锁机制来支持原子性:同步代码块(是一种互斥锁)。同步代码块包含两部分:一个作为锁的 对象的引用,一个作为由这个锁保护的代码块。

synchronized (lock) {
// 由锁保护的共享可变状态
}
重入

Java内置锁是可重入的,即某个线程可以获得它自己正在持有的锁。重入简化了面向对象并发代码的开发。如果内置锁不是可重入的,那么下面的代码就会产生死锁:

public class Base {
	public synchronized void doSomething() {
	//...
	}
}

public class Derived extends Base {
	public synchronized void doSomething() {
		System.out.println("calling doSomething");
		super.doSomething();	//如果内置锁是不可重入的,那么这里将无法获得Base上的锁
	}
}

其他

对于每个包含多个变量的不变性条件,其中涉及的所有变量都需要由同一个锁来保护。简单来说其实就是共享可变状态变量和锁之间是多对一的关系。

另外一个小建议是:当执行时间较长的计算或者可能无法快速完成的操作时,一定不要持有锁。

第三章 对象的共享

可见性

同步除了用于实现原子性和确定临界区以外,还有一个重要的方面:内存可见性。即当一个线程修改了对象状态后,其他线程可以看到发生的状态变化。
考虑下面的代码:

public class Value{
	private int value;
	public int get() {return value;}
	public void set(int value) {this.value = value}
}

如果某个线程调用了set,那么另一个正在调用get的线程看到的value就可能是更新前的也可能是更新后的值。
分别在get和set方法前加上synchronized即可进行同步。

volatile

volatile是一种削弱的同步机制,用于确保变量的更新操作通知到其他线程。在访问volatile变量时不会执行加锁操作,因此也不会使线程阻塞。因此volatile是一种比synchronized更轻量级的同步机制。

加锁机制既可以确保可见性又可以确保原子性,而volatile只能确保可见性

仅当volatile能简化代码时才应该使用它,其正确的使用方式包括:确保它们自身状态的可见性,确保它们所引用对象的状态的可见性,以及标识一些重要的程序生命周期事件的发生(如初始化和关闭)。

满足下面条件时,才应该使用volatile:

  • 对变量的写入操作不依赖变量的当前值,或可以确保只有单个线程更新变量的值。
  • 该变量不会与其他状态量一起纳入不变性条件中。
  • 在访问变量时不需要加锁。

多线程的long和double

当线程在没有同步的情况下读取变量时,可能会得到一个失效值,但至少这个值是由之前的某个线程设置的值,而不是一个随机值。这种安全性保证叫做最低安全性
绝大多数变量都满足最低安全性,但有一个例外:非volatile类型的64位数值变量(double和long)
Java内存模型要求变量的读取和写入操作都必须是原子操作,但对于非volatile的64位数值变量,JVM允许将64位的操作分解为2个32位的操作。这就导致高32位和低32位可能来自于两个不同的值。
因此,在多线程程序中使用共享可变的long和double时要么用volatile声明,要么用锁把它们保护起来

发布与逸出

发布(publish) 一个对象是指:使对象能够在当前作用域之外的代码中使用。当某个不应该发布的对象被发布时,则被称为逸出(escape)

注意:不要在构造方法中使this引用逸出。
在构造方法中使this引用逸出的一个常见错误是:在构造方法中启动一个线程当对象在其构造方法中创建一个线程时,无论是显式创建(通过将它传给构造方法)还是隐式创建(由于Thread或Runnable是该对象的一个内部类),this引用都会被新创建的线程共享。但其实在构造方法中创建线程其实并没有错误,只是不要立即启动它,而是通过一个start或initialize方法来启动。另外的,在构造方法中调用一个可改写的实例方法(既不是private,也不是final)时同样会导致this引用在构造过程中逸出
如下面的代码:

//隐式地使this引用逸出
/*
 *	当ThisEscape发布EventListener时,也隐含地发布了ThisEscape实例本身,
 *	因为在这个内部类的实例中包含了对ThisEscape实例的隐含引用。
*/
public class ThisEscape {
	public ThisEscape(EventSource source) {
		source.registerListener(
			new EventListener() {
				public void onEvent(Event e) {
					doSomething(e);
				}
			});
		//在构造方法内调用source.registerListener()导致了逸出
	}
}

可以用一个私有的构造方法和一个公共的工厂方法来防止this引用在构造过程中逸出:

public class SafeListener {
	private final EventListener listener;
	private SafeListener() {
		listener = new EventListener() {
			public void onEvent(Event e) {
				doSomething(e);
			}
		};
	}
	public static SafeListner newInstance(EventSource source) {
		SafeListener safe = new SafeListener();
		source.registerListener(safe.listener);
		return safe;
	}
}

可以通过以下方法之一安全地发布一个对象:

  • 在静态初始化方法中初始化一个对象引用。
  • 将对象的引用保存到volatile的域或AtomicReference对象中。
  • 将对象的引用保存到某个this引用没有逸出的对象的final域中。
  • 将对象的引用保存到一个由锁保护的域中。
public static Array<String> sentences;
public void initialize() {
	//在initialize()中实例化一个Array对象,并将其引用保存到sentences中以发布该对象。
	sentences = new Array<String>();
}

线程封闭

如果仅在单线程内访问数据,就不需要同步。这种技术称为线程封闭

栈封闭

对于栈封闭,只能通过局部变量才能访问对象。由于任何方法都无法获得对基本类型的引用,因此确保了基本类型的局部变量始终都封闭在线程内。

ThreadLocal类

维持线程封闭的一种更规范的方法是使用ThreadLocal类。它提供了get和set等访问接口和方法,get总是返回当前线程在调用set时设置的最新值。

ThreadLocal对象通常用于防止对可变的单实例变量或全局变量进行共享。例如,在单线程程序中可能会维持一个全局的数据库连接,并在程序启动时初始化这个连接对象,从而避免在调用每个方法时都要传递一个Connection对象。

//使用ThreadLocal来维持线程封闭性
private static ThreadLocal<Connection> connectionHolder = new ThreadLocal<Connection>() {
	public Connection initialValue() {
		return DriverManager.getConnection(DB_URL);
	}
};
public static Connection getConnection() {
	return connectionHolder.get();
}

不变性

满足同步性需求的另一种方法是使用不可变对象。并且,不可变对象一定是线程安全的

要注意不可变性并不等于将对象中的所有域都声明为final。同时,即使对象中所有域都是final,该对象也可能是可变的,因为final的域中可以保存对可变对象的引用。

当满足下面的条件时,对象才是不可变的:

  • 对象创建后其状态就不能修改。
  • 对象的所有域都是final。
  • 在对象的创建期间,this引用没有逸出。

一个建议:除非需要某个域是可变的,否则都应该将其声明为final。

第四章 对象的组合

Java监视器模式

遵循Java监视器模式的对象会把对象的所有可变状态都封装起来,并由对象自己的内置锁来保护。下面是一个遵循Java监视器模式的示例:

public class PersonSet {
	private final Set<Person> mySet = new HashSet<Person>();
	public synchronized void addPerson(Person p) {
		mySet.add(p);
	}
	public synchronized boolean containsPerson(Person p) {
		return mySet.contains(p);
	}
}

PersonSet的状态由HashSet来管理,而HashSet并非是线程安全的。但由于mySet是私有的且不会逸出,因此HashSet被封闭在PersonSet中。唯一能访问mySet的代码是addPerson和containsPerson,在执行它们的时候都会获得PersonSet上的锁。即PersonSet的状态完全由它的内置锁保护。

另外,可以使用私有锁而非对象的内置锁。私有锁可以将锁封装起来使客户代码无法得到锁。下面是一个使用私有锁的示例:

public class PrivateLock {
	private final Object myLock = new Object();
	//一些属性对象
	//...
	void someMethod() {
		synchronized(myLock) {
			//访问或修改属性对象
		}
	}
}

为现有类添加原子操作

有4种方法。

修改这个类

首先最安全的方法是修改原始的类,但通常我们无法访问或修改源代码。

扩展这个类

ExtendsVector对Vector进行扩展,添加一个新的原子操作:若没有则添加。

public class ExtendsVector<E> extends Vector<E> {
	public synchronized boolean putIfAbsent(E x) {
		boolean absent = !contains(x);
		if(absent)
			add(x);
		return absent; 
	}
}

扩展类是脆弱的,它将类的加锁代码分布到多个单独维护的类中,如果底层改变了同步策略那么子类就会被破坏。

客户端加锁机制

第三种方法是扩展类的功能,将添加的功能放入一个辅助类中,而不是通过继承扩展类本身。

public class ListHelper<E> {
	public List<E> list = Collections.synchronizedList(new ArrayList<E>());
	public synchronized boolean putIfAbsent(E x) {
		boolean absent = !list.contains(x);
		if(absent)
			list.add(x);
		return absent;
	}
}

然而尽管已经声明了synchronized上面的代码仍不是线程安全的,问题在于在错误的锁上进行了同步。无论List用哪一个锁来保护它的状态,可以肯定的是这个锁不会是ListHelper的锁。因此putIfAbsent和List的其他操作使用了不同的锁,也就不是原子的了。
应该修改为这样:

public boolean putIfAbsent(E x) {
	synchronized (list) {
		//方法内容
		//...
	}
}

客户端加锁比扩展类更脆弱,因为它将一个类的加锁代码放到与其完全无关的另一个类中。

组合

为现有的类添加一个原子操作时,更好的方法是组合。
下面的例子中,ImprovedList作为一个实现类通过将List对象的操作委托给底层的List实例来实现List的操作。

public class ImprovedList<T> implements List<T> {
	private final List<T> list;
	public ImprovedList(List<T> list) {this.list = list;}
	public synchronized boolean putIfAbsent(T x) {
		boolean absent = !list.contains(x);
		if(absent)
			list.add(x);
		return absent;
	}
	//按照类似的方法委托List的其他方法
	//...
}

它并不关心底层的List是否是线程安全的,即使List不是线程安全的或者修改了它的同步策略、加锁实现,ImprovedList也会实现线程安全性。

第五章 基础构建模块

同步容器类

同步容器类包括Vector, Hashtable和由Collections.synchronizedXxx等工厂创建的类。

同步容器类将它们的状态封装起来,并对每个共有方法都进行同步,因此是线程安全的,但需要额外的客户端加锁来保护复合操作。容器上常见的复合操作包括:迭代,跳转(根据指定顺序找到当前元素的下一个元素)以及条件运算(如“若没有则添加”)。因此同步容器类都支持客户端加锁。

有时为了防止饥饿、死锁的情况,我们不希望在迭代的时候对容器加锁。另外一种替代方法就是克隆容器,在副本上迭代(在克隆的时候仍需加锁)。

要注意一些其他操作可能隐式地执行了迭代操作(如toString()),应该在可能出现这种问题的地方封装对象的同步机制。如,用synchronizedSet代替HashSet。

并发容器

同步容器将所有对容器状态的访问都串行化,这严重降低了并发性。通过并发容器来代替同步容器,可以极大地提高可伸缩性并降低风险。

阻塞队列和生产者-消费者模式

生产者-消费者模式将“找出需要完成的工作”和“执行工作”这两个过程分离开来,并把工作项放入一个待完成列表(这个列表就是阻塞队列或其他阻塞数据结构)中以便在随后处理,而不是找到后立即处理。当数据生成时,生产者把数据放入队列,当消费者准备处理数据时,将从队列中获取数据。

下面的一个生产者-消费者模式示例,在某个文件中搜索符合标准的文件并建立索引:

// 生产者
public class FileCrawler implements Runnable {
	private final BlockingQueue<File> fileQueue;	//阻塞队列
	private final FileFilter fileFilter;
	private final File root;

	public void run() {
		try {
			craw(root);
		} catch(InterruptedException e) {
			Thread.currentThread().interrupt();
		}
	}

	private void craw(File root) throws InterruptedException {
		File[] entries = root.listFiles(fileFilter);	//找出当前文件的子目录
		if(entries != null) {
			for(File entry : entries) {
				// 如果是目录则迭代调用
				if(entry.isDirectory())
					craw(entry);
				// 如果是没有建立索引的文件,则加入到工作队列
				else if(!alreadyIndexed(entry))
					fileQueue.put(entry);
			}
		}
	}
}

// 消费者
public class Indexer implements Runnable {
	private final BlockingQueue<File> queue;

	public Indexer(BlockingQueue<File> queue) {
		this.queue = queue;
	}
	public void run() {
		try {
			while(true)
				indexFile(queue.take());	//indexFile是建立索引的一个方法,没写具体实现
		} catch(InterruptedException e) {
			Thread.currentThread().interrupt();
		}
	}
}

在这个例子中因为消费者线程是死循环所以程序永远不会退出,下面有关闭的方法

同步工具类

同步工具类可以是任何一个根据自身状态来协调线程的控制流的对象。阻塞队列可以作为同步工具类,其他的还有:信号量(Semaphore),闭锁(Latch),栅栏(Barrier)

信号量

计数信号量用来控制同时访问某个特定资源的操作数量,或同时执行某个操作的数量。Semaphore中管理着一组在构造方法中指定数量的虚拟的许可(permit),在执行操作时获得许可,并在使用以后释放许可。如果没有许可,那么acquire()将阻塞到由许可。

闭锁

闭锁可以延迟线程的进度直到其到达终止状态。闭锁相当于一扇门,在闭锁到达结束状态前,它一直是关闭的。当到达结束状态时,这扇门会打开并将永久打开,允许所有线程通过。闭锁用来确保某些活动直到其他活动都完成后才继续执行。比如等所有玩家都准备就绪再开始游戏。

CountDownLatch是一种闭锁的实现。闭锁状态包括一个计数器,该计数器被初始化为一个正数,表示需要等待的事件数量。countDown()方法递减计数器,表示完成一个事件。await()方法等待计数器到达零。

栅栏

栅栏类似于闭锁,它能阻塞一组线程直到某个事件发生。闭锁用于等待事件,而栅栏用于等待其他线程。闭锁和栅栏的关键区别在于:所有线程必须都到达栅栏的位置,才能继续执行。并且栅栏在让所有线程通过后会被重置以便下次使用。

第二部分 结构化并发应用程序

第六章 任务执行

Executor框架

大部分并发都是围绕任务执行来构造的。任务执行的主要抽象不是Thread,而是Executor。
Executor是基于生产者-消费者模式的一个类。可以理解为执行者,其提交任务的操作相当于生产者,执行任务的操作相当于消费者。

在线程池中执行任务比为每个任务分配一个线程要好得多。可以通过调用Executor的静态工厂方法来创建线程池:

  • newFixedThreadPool创建一个固定长度的线程池。
  • newCachedThreadPool创建一个可缓存的线程池。如果当前规模超过需求,将收回空闲的线程,当需求增加时,则添加新的线程。
  • newSingleThreadPool创建单个线程。
  • newScheduledThreadPool创建一个固定长度线程,并且以延迟或定时的方式执行任务。用它来代替Timer类的使用。

每当看到new Thread(runnable).start()这样的代码时,应该考虑用Executor来代替Thread。

关闭Executor

为了解决执行服务的生命周期问题,Executor扩展了ExecutorService接口。其shutdown()方法平缓关闭过程:不再接受新任务,将已提交的任务完成。shutdownNow()方法则直接关闭。

还有一种关闭生产者-消费者服务的方法就是使用 毒丸(poison pill) 对象。毒丸是指一个放在队列上的对象,当得到这个对象时立即停止。如下面的示例是对第五章给文件添加索引服务的完善:

public class IndexingService {
	private static final File POISON = new File("");
	private final IndexerThread consumer = new IndexerThread();
	private final CrawlerThread producer = new CrawlerThread();
	private final BlockingQueue<File> queue;
	private final FileFilter fileFilter;
	private final File root;

	public void start() {
		producer.start();
		consumer.start();
	}
	public void stop() {producer.interrupt();}

	// 生产者线程
	class CrawlerThread extends Thread {
		public void run() {
			try {
				crawl(root);
			} catch(InterruptedExecption e) {
				// 发生异常
			} finally {	//最后插入毒丸对象
				while(true) {
					try {
						queue.put(POISON);
						break;
					} catch(InterruptedException e1) {
						//重新尝试put(POISON)
					}
				}
			}
		}
	}
	// 消费者线程
	class IndexerThread extends Thread {
		public void run() {
			try {
				while(true) {
					File file = queue.take();
					// 遇到毒丸对象则退出
					if(file == POISON)
						break;
					else
						indexFile(File);
				}
			} catch(InterruptedException consumed) {}
		}
	}
}

要注意只有在生产者和消费者的数量都已知的时候才可以使用毒丸对象
当有多个生产者时,每个生产者都要向队列放入一个毒丸对象,并且消费者仅当接收到 N p r o d u c e r N_{producer} Nproducer个毒丸对象时才停止。
当有多个消费者时,生产者需要将 N p r o d u c e r N_{producer} Nproducer个对象放入队列。

Timer类的缺点

Timer类负责管理延迟任务和周期任务。但存在一些问题:

  1. Timer在执行所有定时任务时只会创建一个线程。如果某个任务的执行时间过长会破坏其他任务的精确性。例如:一个周期任务需要每10ms执行一次,而另一个任务需要执行40ms。那么这个周期任务在40ms任务执行完成后可能连续调用4次,也可能失去这4次调用。
  2. Timer线程不捕获异常,因此当任务抛出异常时Timer会将整个Timer停止调用。这将导致线程泄露:即已经被调度单未被执行的任务将不再执行,新的任务也不能被调度。

因此应该使用ScheduledThreadPoolExecutor来代替Timer。

第七章 取消与关闭

Java没有提供任何机制来安全地终止线程,但它提供了中断,这是一种协作机制,能够使一个线程终止另一个线程的当前工作。

任务取消

用一个标志表示取消

最容易想到的是设置一个boolean类型的“已取消”标志,以下面的PrimeGenerator持续地列举素数为例:

public class PrimeGenerator implements Runnable {
	private final List<BigInteger> primes = new ArrayList<BigInteger>();
	private volatile boolean cancelled;	// 已取消标志
	public void run() {
		BigInteger p = BigInteger.ONE;	//第一个素数是1
		while(!cancelled) {
			p = p.nextProbablePrime();
			synchronized (this) {
				primes.add(p);
			}
		}
	}

	public void cancel() {cancelled = ture;}
}

PrimeGenerator中的取消机制存在一个严重问题。如果采用这种方法的任务调用了一个阻塞方法,那么任务可能永远不会检查取消标志,从而导致永远不会结束。以下面的BrokenPrimeProducer为例:

class BrokenPrimeProducer extends Thread {
	private final BlockingQueue<BigInteger> queue;
	private volatile boolean cancelled = false;
	BrokenPrimeProducer(BlockingQueue<BigInteger> queue) {
		this.queue = queue;
	}
	// 生产者生产素数并放入阻塞队列
	public void run() {
		try {
			BigInteger p = BigInteger.ONE;
			while(!cancelled) {
				queue.put(p = p.nextProbablePrime())
			} catch(InterruptedException consumed) {}
		}
		public void cancel() {cancelled = true;}
	}

	void consumePrimes() throws InterruptedException {
		BlockingQueue<BigInteger> primes = new BlockingQueue<BigInteger>();
		BrokenPrimeProducer producer = new BrokenPrimeProducer(primes);
		producer.start();
		try {
			while(needMorePrimes())
				consume(primes.take());
		} finally {
			producer.cancel();
		}
	}
}

如果生产者的速度大于消费者的速度,put()方法就会阻塞。
如果生产者在put()中阻塞时,消费者希望取消生产任务而调用cancel()。此时生产者因为无法从阻塞的put()方法中恢复过来 (消费者取消了任务不再从队列中取走素数,而生产者一直在等待put()) 就永远不能检查到这个标志。0

使用中断

每个线程都有一个boolean类型的中断状态,当中断线程时将其设为true。

  • interrupt()可以中断目标线程。
  • isInterrupted()能返回目标线程的中断状态。
  • interrupted()清除当前线程的中断状态。

上面BrokenPrimeProducer里“用取消标志来取消任务会因为阻塞而失败”的问题可以通过使用中断而非取消标志来解决。如下面的示例:

class PrimeProducer extends Thread {
	private final BlockingQueue<BigInteger> queue;
	PrimeProducer(BlockingQueue<BigInteger> queue) {
		this.queue = queue;
	}
	public void run() {
		try {
			BigInteger p = BigInteger.ONE;
			while(!Thread.currentThread().isInterrupted())
				queue.put(p = p.nextProbablePrime());
		} catch(InterruptedException consumed) {
			//让线程退出
		}
	}
	public void cancel() {interrupt();}	// 用中断来取消任务
}

通常中断是实现取消的最合理的方式。

区分任务和线程对中断的反应是很重要的。任务不是在其自己的线程中执行,而是在如线程池的其他线程中执行的。对于非线程所有者的代码来说,应该保存中断状态,让拥有线程的代码做出响应。这就是为什么大多数可阻塞的库函数都只是抛出InterruptedException作为中断的响应。

使用Future

Future拥有一个cancel()方法,其参数是boolean类型的mayInterruptIfRunning,表示任务是否能接收中断。

在使用线程池的时候,不宜直接中断线程池,应该通过任务的Future来实现取消:

// 假设正在使用名为taskExec的线程池
// ExecutorService.submit()返回一个Futuer来描述任务。
Future<?> task = taskExec.submit(r)	// r是一个Runnable对象,即一个任务
// 调用Future的cancel()方法
taks.cancel(true);

一个良好的编程习惯:取消那些不再需要结果的任务

第八章 线程池的使用

在线程池中,如果任务依赖于其他任务,那么可能产生死锁,此时应该使用无界的线程池,如newCachedThreadPool

设置线程池

对于计算密集型任务。在拥有 N c p u N_{cpu} Ncpu个处理器的系统上,线程大小为 N c p u + 1 N_{cpu} + 1 Ncpu+1个时可以实现最高利用率。
对于包含I/O或其他阻塞操作的任务。令:
N c p u = C P U 的 数 量 U c p u = C P U 利 用 率 W / C = 等 待 时 间 比 计 算 时 间 N_{cpu} = CPU的数量 \\ U_{cpu} = CPU利用率 \\ W/C = 等待时间比计算时间 Ncpu=CPUUcpu=CPUW/C=
要使CPU利用率达到 U c p u U_{cpu} Ucpu,线程池的最优大小为: U t h r e a d = N c p u ∗ U c p u ∗ ( 1 + W / C ) U_{thread} = N_{cpu} * U_{cpu} * (1+W/C) Uthread=NcpuUcpu(1+W/C)

可以用ThreadPoolExecutor定制满足需求的线程池。在Executors中有一个unconfigurableExecutorService方法,对一个现有的ExecutorService进行封装使其只暴露出ExecutorService的方法而不能被配置。

饱和策略

如果任务的提交速率远大于执行速率,那么总会耗尽资源。
对于非常大或无界的线程池,可以通过使用SynchronousQueue来避免任务排队。它不是一个真正的队列,任务会直接交给执行它的线程,而不是被放在队列中。
对于有界队列,当队列填满后应该怎么办,下面几个饱和策略就是应对方法。

  1. 中止策略AbortPolicy
    是默认的饱和策略。该策略在饱和时抛出一个RejectedExecutionException异常。
  2. 抛弃策略DiscardPolicy
    在饱和时抛弃该任务。
  3. 抛弃最旧策略DiscardOldestPolicy
    则抛弃下一个将被执行的任务。注意如果工作队列是一个优先级队列,那么该策略会导致抛弃优先级最高的策略。因此不要将该策略与优先级队列放在一起使用
  4. 调用者运行策略CallerRunsPolicy
    将某些任务回退给调用者,从而降低新任务的流量。它在一个调用了execute的线程中执行任务(该线程本来用于提交任务)。由于执行任务需要时间,因此暂时不会有新的任务提交,执行任务的线程也就有时间去完成任务。这种策略会让服务器在高负载的情况下实现一种平缓的性能降低。

ThreadPoolExecutor的饱和策略通过setRejectedExecutionHandler来修改。

ThreadPoolExecutor executor = new ThreadPoolExecutor();
// 修改饱和策略
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.callerRunsPolicy());

第三部分

第十章 避免活跃性问题

活跃性问题包括死锁、饥饿、丢失信号和活锁等。

死锁

两个线程以不同的顺序来获得相同的锁就会引发锁顺序死锁问题,如下面的例子:

public class LockOrdering {
	// 两个锁
	private final Object left = new Object();
	private final Object right = new Object();

	public void leftRight() {
		synchronized(left) {
			synchronized(right) {
				doSomething();
			}
		}
	}
	public void rightLeft() {
		synchronized(right) {
			synchronized(left) {
				doSomething();
			}
		}
	}
}

进一步地,锁的顺序也可能取决于传参的顺序,如下面的转账示例:

// 转账需要3个参数:转出转入账户和金额
public void transferMoney(Account fromAcct, Account toAcct, DollarAmount amount) {
	synchronized(fromAcct) {
		synchronized(toAcct) {
			// 转账操作
		}
	}
}

虽然表面上看是按顺序获得锁,但是一个线程从X向Y转账,另一个线程从Y向X转账时,就会发生锁顺序死锁。
要解决这个问题,就要利用System.identityHashCode()定义锁的顺序。可以将上面的代码改为这样:

// 极少数情况下,两个对象可能有相同的散列值
// 使用“加时赛”(TieBreaking)锁tieLock以保证每次只有一个线程以未知的顺序获得两个锁
private static final Object tieLock = new Object();
public void transferMoney(final Account fromAcct, final Account toAcct, final DollarAmount amount) {
	class Transfer {
		public void transfer() {
			// 转账操作
		}
	}
	// 两个锁的散列值
	int fromHash = System.identityHashCode(fromAcct);
	int toHash = System.identityHashCode(toAcct);
	// 用散列值来定义锁的顺序
	if(fromHash < toHash>) {
		synchronized(fromAcct) {
			synchronized(toAcct) {
				new Transfer().transfer();
			}
		}
	}
	else if(fromHash > toHash) {
		synchronized(toAcct) {
			synchronized(fromAcct) {
				new Transfer().transfer();
			}
		}
	}
	else {
		synchronized(tieLock) {
			synchronized(fromAcct) {
				synchronized(toAcct) {
					new Transfer().transfer();
				}
			}
		}
	}
}

如果有一个唯一的、不可变的、可比较的键值,例如账号(类比数据库中的主码)。就可以通过该键值对对象排序,也避免了使用加时赛锁。如果所有线程都以固定的顺序来获得锁,就可以解决锁顺顺序死锁的问题

还有另外一种通过轮询的方式解决该问题的方法,即不使用内置锁。

如果在调用某个方法时不需要持有锁,就称这种调用为开放调用(open call)。
在程序中尽量使用开放调用,这更易于进行死锁分析。

JVM通过 线程转储(thraed dump) 来帮助识别死锁的发生。它包括各个运行中的线程的栈追踪信息和加锁信息(持有哪些锁,在哪些栈帧中获得的锁,正在等待哪些锁)。

饥饿

饥饿(starvation) 是指线程由于无法访问所需要的资源而不能继续执行。

引发饥饿的最常见的资源就是CPU时钟周期。也可能是对线程的优先级使用不当。一个贴士是:避免使用线程优先级,因为这会降低可移植性(与操作系统相关),也可能引发饥饿。大多数情况都应该使用默认的线程优先级。

活锁

活锁(livelovk) 是指线程不断重复执行相同的操作,而且总会失败。有时候也叫毒药消息(poison message)。比如回滚的事务被放到消息队列开头,一直不能处理就会一直回滚引发活锁。

有一种情况是:两个线程在产生冲突时都放弃本次执行并在1秒后重试,那么1秒后它们还是会发生冲突。要解决这种问题,需要在重试机制中引入随机性。通过等待随机长度的时间可以避免活锁的发生。

第十一章 性能与可伸缩性

可伸缩性(Scalability) 是指当增加计算资源时,程序的吞吐量或处理能力能相应地增加。

Amdahl定律

Amdahl定律描述的是在增加计算机资源时,程序在理论上能够实现的最高加速比。
用F表示必须被串行执行的部分的运行时间所占总时间的百分比,在N个处理器的机器中,加速比:
S p e e d u p ≤ 1 F + 1 − F N Speedup ≤ \frac {1} {F + \frac {1-F} {N}} SpeedupF+N1F1
当N趋近无穷大时,最大加速比趋近于1/F。因此,如果程序有50%的计算需要串行执行,那么最高的加速比只能是2。
并且处理器利用率定义为加速比/处理器数量,因此此时不管有多少个线程可用,只要2个线程就可以让利用率达到100%。

多线程导致的额外开销

多线程相比单线程引入了以下几个额外的开销。

  1. 上下文切换。
  2. 内存同步。
    JVM会去掉一些不会发生竞争的锁。如果一个锁对象只能由当前线程访问,那么JVM就会通过优化去掉它。
  3. 阻塞。

减少锁的竞争

减少锁的竞争可以提高可伸缩性。有以下3种方式来减少锁的竞争。

减少锁的持有时间

可以将一些与锁无关的代码移出同步代码块,例如I/O操作。这样可以减少串行代码量,根据Amdahl定律即可提高可伸缩性。

降低锁的请求频率

如果一个锁需要保护多个相互独立的状态变量,那么可以将这个锁分解为多个锁,每个锁只保护一个变量。

在下面的例子中,一个锁保护了多个方法。这样的话即使多个线程也只能一个个地调用实例的各个方法,因为他们锁的对象都是this。参考这篇同一个类里面两个synchronized方法,两个线程同时访问的问题

public class OneLock {
	public final Set<String> setOne;
	public final Set<String> setTow;
	// 两个方法其实都是synchronized(this)
	public synchronized void method1(String s) {
		setOne.add(s);
	}
	public synchronized void method2(String s) {
		setTwo.add(s);
	}
}

使用锁分解改写上面的代码即可让多个线程同时调用一个实例的不同方法,减小锁的粒度,进而减少锁的请求频率。

// 进行锁分解,将方法改写为下面这样
public void method1(String s) {
	synchronized(setOne) {
		setOne.add(s);
	}
}
public void method2(String s) {
	synchronized(setTwo) {
		setTwo.add(s);
	}
}

还可以利用锁分段技术。某些情况下,可以将锁分解技术进一步扩展为对一组独立对象上的锁进行分解。例如:在ConcurrentHashMap的实现中使用了一个包含16个锁的数组,每个锁保护散列桶的1/16。

使用其他机制的锁

例如共享锁ReadWriteLock只在写操作时加锁。

第四部分 高级主题

第十三章 显式锁

内置锁存在一些局限性,如无法中断一个正在等待获取锁的线程。再内置锁中,发生死锁时唯一的恢复方法是重启程序。可以使用lockInterruptibly()方法让锁在等待的时候保持对中断的响应。

当需要一些高级功能时才应该使用ReentrantLock,如:可定时的、可轮询的与可中断的锁获取操作,公平队列,非块结构的锁。

比如,可以通过轮询解决第十章提到的动态顺序死锁问题:
public boolean transferMoney(Account fromAcct, Account toAcct, DollarAmount amount, long timeout, TimeUnit unit) {
	// 用来随机轮询的等待时间
	long fixedDelay = /*...*/;
	long randMod = /*...*/;
	// System.nanoTime()返回一个纳秒为单位的时间
	long stopTime = System.nanoTime() + unit.toNanos(timeout);

	while(true) {
		// tryLock()方法:成功获取到该锁则返回true,否则返回false
		if(fromAcct.lock.tryLock()) {
			try {
				if(toAcct.lock.tryLock()) {
					try {
						// 转账操作
						return true;
					} finally {
						toAcct.lock.unlock();
					}
				}
			} finally {
				fromAcct.lock.unlock();
			}
		}
		if(System.nanoTime() < stopTime>)
			return false;
		// 获取锁失败时,等待的时间随机,以避免活锁
		NANOSECONDS.sleep(fixedDelay + rnd.nextLong() % randMod);
	}
}

第十四章 构建自定义的同步工具

状态依赖性是指需要依赖状态才能进行的操作,如take()需要在队列状态为非空时才可进行操作。
要实现对状态依赖类的管理应该采用条件队列的方式,但这之前我们先看两个不用条件队列的实现方法,它们都有一些缺陷。

  1. 将不满足的状态条件抛出异常给给调用者处理
public synchronized void take() {
	if (isEmpty())
		// 抛出异常
	// 执行take()操作并返回
}

这需要在调用者的地方多写一些处理异常的代码,既麻烦又逻辑混乱。
2. 通过轮询与休眠来实现

public void take() {
	while (true) {
		synchronized (this) {
			if (!isEmpty())
				// 执行take()操作
		}
		Thread.sleep(1);
	}
}

这种方法的缺陷是需要处理中断的异常,并且休眠是固定的时间,可能会在醒来之前条件就变为真,这样会浪费时间。

如果存在某种方法可以挂起线程,并在某个条件为真时让其立刻醒来,就可以极大简化实现工作。这就是条件队列的功能。

条件队列

条件队列本身是对轮询+休眠实现方法的优化。
wait()会释放锁,并挂起线程直到被唤醒,并且在返回之前重新获得锁(它在重新获得锁时不具有任何优先级,而要与其他线程竞争)。在被唤醒到重新获得锁期间,可能会有其他线程获得该锁并将条件又更改为假,或者可能是被其他条件为真时的notifyAll()一并唤醒。因此每次唤醒时,都应该重新测试条件是否为真。这也就是wait()要放到while里的原因。

// 条件谓词:非空 (!isEmpty())
public synchronized V take() {
	// wait()要在while里重复测试条件
	while (isEmpty())
		wait();
	V v = // 执行take()操作
	notifyAll();
	return v;
}

锁对象和条件队列对象必须是一个对象。
另外,必须确保在每条使条件谓词为真的代码路径中都发出通知,例如:在put()之后调用notifyAll()发出通知以进行take()操作。

关于notify()notifyAll()的区别:调用notify()时,JVM会从这个条件队列上等待的多个线程中选择一个来唤醒,而调用notifyAll()则会唤醒所有线程。由于多个线程可以基于不同的条件谓词(如put和take对应的非满和非空)在同一个条件队列上(一个对象就是一个条件队列)等待,因此使用notify()可能导致信号丢失。

只有同时满足以下两个条件时,才能使用notify():

  • 所有等待线程的类型都相同
    只有一个条件谓词与条件队列相关,并且每个线程唤醒后执行的操作相同。
  • 单进单出
    在条件变量上的每次通知,最多只能唤醒一个线程来执行。

然而内置条件队列存在一些缺点:每个内置锁都只能关联一个内置条件队列。在需要使用更丰富的功能(如:每个锁上存在多个等待,条件等待是否是可中断的、基于时限的,公平或非公平队列)的时候就可以用到Condition类。
Condition之于内置条件队列,就像ReentrantLock之于内置锁一样。对于每个Lock,可以有任意个Condition对象与之关联。在Condition对象中,与wait、notify、notifyAll对应的是await、signal、signalAll。通常其用法如下:

// 在相关联的Lock对象lock上创建Contition对象
// 条件谓词:非满
private final Condition notFull = lock.newCondition();
// 条件谓词:非空
private final Condition notEmpty = lock.newCondition();

AQS

AbstractQueuedSynchronizer是许多其他同步类的基类。(诸如ReentrantLock、Semaphore、CountDownLatch、FutureTask等)可以使用委托(而不要使用继承)的方式利用AQS来构建自己的同步器。

第十五章 原子变量与非阻塞同步机制

volatile类型变量有一定缺点,例如当一个变量依赖其他变量,或者当变量的新值依赖旧值时,就不能使用volatile。原子变量类是一种更好的volatile类型变量。可以用原子变量来构建高效的非阻塞算法。在高度竞争(通常是一种极端情况)的情况下,锁的性能要超过原子变量的性能,但在更真实的情况下,原子变量的性能要优于锁。就好像当交通拥堵时红绿灯(加锁)能实现更高的吞吐量,而低拥堵时环岛(细粒度的更新)能实现更高的吞吐量一样。

在并发算法领域的大多数研究都侧重于非阻塞算法,即线程的失败或挂起不会导致其他线程也失败或挂起。这种算法用底层的原子机器指令(如下面的CAS)来代替锁来保证一致性。

比较并交换(CAS)指令

对于细粒度的操作,有另外一种方法在性能上要优于加锁机制。这种方法会判断更新时是否有其他线程的干扰,如果有,则操作失败并可以重试(或者不重试)。大多数处理器都通过CAS指令来实现该方法。

CAS是一个更新操作,它首先验证数据有没有被其他线程修改,如果没有再继续更新操作。在更新某个值时如果存在可能的不一致性,并且在失败时要重新尝试,就可以用到CAS。
下面用一段代码来模拟CAS操作(注意不是CAS的真正实现)

// 模拟CAS
public class CAS {
	private int value;
	public synchronized int get() {return value;}
	public synchronized int compareAndSwap(int exceptedValue, int newValue) {
		int oldValue = value;
		// 确保没有被其他线程修改,exceptedValue表示“此时旧值应该是什么”
		if (oldValue == exceptedValue)
			value = newValue;
		return oldValue;
	}
}

下面用CAS实现一个非阻塞的计数器:

public class CasCounter {
	private CAS count;
	public int getCount() {return count.get();}
	// 递增操作
	public int increment() {
		int c;
		do {
			c = conut.get();
			// 当c没有被其他线程更新时退出循环
		} while (c != count.compareAndSwap(c, c + 1));
		return c + 1;
	}
}
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值