线程会共享进程范围内的资源,例如内存句柄和文件句柄,但每个线程都有各自的程序计数器(Program Counter)、栈以及局部变量等。
在同一个程序中的多个线程也可以被同时调度到多个CPU上运行。
Java中的主要同步机制是关键字synchronized,它提供了一种独占的加锁方式,但“同步”这个术语还包括volatile类型的变量,显式锁(Explicit Lock)以及原子变量。
如果当多个线程访问同一个可变的状态变量时没有使用合适的同步,那么程序就会出现错误。有三种方式可以修复这个问题:
线程安全性定义:当多个线程访问某个类时,这个类始终都能表现出正确的行为,那么就称这个类是线程安全的。
无状态对象一定是线程安全的。
大多数竞态条件的本质:基于一种可能失效的观察结果来做出判断或者是执行某个计算。这种类型的竞态条件成为“先检查后执行”:首先观察到某个条件为真(例如文件X不存在),然后根据这个观察结果采用相应的动作(创建文件X),但事实上,在你观察到这个结果以及开始创建文件之间,观察结果可能变得无效(另一个线程在这期间创建了文件X),从而导致了各种问题(未预期的异常、数据被覆盖、文件被破坏等)。
假定有两个操作A和B,如果从执行A的线程来看,当另一个线程执行B时,要么将B全部执行完,要么完全不执行B,那么A和B对彼此来说是原子的。原子操作是指,对于访问同一个状态的所有操作(包括该操作本身)来说,这个操作是一个以原子方式执行的操作。
在实际情况中,应尽可能地使用现有的线程安全对象(例如AtomicLong)来管理类的状态。与非线程安全的对象相比,判断线程安全对象的可能状态及其状态转换情况要更为容易,从而也更加容易维护和验证线程安全性。
要保持状态的一致性,就需要在单个原子操作中更新所有相关的状态变量。
重入的一种实现方法是,为每个锁关联一个获取计数值和一个所有者线程。当计数值为0时,这个锁就被认为是没有被任何线程持有。当线程请求一个未被持有的锁时,JVM将会记下锁的持有者,并且将获取计数值置为1。如果同一个线程再次获取这个锁,计数值将会递增,而当线程退出同步代码块时,计数器会相应地递减。当计数值为0时,这个锁将被释放。
并非所有数据都需要锁的保护,只有被多个线程同时访问的可变数据才需要通过锁来保护。
当执行时间较长的计算或者可能无法快速完成的操作时(例如,网络IO或控制台IO),一定不要持有锁。
状态的理解,我认为是类的成员变量。无状态对象就是成员变量不能储存数据,或者是可以储存数据但是这个数据不可变。无状态对象是线程安全的。如果方法中存在成员变量,就需要对这个成员变量进行相关的线程安全的操作。
不要一味地在方法前加synchronized,这可以保证线程安全,但是方法的并发功能会减弱,导致本来可以支持并发的方法变成堵塞,导致程序处理速度的变慢。
synchronized包围的代码要尽可能的短,但是要保证有影响的所有成员变量在一起。没有关系的成员变量可以用多个synchronized包围。
加锁的含义不仅仅局限于互斥行为,还包括内存可见性。为了确保所有线程都能看到共享变量的最新值,所有执行读操作或者写操作的线程都必须在同一个锁上同步。
Java语言提供了一种稍弱的同步机制,即volatile变量,用来确保将变量的更新操作通知到其他线程。当把变量声明为volatile类型后,编译器与运行时都会注意到这个变量是共享的,因此不会将该变量上的操作与其他内存操作一起重新排序。volatile变量不会被缓存在寄存器或者对其他处理器不可见的地方,因此在读取volatile类型的变量时总会返回最新写入的值。
在访问volatile变量时不会执行加锁操作,因此也就不会使执行线程阻塞,因此volatile变量是一种比synchronized关键字更轻量级的同步机制。
volatile变量通常用做某个操作完成、发生中断或者是状态的标志。volatile的语义不足以确保递增操作(count++)的原子性,除非你能确保只有一个线程对变量执行写操作。
加锁机制既可以确保可见性又可以确保原子性,而volatile变量只能确保可见性。
当且仅当满足以下所有条件时,才应该使用volatile变量:
“发布(Publish)”一个对象的意思是指,使对象能够在当前作用域之外的代码中使用。
当某个不应该发布的对象被发布时,这种情况就被称为逸出(Escape)。
不要在构造过程中使this引出逸出。
如果想在构造函数中注册一个事件监听器或者启动线程,那么可以使用一个私有的构造函数和一个公共的工厂方法(Factory Method),从而避免不正确的构造过程。
栈封闭是线程封闭的一种特例,在栈封闭中,只有通过局部变量才能访问对象。
维持线程封闭性的一种更规范方法是使用ThreadLocal,这个类能够使线程中的某个值与保存值的对象关联起来。
ThreadLocal对象通常用于防止对可变的单实例对象(Singleton)或全局变量进行共享。
当满足以下条件时,对象才是不可变的:
不可变对象一定是线程安全的。
要安全地发布一个对象,对象的引用以及对象的状态必须同时对其他线程可见。一个正确构造的对象可以通过以下方式来安全地发布:
在没有额外的同步的情况下,任何线程都可以安全地使用被安全发布的事实不可变对象。
对象的发布需求取决于它的可变性:
在并发程序中使用和共享对象时,可以使用一些实用的策略,包括:
发布和逸出的理解:就是说一个类中的成员变量或者对象可以被其他的类所引用使用就是发布,如用static修饰的静态变量或者是当前调用方法的对象。逸出是指该成员变量或对象在本来不应该被多线程引用的情况下暴露出去被引用,导致其值可能被错误修改的问题。一句话,不要随便扩大一个类以及内部使用成员变量和方法的作用域。这也是封装应该考虑的问题。
this逸出:即在构造方法的内部类中启动另一个线程引用了这个对象,但是这时这个对象还没有构造完成,可能会导致出乎意料的错误。解决方法是创建一个工厂方法,然后将构造器设置成私有构造器。
final修改的成员变量需要在构造器在构造器中初始化,否则对象实例化后这个成员变量不能赋值。final修饰的成员变量是引用对象时,这个对象的地址不能修改,但是这个对象的值是可以修改的。
安全发布一个对象的四种方式的理解,如A类中有B类的引用:
事实不可变对象很简单的理解就是技术上是可变的,但是在业务逻辑处理中是不会去修改的对象。
在设计线程安全类的过程中,需要包含以下三个基本要素:
将数据封装在对象内部,可以将数据的访问限制在对象的方法上,从而更容易确保线程在访问数据时总能持有正确的锁。
如果一个类是由多个独立切线程安全的状态变量组成,并且在所有的操作中都不包含无效状态转换,那么可以将线程安全性委托给底层的状态变量。
如果一个状态变量是线程安全的,并且没有任何不变性条件来约束它的值,在变量的操作上也不存在任何不允许的状态转换,那么就可以安全地发布这个变量。
类中的成员变量如果是独立的,就是说没有相互的判断和依赖关系,并且这个变量的类型是线程安全的,如final修饰的ConCurrentHashMap,那么这些成员变量可以用基本的getter和setter来保障线程安全。
在已经是线程安全类的对象的方法上加线程安全的锁并不能保证线程安全,因为方法上的锁和对象的锁不是同一个。要保证安全,可以在方法中对象进行加锁,或者是重新实现一个类,然后在类中对于操作方法进行线程安全的加锁。
标准容器的toString()方法将迭代容器,并在每个元素上调用toString()来生成容器内容的格式化表示。
容器的hashCode和equals等方法也会间接地执行迭代操作,当容器作为另一个容器的元素或键值时,就会出现这种情况。同样,contailsAll、removeAll和retainAll等方法,以及把容器作为参数的构造器,都会对容器进行迭代。所有这些间接的迭代操作都有可能抛出ConcurrentModificationException。
Java5.0增加了两种新的容器类型:Queue和BlockingQueue。Queue用来临时保存一组等待处理的元素。它提供了几种实现,包括:ConcurrentLinkedQueue,这是一个传统的先进先出队列,以及PriorityQueue,这是一个(非并发的)优先队列。Queue上的操作不会阻塞,如果队列为空,那么获取元素的操作间返回空值。虽然可以用List来模拟Queue的行为——事实上,正是通过LinkedList来实现Queue的,当还需要一个Queue的类,因为它能去掉List的随机访问需求,从而实现更高效的并发。
BlockingQueue扩展了Queue,增加了可阻塞的插入和获取等操作。如果队列为空,那么获取元素的操作间一直阻塞,知道队列中出现一个可用的元素。如果队列已满(对于有界队列来说),那么插入元素的操作将一直阻塞,直到队列中出现可用的空间。在“生产者——消费者”这种设计模式中,阻塞队列是非常有用的。
ConcurrentHashMap并不是将每个方法都在同一个锁上同步并使得每次只能由一个线程访问容器,而是使用一种粒度更细的加锁机制来实现更大程度的共享,这种机制称为分段锁(Lock Striping)。在这种机制中,任意数量的读取线程可以并发地访问Map,执行读取操作的线程和执行写入操作的线程可以并发地访问Map,并且一定数量的写入线程可以并发地修改Map。ConcurrentHashMap带来的结果是,在并发访问环境下将实现更高的吞吐量,而在单线程环境中只损失非常小的性能。
CopyOnWriteArrayList用于替代同步List,在某些情况下它提供了更好的并发性能,并且在迭代期间不需要对容器进行加锁或复制。(类似地,CopyOnWriteArraySet的作用是替代同步Set)。
仅当迭代操作远远多于修改操作时,才应该使用“写入时复制”容器。
当在代码中调用了一个将抛出InterruptedException异常的方法时,你自己的方法也就变成了一个阻塞方法,并且必须要处理对中断的响应。对于库代码来说,有两种基本选择:
public class TaskRunnable implements Runnable {
BlockingQueue queue;
...
public void run() {
try {
processTask(queue.take());
} catch (InterruptedException e) {
//恢复被中断的状态
Thread.currentThread().interrupt();
}
}
}
CountDownLatch是一种灵活的闭锁实现,可以在上述各种情况中使用,它可以使一个或者多个线程等待一组事件发生。闭锁状态包括一个计数器,该计数器被初始化为一个正数,表示需要等待的事件数量。countDown方法递减计数器,表示有一个时间已经发生了,而await方法等待计数器到达零,这表示所有需要等待的事件都已经发生。如果计数器的值非零,那么await会一直阻塞直到计数器为零,或者等待中的线程中断,或者等待超时。
FutureTask也可以用做闭锁。(FutureTask实现了Future语义,表示一种抽象的可深层结果的计算)。FutureTask表示的计算是通过Callable来实现的,相当于一种可生成结果的Runnable,并且可以处于一下3种状态:等待运行(Waitting to run),正在运行(Running)和运行完成(Completed)。“执行完成”表示计算的所有可能结束方式,包括正常结束、由于取消而结束和由于异常而结束等。当FutureTask进入完成状态后,它会用于停止在这个状态上。
Semaphore中管理着一组虚拟的许可(permit),许可的初始数量可通过构造函数来指定,在执行操作时可以首先获取许可(只要还有剩余的许可),并在使用以后释放许可。如果没有许可,那么acquire将阻塞直到有许可(或者直到被中断或者操作超时)。release方法将返回一个许可给信号量。计算信号量的一种简化形式是二值信号量,即初始值为1的Semaphore。二值信号量可以用做互斥体(mutex),并具备不可重入的加锁语义:谁拥有这个唯一的许可,谁就拥有了互斥锁。
release返回许可信号量的实现不包含真正的许可对象,而且Semaphore也不会将许可和线程关联起来,因此在一个线程中获取的许可可以在另一个线程中释放。可以将acquire操作视为是消费一个许可,而release操作是创建一个许可,Semaphore并不受限于它在创建时的初始许可数量。
Semaphore使用示意:
public class BoundedHashSet {
private final Set set;
private final Semaphore sem;
public BoundedHashSet(int bound) {
this.set = Collection.synchronizedSet(new HashSet());
sem = new Semaphore(bound);
}
public boolean add(T o) throws InterruptedException {
sem.acquire();
boolean wasAdd = false;
try {
wasAdd = set.add(o);
return wasAdd;
}
finally {
if (!wasAdd)
sem.release();
}
}
public boolean remove(Object o) {
boolean wasRemoved = set.remove(o);
if (wasRemoved)
sem.release();
return wasRemoved();
}
}
就算是线程安全的容器类,在进行迭代或者是条件运算(如若没有则添加)时,没有对容器对象进行加锁的话,还是会出现线程不安全的情况。因此需要在方法内对容器对象进行加锁。但是这样的方法有一个问题就是会大幅度降低容器的并发性,吞吐量严重降低。
在使用迭代器Iterator对容器进行遍历的时候,容器内部的计数器发生变化的话,hasNext和next方法会抛出ConcurrentModificationException。比如在迭代的时候没有通过迭代器的remove方法。
之前在别的博客中,或者是看源码的时候发现了HashMap、HashTable和ConcurrentHashMap的区别。举一个厕所的例子,HashMap就是没有门的厕所,里面的坑位也是没有门的,有人要上厕所,就算坑位里面有人,他也要一起上。HashTable的作用就是在厕所加一个门,所有人在厕所门口排队,里面的人安全了,但是外面需要排很长的队,性能堪忧。ConcurrentHashMap则是在坑位上加了一个门,大家可以进厕所,然后在想要进的坑位前排队,这样性能就得到了很大的优化。这也是书中说的分段锁的概念。具体的实现需要看源码和查看别的博客书籍获取。
书中关于CountDownLatch的例子代码很有意思,是关于统计多线程执行完任务的时间统计。看懂这个例子,CountDownLatch的用法就明了了。还有,这个类的名字很有意思,直译叫做倒计时发射,很形象。
public long timeTasks(int nThreads, final Runnable task) {
final CountDownLatch startGate = new CountDownLatch(1);
final CountDownLatch endGate = new CountDownLatch(nThreads);
for (int i = 0; i < nThreads; i++) {
Thread t = new Thread() {
public void run() {
try {
startGate.await();
try {
task.run();
} finally {
endGate.countDown();
}
} catch (InterruptedException ignored) {}
}
};
t.start();
}
long start = System.nanoTime();
startGate.countDown();
endGate.await();
long end = System.nanoTime();
return end - start;
}
无限制创建线程的不足:
每当看到下面这种形式的代码时:
new Thread(runnable).start()
并且你希望获得一种更灵活的执行策略时,请考虑使用Executor来代替Thread。
线程池,从字面含义来看,是指管理一组同构工作线程的资源池。线程池是与工作队列(Work Queue)密切相关的,其中在工作队列中保存了所有等待执行的任务。工作者线程(Worker Thread)的任务很简单:从工作队列中获取一个任务,执行任务,然后返回线程池并等待下一个任务。
类库提供了一个灵活的线程池以及一些有用的默认配置。可以通过调用Executors中的静态工作方法之一来创建一个线程池:
单线程的Executor还提供了大量的内部同步机制,从而确保了任务执行的任何内存写入操作对于后续任务来说都是可见的。这意味着,即使这个线程会不时地被另一个线程替代,当对象总是可以安全地封存在“任务线程”中。
Java没有提供任何机制来安全地终止线程。但它提供了中断(Interruption),这是一种协作机制,能够使一个线程终止另一个线程的当前工作。
每个线程都有一个boolean类型的中断状态。当中断线程时,这个线程的中断状态将被设置成true。在Thread中包含了中断线程以及查询线程中断状态的方法,如程序所示。interrupt方法能中断目标线程,而isInterrupt方法能返回目标线程的中断状态。静态的interrupted方法将清除当前线程的中断状态,并返回它之前的值,这也是清除中断状态的唯一方法。
puiblic class Thread {
public void interrupt() {...}
public boolean isInterrupted() {...}
public static boolean interrupted() {...}
}
阻塞库方法,例如Thread.sleep和Object.wait等,都会检查线程何时中断,并且在发现中断时提前返回,它们在响应中断时执行的操作包括:清除中断状态,抛出InterruptedException,表示阻塞操作由于中断而提前结束。JVM并不能保证阻塞方法检测到中断的速度,但在实际情况中响应速度还是非常快的。
当线程在非阻塞状态下中断时,它的中断状态将被设置,然后根据将被取消的操作来检查中断状态以判断发生了中断。通过这样的方法,中断操作将变得“有粘性”–如果不触发InterruptedException,那么中断状态将一直保持,直到明确地清除中断状态。
调用interrupt并不意味着立即停止目标线程正在进行的工作,而只是传递了请求中断的消息。
对中断操作的正确理解是:它并不会真正地中断一个正在运行的线程,而只是发出中断请求,然后由线程在下一个合适的时刻中断自己。(这些时刻也被称为取消点。)有些方法,例如wait/sleep和join等,将严格地处理这些请求,当它们收到中断请求或者在开始执行时发现某个已被设置好的中断状态时,将抛出一个异常。设计良好的方法可以完全忽略这种请求,只要它们能使调用代码对中断请求进行某种处理。设计糟糕的方法可能会屏蔽中断请求,从而导致调用栈中的其他代码无法对中断请求做出响应。
通常,中断是实现取消的最合理方式。
最合理的中断策略是某种形式的线程级(Thread-Level)取消操作或服务级(Service-Level)取消操作:尽快退出,在必要时进行清理,通知某个所有者该线程已经退出。
由于每个线程拥有各自的中断策略,因此除非你知道中断对该线程的含义,否则就不应该中断这个线程。
只有实现了线程中断策略的代码才可以屏蔽中断请求。在常规的任务和库代码中都不应该屏蔽中断请求。
程序清单给出了另一个版本的timedRun:将任务提交给一个ExecutorService,并通过一个定时的Future.get来获得结果。如果get在返回时抛出了一个TimeoutException,那么任务将通过它的Future来取消。如果任务在被取消前就抛出一个异常,那么该异常将重新抛出以便由调用者处理异常。在程序清单中还给出了一个良好的编程习惯:取消那些不再需要结果的任务。
public static void timeRun(Runnable r, long timeout, TimeUnit unit) throw InterruptedException {
Future task = taskExec.submit(r);
try {
task.get(timeout, unit);
} catch () {
//接下来任务将被取消
} catch () {
//如果在任务中抛出异常,那么重新抛出该异常
throw launderThrowable(e.getCause);
} finally {
//如果任务已经结束,那么执行取消操作也不会带来任何影响
task.cancel(true);//如果任务正在运行,那么将被取消
}
}
并非所有的可阻塞方法或者阻塞机制都能响应中断;如果一个线程由于执行同步的Socket I/O或者等待获取内置锁而阻塞,那么中断请求只能设置线程的中断状态,除此之外没有其他任何作用。对于那些由于执行不可中断操作而被阻塞的线程,可以使用类似于中断的手段来停止这些 线程,但这要求我们必须知道线程阻塞的原因。
与其他封装对象一样,线程的所有权是不可传递的:应用程序可以拥有服务,服务也可以拥有工作者线程,但应用程序并不能拥有工作者线程,因此应用程序不能直接停止工作者线程。相反,服务应该提供生命周期方法(Lifecycle Method)来关闭它自己以及它所拥有的线程。这样,当应用程序关闭该服务时,服务就可以关闭所有的线程了。在ExecutorService中提供了shutdown和shutDownNow等方法。同样,在其他拥有线程的服务中也应该提供类似的关闭机制。
对于持有线程的服务,只要服务的存在时间大于创建线程的方法的存在时间,那么就应该提供生命周期方法。
在运行时间较长的应用程序中,通常会为所有线程的未捕获异常指定同一个异常处理器,并且该处理器至少会将异常信息记录到日志中。
此外,守护线程通常不能用来替代应用程序管理程序中各个服务的生命周期。
避免使用终结器。
多线程的取消策略,可以设置一个volatile类型的变量表示取消状态,如果有线程修改了这个变量为取消,那么别的变量在执行的时候能够第一时间获取到这个信息,然后停止后续的操作。
但是取消状态这种玩法有一个问题就是遇到阻塞操作的时候,这个方式就会失效。如在生产者和消费者模式中,生产者通过阻塞队列BlockingQueue.put方法放置产品到队列中时,当队列满的时候put会阻塞生产者的线程,这时将取消标识修改了,此线程也不会去检查取消标识,此线程就无法取消这个操作。
前面提到了通过一个取消状态的标识位来进行判断,但是这个方法会因为阻塞方法而失效。而中断机制很好的处理了这个问题。总的来说,就是可以通过线程的方法interrupt方法来告知中断信息,敏感方法如sleep、wait、join会立即执行,其他的方法则是会在自己决定在取消点,即一个合适的时机进行中断。
InterruptException异常以前在使用一些线程相关的方法时会遇到过,以前的理解就是简单的如同系统异常的线程执行失败。现在看下来,其实这是一种可控制的异常,只要是反馈当前线程被中断,以及后续相关的操作,比如是结束线程的后续操作并返回结果,或者是尝试重新唤醒线程进行后续的操作。
遇到不可中断的阻塞操作时,就需要对症下药了。socket在io的时候不能中断,那么就先把socket关闭了,再把运行这个方法的线程中断就OK了。还有其他的可以参见书中的举例。
应用程序、服务和线程的关系:应用程序可能会有多个服务,每个服务有相应的线程支持,无论是一条还是多条。书中说应用程序不能直接操控线程,我的理解是不能别的服务来操控某个服务产生的线程。这其实也很好理解,毕竟本服务的线程被别的服务操控的话,这个服务就变得不可控了。而服务需要对自己产生的线程进行完整的生命周期的维护,从创建、运行到销毁。