第一章 简介
并发的优势
- 发挥多处理器的强大能力
- 建模的简单性 每个线程可以专注于自己的任务
- 异步事件的简化处理 单个线程可以根据自己的情况阻塞,而不会影响到其它任务
- 响应更灵敏的用户界面 现代GUI框架都采用事件分发线程机制,事件线程中执行耗时操作将导致程序卡顿,此时通过多线程处理耗时操作,可使用户界面具有更高灵敏性。
线程带来的风险
- 安全性问题 多个线程交替执行,程序执行结果可能错误
- 活跃性问题 不合理的设计,可能导致程序无法得到结果,例如死锁(10.1节)、饥饿(10.3.1节)、活锁(10.3.3节)等问题。
- 性能问题 不合理的设计,可能导致程序性能下降
笔记:安全性关注的是程序运行能否得到正确的结果;活跃性关注的是程序运行能否得到结果,比如单线程程序中的死循环,就是活跃性问题;性能关注的是指程序运行效率是否达到做大或者预期。
第二章 线程安全性
线程安全性
当多个线程访问某个类时,不管运行时环境采用何种调度方式或者这些线程将如何交替执行,并且在主调代码中不需要任何额外的同步或者协同,这个类都能表现出正确的行为,这么就称这个类是线程安全的。
什么是正确性?
正确性的含义是,某个类行为与其规范完全一致。在良好的规范中通常会定义各种不变性条件(Invariant)来约束对象的状态,以及定义各种后验条件(Postcondition)来描述对象操作的结果。
先验条件:针对方法,规定了在条用方法之前必须为真的条件。
后验条件:针对方法,规定了在条用方法之后必须为真的条件。
不变性条件:在程序执行过程或部分过程中,可始终被假定成立的条件,程序员往往使用断言来显式定义不变条件。
当设计线程安全的类时,良好的面向对象技术、不可修改性,以及明晰的不变性规范都能起到一定的帮助作用。
原子性
原子性是指任务在执行过程中不能被打断的一序列操作。
竞态条件(Race Condition)
竞态条件是指:当两个线程竞争同一资源时,如果对资源的访问顺序敏感,就称存在竞态条件。导致竞态条件发生的代码区称作临界区。
同时,引用一下维基百科的解释:
A race condition or race hazard is the condition of an electronics, software, or other system where the system's substantive behavior is dependent on the sequence or timing of other uncontrollable events. It becomes a bug when one or more of the possible behaviors is undesirable.
笔记:感觉Race Condition翻译为竞态条件过于抽象,Race Condition本身就是竟态的意思,后面再加上个条件反而不容易带入上下文。
复合操作
复合操作是一组必须以原子方式执行的操作。
在java.util.concurrent.atomic包中包含了一些原子变量类,用于实现在数值和对象引用上的原子状态转换。通过用AtomicLong来替代long类型的计数器,能够确保所有对计数器状态的访问都是原子的。
加锁机制
内置锁
Java提供了一种内置的锁机制来支持原子性:同步代码块(Synchronized Block)。同步代码块包括两部分:一个作为锁的对象引用,一个由这个锁保护的代码块。
每个Java对象都可以用作一个实现同步的锁,这些锁被称为内置锁(Intrinsic Lock)或监视器锁(Monitor Lock)。Java的内置锁是互斥锁,这意味着最多只能有一个线程能持有这种锁。
重入
当某个线程请求一个由其它线程持有的锁时,发出请求的线程就会阻塞,然而,由于内置的锁是可重入的,因此如果某个线程试图获得一个已经由它自己持有的锁,那么这个请求就会成功。
用锁来保护状态
由于锁能使其保护的代码路径以串行形式来访问,因此可以通过锁来构造一些协议,以实现对共享状态的独占访问,只要始终遵循这些协议,就能确保状态的一致性。
一种常见的加锁约定是,将所有的可变状态都封装在对象内部,并通过对象的内置锁对所有访问可变状态的代码路径进行同步,使得在该对象上不会发生并发访问。
活跃性与性能
同步机制需要保证合理,否则会引发活跃性或性能问题。
第三章 对象的共享
可见性
可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。
在没有同步的情况下,编译器、处理器以及运行时等都可能对操作的执行顺序进行一些意想不到的调整。在缺乏足够同步的多线程程序中,要想对内存操作的执行顺序进行判断,几乎无法得到正确的结论。
失效数据
缺乏同步的程序中,可能导致线程读到某个变量的失效值,这称为失效数据。
非原子的64位操作
Java内存模型要求,变量的读取操作和写入操作都必须是原子操作,但对于非volatile类型的long和double变量,JVM允许将64位的读操作或写操作分解为两个32位的操作。这也可能引起失效数据。
加锁和可见性
正确的加锁可以确保可见性。
volatile变量
Java语言提供了一种稍弱的同步机制,即volatile变量,用来确保将变量的更新操作通知到其它线程。当把变量声明为volatile类型后。编译器与运行时都会注意到这个变量是共享的,因此不会把该变量上的操作与其它内存操作一起重排序。volatile变量不会被缓存在寄存器或者对其他处理器不可见的地方,因此在读取volatile类型的变量时总会返回最新写入的值。
加锁机制既能确保可见性,又可以确保原子性。而volatile变量只能确保可见性。
当且仅当满足以下条件时,才应该使用volatile变量:
- 对变量的写入操作不依赖变量的当前值,或者你能确保只有单个线程更新变量的值。
- 该变量不会与其它状态变量一起纳入不变性条件中。
- 在访问变量时不需要加锁。
发布与逸出
“发布(Publish)”一个对象的意思是指,使对象能够在当前作用域之外的代码中使用。
当某个不应该发布的对象被发布时,这种情况就被称为逸出(Escape)。
发布对象常见方式:
- 放到公有静态变量中;
- 从非私有方法中返回一个引用;
- 当发布一个对象时,在该对象的非私有域中引用的所有对象同样会被发布;
- 发布内部类的实例,也会隐含的发布外部类本身。(内部类包含外部类的引用);
安全的对象构造过程
不要在构造过程中使this引用逸出。当且仅当对象的构造函数返回时,对象才处于可预测的和一致的状态。
如果想在构造函数中注册一个事件监听器或启动线程,那么可以使用一个私有的构造函数和一个公共的工厂方法(Factory Method),从而避免不正确的构造过程。
线程封闭
当访问共享的可变数据时,通常需要使用同步。一种避免使用同步的方式,就是不共享数据。如果仅在单线程内访问数据,就不需要同步。这种技术被称为线程封闭(Thread Confinement)。它是实现线程安全性的最简单方式之一。
Java语言及其核心库提供了一些机制来帮助维持线程封闭性,例如与局部变量和ThreadLocal类。
Ad-hoc线程封闭
Ad-hoc线程封闭是指,维护线程封闭性的职责完全由程序实现来承担。
栈封闭
栈封闭式线程封闭的一种特例。在栈封闭中,只能通过局部变量访问对象。
ThreadLocal类
维持线程封闭性的一种更规范方法是使用ThreadLocal,这个类能使线程中的某个值与保存值的对象关联起来。ThreadLocal提供了get与set等访问接口或方法,这些方法为每个使用该变量的线程都存有一份独立的副本,因此,get总是返回由当前执行线程在调用set时设置的最新值。
不变性
满足同步需求的另一种方法是使用不可变对象(Immutable Object)。
不可变对象一定是线程安全的。
当满足以下条件时,对象才是不可变的:
- 对象创建以后其状态就不能修改。
- 对象的所有域都是final类型。
- 对象是正确创建的(在对象的创建期间,this引用没有逸出)。
final域能确保初始化过程的安全性,从而可以不受限制的访问不可变对象,并在共享这些对象时无须同步。
通过volatile类型引用指向不可变容器对象,也是确保线程安全性的一种手段。
安全发布
不可变对象与初始化安全性
任何线程都可以在不需要额外同步的情况下安全地访问不可变对象,即使在发布这些对象时没有使用同步。
不可变对象保证线程安全的前提是安全的进行了初始化,为了维持这种初始化安全性的保证,必须满足不可变性的所有需求:状态不可修改,所有域都是final类型,以及正确的构造过程。
安全发布的常用模式
要安全的发布一个对象,对象的引用以及对象的状态必须同时对其他线程可见。一个正确构造的对象可以通过以下方式来安全地发布:
- 在静态初始化函数中初始化一个对象引用。
- 将对象的引用保存在volatile类型的域或者
AtomicReference对象中。 - 将对象的引用保存到某个正确构造对象的final类型域中。
- 将对象的引用保存在一个由锁保护的域中。
事实不可变对象
如果对象从技术上来看是可变的,但其状态待发布后不会再改变,那么就把这种对象称为“事实不可变对象(Effectively Immutable Object)”。
但没有额外同步的情况下,任何线程都可以安全地使用被安全发布的事实不可变对象。
例如Data本身是可变的,但是安全创建之后,其它地方只读取,不修改,那么它就成为了事实不可变对象。
可变对象
要安全的共享可变对象,这些对象就必须被安全的发布,并且必须是线程安全的或者由某个锁保护起来。
对象的组合
设计线程安全的类
在设计线程安全类的过程中,需要包括以下三个基本要素:
- 找出构成对象状态的所有变量。
- 找出约束状态变量的不变性条件。
- 建立对象状态的并发访问管理策略。
收集同步需求
要确保类的线程安全性,就需要确保它的不变性条件不会在并发访问的情况下被破坏,这就需要对其状态进行推断。同样在操作中,还会包含一些后验条件来判断状态迁移是否是有效的。由于不变性条件以及后验条件在状态及状态转换上施加的各种约束,因此就需要额外的同步与封装。
依赖状态的操作
类的不变性条件与后验条件约束了在对象上有哪些状态和状态转换是有效的。但某些对象的方法中还包含一些基于状态的先验条件(Precondition)。
在单线程程序中,如果某个操作无法满足先验条件,那么就只能失败。但在并发程序中,先验条件可能会由其他线程执行的操作而变成真,在并发程序中,要一直等到先验条件为真,然后再执行该操作。我们熟悉的等待和通知机制,阻塞队列等,都是为这些情况服务的。
状态的所有权
状态变量所有者将决定采用何种加锁协议来维持变量状态的完整性,所有权意味着控制权。
实例封闭
如果某对象不是线程安全的。那么可以通过多种技术使其在多线程程序中安全的使用。你可以确保该对象只能由单个线程访问(线程封闭),或者通过一个锁来保护对该对象的所有访问。
封装简化了线程安全类的实现过程,它提供了一种实例封闭机制(Instance Confinement),通常也简称为“封闭”。当一个对象被封装到另一个对象中时,能够访问被封装对象的服务代码路径都是已知的。与对象可以由整个程序访问的情况相比,更易于对代码进行分析。通过将封闭机制与合适的加锁策略结合起来,可以确保以线程安全的方式来使用非线程安全对象。
Java监视器模式
一个对象,把所有可变状态都封装起来,并由对象自己的内置锁来保护,这称为Java监视器模式。
线程安全性的委托
大多数对象都是组合对象。有时可以将类的安全性委托给其内部状态来实现该类的安全性。例如,一个无状态的类中增加了一个AtomicLong类型的域,那么这个组合对象仍然是线程安全的,而安全性,是由AtomicLong保证的。当然,如果类中存在线程安全的状态变量,而它们之间没有耦合关系,那么这个组合类仍然是线程安全的。
如果一个状态变量是线程安全的,并且没有任何不变性条件来约束它的值,在变量的操作上也不存在任何不允许的状态转换,那么就可以安全的发布这个变量。
在现有的线程安全类中添加功能
-
修改原始的类,但这通常无法做到。
-
扩展这个类,需要类本身支持扩展。
-
客户端加锁,使用合适的锁保护同步代码块中对线程安全类的操作来保证安全性,这种方式可能破坏同步策略的封装性。
-
组合,将现有线程安全类组合到新的类中,借用部分功能,也可以扩展功能。
将同步策略文档化
在维护线程安全性时,文档是最强大的工具之一。在文档中说明客户代码需要了解的线程安全性保证,以及代码维护人员需要了解的同步策略。
第五章 基础构建模块
同步容器类
同步容器类包括Victor和Hashtable,二者是早期JDK的一部分,此外还包括在JDK1.2中添加的一些功能相似的类,这些同步的封装类是由Collections.synchronizedXxx等工厂方法创建的。这些类实现线程安全的方式是将他们的状态封装起来,并对每个公有方法都进行同步,使得每次只有一个线程能访问容器的状态。
同步容器类的问题
同步容器类都是线程安全的,但在某些情况下,可能需要额外的客户端加锁来保护复合操作。常见的复合操作包括迭代、跳转以及条件运算。在同步容器类中,这些复合操作在没有客户端加锁的的情况下仍然是线程安全的,但当其他线程并发的修改容器时,他们可能会表现出意料之外的行为。
如下所示,哪怕Vector是线程安全的,复合操作仍然可能导致写过混乱:
package net.jcip.examples;
import java.util.*;
/**
* UnsafeVectorHelpers
* <p/>
* Compound actions on a Vector that may produce confusing results
*
* @author Brian Goetz and Tim Peierls
*/
public class UnsafeVectorHelpers {
public static Object getLast(Vector list) {
int lastIndex = list.size() - 1;
return list.get(lastIndex);
}
public static void deleteLast(Vector list) {
int lastIndex = list.size() - 1;
list.remove(lastIndex);
}
}
迭代器与ConcurrentModificationException
无论在直接迭代还是在Java 5.0引入的for-each循环语法中,对容器内进行迭代的标准方式都是使用Iterator。然而,如果有其他线程并发地修改容器,那么即使是使用迭代器也无法避免在迭代期间对容器进行加锁。在设计同步容器类的迭代器时,并没有考虑到并发修改的问题。并且它们表现出的行为是“及时失败”(fail-fast)的。这意味着,当他们发现容器在迭代过程中被修改时。就会抛出一个ConcorrectModificationException。
它们采用的实现方式是,将计数器的变化与容器关联起来:如果在迭代期间计数器被修改,那么hasNext或next将抛出ConCurrentModificationException。然而这种检查是在没有同步的情况下进行的,因此可能会看到失效的计数值,而迭代器可能并没有意识到已经发生了修改,这是一种设计上的权衡,从而降低并发修改操作的检测代码对程序性能带来的影响。
以ArrayList为例,在其父类AbstractList中存在modCount成员变量:
protected transient int modCount = 0;
每次调用add()方法或者remove()方法就会对modCount进行加1操作,而在使用Iterator遍历时,其next()方法会检查modCount值是否会变化,变化了就抛出ConcurrentModificationException异常。
public E next() {
// 检查modCount值是否会变化,变化了就抛出ConcurrentModificationException异常
checkForComodification();
try {
E next = get(cursor);
lastRet = cursor++;
return next;
} catch (IndexOutOfBoundsException e) {
checkForComodification();
throw new NoSuchElementException();
}
}
隐藏迭代器
某些调用会隐式调用容器的迭代操作,需要格外注意,例如,标准容器的toString()方法将迭代容器,并在每个元素上调用toString()来生成容器内容的格式化表示。容器的hashCode和equals等方法也会间接地执行迭代操作。
并发容器
同步容器将所有对容器状态的访问都串行化,以实现他们的线程安全性。这种方法的代价是严重降低并发性,当多个线程竞争容器的琐时吞吐量将严重减低。
并发容器是针对多个线程并发访问设计的。通过并发容器来替代同步容器,可以极大地提高伸缩性并降低风险。
在Java 5.0中增加了ConcurrentHashMap,用来替代同步且基于散列的Map,以及CopyOnWriteArrayList,用于在遍历操作为主要操作的情况下代替同步的List。在新的ConcurrentMap接口中增加了对一些常见复合操作的支持,例如“若没有则添加”、替换以及有条件删除等。
Java 5.0增加了两种新的容器类型:Queue和BlockingQueue。
Queue用来临时保存一组待处理的元素,Queue上的操作不会阻塞,如果队列为空,那么获取元素的操作将返回空值。它提供了几种实现,包括:
ConcurrentLinkedQueue,传统的先进先出队列PriorityQueue,(非并发的)优先队列
BlockingQueue扩展了Queue,增加了可阻塞的插入和获取等操作。
Java 6引入和ConcurrentSkipListMap和ConcurrentSkipListSet,分别作为同步的SortedMap和SortedSet的并发替代品。
ConcurrentHashMap
ConcurrentHashMap与HashMap一样,都是基于散列的Map,但它使用了分段锁(11.4.3)来提供更高的并发性和伸缩性。任意数量的读取线程可以并发的访问Map,执行读取操作的线程和执行写入操作的线程可以并发的访问Map,并且一定数量的Map可以并发的修改Map。ConcurrentHashMap带来的结果是,在并发访问的环境下将实现更高的吞吐量,而在单线程环境中只损失非常小的性能。只有当应用程序需要加锁Map以进行独占访问时,才应该放弃使用ConcurrentHashMap。
ConcurrentHashMap返回的迭代器具有弱一致性(Weakly Consistent)。弱一致性的迭代器可以容忍并发的修改,当创建迭代器时会遍历已有的元素,并可以(但是不保证)在迭代器被构造后将修改操作反映给容器。
ConcurrentHashMap不能被加锁来执行独占访问,因此我们无法使用客户端加锁来创建新的原子操作。
较详细的ConcurrentHashMap解释参考这里。
CopyOnWriteArrayList
CopyOnWriteArrayList用来替代同步List,在某些情况下它提供了更好的并发性能,并且在迭代期间不需要进行加锁和复制。(类似的,CopyOnWriteArraySet的作用是替代同步Set)
每次修改时,都会创建并重新发布一个新的容器副本,从而实现可变性。仅当迭代操作远远多于修改操作时,才应该使用“写入时复制”容器。
阻塞队列和生产者-消费者模式
阻塞队列提供了可阻塞的put和take方法,以及支持定时的off和poll方法。如果队列已经满了,那么put的方法将阻塞直到有空间可用;如果队列为空,那么take方法将会阻塞直到有元素可用。队列可以是有界的也可以是无界的,无界队列永远都不会充满,因此无界队列上的put方法也永远不会堵塞。
在类库中包含了BlockingQueue的多种实现,其中LinkedBlockingQueue和ArrayBlockingQueue是FIFO队列,二者分别为LinkedList和ArrayList类似,但比同步List拥有更好的并发性。PriorityBlockingQueue是一个按优先级排序的队列,当你希望按照某种顺序而不是FIFO来该处理元素时,这个队列将非常有用。其他有序容器一样,PriorityBlockingQueue既可以根据元素的自然顺序来比较元素(如果他们实现了Comparable方法),也可以使用Comparator来比较。
SynchronousQueue,实际上他不是一个真正的队列,因为她不会为队列中元素维护存储空间。与其他队列不同的是,它维护一组线程,这些线程在等待着把元素加入或移除队列。它有一下特点:
- 内部没有存储(容量为0)
- 阻塞队列(也是blockingqueue的一个实现)
- 发送或者消费线程会阻塞,只有有一对消费和发送线程匹配上,才同时退出。
- 配对有公平模式和非公平模式(默认)
串行线程封闭
对于可变对象,生产者-消费者这种设计与阻塞队列一起促进了串行线程封闭,从而将对象的所有权从生产者交付到消费者。线程封闭对象只能由单个线程拥有,但可以通过安全的发布该对象来“转移”所有权。在转移所有权后,也只有另一个线程能获得这个对象的访问权限,并且发布对象的线程不会再访问它。这种安全的发布确保了对象状态对于新的所有者来说是可见的,并且由于最初的的所有者不会再访问它,因此对象将被封闭的新的线程中。新的所有者线程可以对对象做任意修改,因为它具有独占的访问权。
双端队列与工作密取
Java 6增加了两种容器类型,Deque(发音为“deck”)和BlockingDeque,它们分别对Queue和BlockingQueue进行了扩展。Deque是一个双端队列,实现了在队列头和队列尾的高效插入。具体实现包括ArrayDeque LinkedBlockingDeque。
双端队列适合工作密取(Work Stealing)。工作密取设计中,每个消费者都有各自的双端队列。如果一个消费者完成了自己双端队列中的全部工作,那么它可以从其它消费者双端队列末尾秘密地获取工作。
阻塞方法和中断方法
线程可能会堵塞或暂停执行,原因有多种,等待I/O操作结束,等待获得一个锁,等待从Thread.sleep方法中醒来,或者是等待另一个线程的计算结果。当线程阻塞时,它通常被挂起。并处于某种阻塞状态(BLOCKED,WAITING或TIMED_WAITING)。阻塞操作与执行时间很长的普通操作的差别在于,被阻塞的线程必须等待某个不受它控制的事件发生后才能继续执行,例如等待I/O操作完成,等待某个锁变成可用,或者等待外部计算的结束。当某个外部事件发生时,线程被置回RUNNABLE状态,并可以再次被调度执行。
Thread提供了interrupt方法,用于中断线程或者查询线程是否已经被中断。每个线程都有一个布尔类型的属性,表示线程的中断状态,当中断线程时将设置这个状态。
响应库代码的中断异常,有两种基本的选择:
- 传递
InteruptedException。根本不捕获该异常,或者捕获该异常,然后在执行某种简单的清理工作后再次抛出这个异常。 - 恢复中断。阻塞库方法,例如
Thread.sleep和Object.wait等,都会检查线程何时中断,并且在发现中断时提前返回。它们在响应中断时执行的操作包括:清除中断状态;抛出InterruptedException,表示阻塞操作由于中断而提前结束。此时,我们捕获到异常后,应该调用Thread.currentThread().interrupt()方法恢复中断,以便上层逻辑判断是否发生了中断,进一步处理。
同步工具类
闭锁
闭锁是一种同步工具类,可以延迟线程的进度直到到达中止状态。一个闭锁工作起来就像是一道大门:直到闭锁达到终点状态之前,门一直是关闭的,没有线程能够通过,在终点状态到来的时候,所有线程都可以通过。
闭锁可以用来确保特定活动直到其他的活动都完成后才开始发生,比如:
- 确保一个计算不会执行,直到它所需要的资源被初始化
- 确保一个服务不会开始,直到它依赖的其他服务都已经开始
- 等待,直到活动的所有部分都为继续处理做好充分准备
- 死锁检测,可以使用n个线程访问共享资源,在每次测试阶段的线程数目是不同的,并尝试产生死锁
CountDownLatch是一种灵活的闭锁实现。
public void countDown()
public void await()
FutureTask
FutureTask一个可取消的异步计算,FutureTask 实现了Future的基本方法,提空 start、cancel 操作,可以查询计算是否已经完成,并且可以获取计算的结果。结果只可以在计算完成之后获取,get方法会阻塞当计算没有完成的时候,一旦计算已经完成,那么计算就不能再次启动或是取消。
一个FutureTask 可以用来包装一个 Callable 或是一个runnable对象。因为FurtureTask实现了Runnable方法,所以一个 FutureTask可以提交(submit)给一个Excutor执行(`excution
public FutureTask(Callable<V> callable)
public V get() throws InterruptedException, ExecutionException
例:
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.FutureTask;
public class FutureTaskExample {
public static void main(String[] args) {
// 创建一个 Callable 对象
Callable<Integer> callable = () -> {
// 模拟耗时计算
Thread.sleep(2000);
return 42;
};
// 创建 FutureTask 对象
FutureTask<Integer> futureTask = new FutureTask<>(callable);
// 创建线程执行 FutureTask
Thread thread = new Thread(futureTask);
thread.start();
// 在主线程中执行其他操作
try {
// 获取任务的结果,阻塞直到任务完成
int result = futureTask.get();
System.out.println("任务结果:" + result);
} catch (InterruptedException | ExecutionException e) {
e.printStackTrace();
}
}
}
信号量
信号量(Semaphore)是用来保护一个或者多个共享资源的访问,Semaphore内部维护了一个计数器,其值为可以访问的共享资源的个数。一个线程要访问共享资源,先获得信号量,如果信号量的计数器值大于1,意味着有共享资源可以访问,则使其计数器值减去1,再访问共享资源。
如果计数器值为0,线程进入休眠。当某个线程使用完共享资源后,释放信号量,并将信号量内部的计数器加1,之前进入休眠的线程将被唤醒并再次试图获得信号量。
public void acquire(int permits) throws InterruptedException
public void release()
例:
import java.util.concurrent.Semaphore;
public class SemaphoreExample {
public static void main(String[] args) {
// 创建 Semaphore 对象,初始许可数量为3
Semaphore semaphore = new Semaphore(3);
// 创建并启动多个线程
for (int i = 1; i <= 5; i++) {
Thread thread = new Thread(new Worker(semaphore, i));
thread.start();
}
}
}
class Worker implements Runnable {
private Semaphore semaphore;
private int id;
public Worker(Semaphore semaphore, int id) {
this.semaphore = semaphore;
this.id = id;
}
@Override
public void run() {
try {
System.out.println("Worker " + id + " is trying to acquire semaphore.");
semaphore.acquire(); // 获取许可
System.out.println("Worker " + id + " has acquired semaphore. Performing task...");
Thread.sleep(2000); // 模拟任务执行时间
System.out.println("Worker " + id + " has completed the task. Releasing semaphore.");
semaphore.release(); // 释放许可
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
在上述示例中,创建了一个初始许可数量为 3 的 Semaphore 对象。然后,创建了 5 个 Worker 线程,并在每个线程中执行任务。在任务执行之前,线程会调用 acquire() 方法来获取许可,如果当前可用的许可数量为 0,则线程会被阻塞,直到有许可可用。任务执行完成后,线程会调用 release() 方法释放许可。
栅栏
栅栏(Barrier)类似于闭锁,他能阻塞一组线程直到某个事件发生。栅栏与闭锁的关键区别在于,所有线程必须都到达了栅栏位置,才能继续执行,在此之前,所有线程都会阻塞在那里。
CyclicBarrier可以使一定数量的参与方反复地在栅栏位置汇集,它在并行迭代算法中非常有用。
public CyclicBarrier(int parties, Runnable barrierAction)
public int await() throws InterruptedException, BrokenBarrierException
例:
import java.util.concurrent.BrokenBarrierException;
import java.util.concurrent.CyclicBarrier;
public class CyclicBarrierExample {
public static void main(String[] args) {
int threadCount = 3; // 线程数量
Runnable barrierAction = () -> {
System.out.println("All threads have reached the barrier. Barrier action is executed.");
};
// 创建 CyclicBarrier 对象,指定线程数量和屏障动作
CyclicBarrier barrier = new CyclicBarrier(threadCount, barrierAction);
// 创建并启动多个线程
for (int i = 0; i < threadCount; i++) {
Thread thread = new Thread(new Worker(barrier, i));
thread.start();
}
}
}
class Worker implements Runnable {
private CyclicBarrier barrier;
private int id;
public Worker(CyclicBarrier barrier, int id) {
this.barrier = barrier;
this.id = id;
}
@Override
public void run() {
try {
System.out.println("Worker " + id + " is performing some work.");
Thread.sleep(2000); // 模拟工作时间
System.out.println("Worker " + id + " has reached the barrier.");
barrier.await(); // 等待其他线程到达屏障
System.out.println("Worker " + id + " continues to work after the barrier.");
} catch (InterruptedException | BrokenBarrierException e) {
e.printStackTrace();
}
}
}
在上述示例中,创建了一个 CyclicBarrier 对象,指定了线程数量为 3 和屏障动作(即当所有线程都到达屏障时执行的动作)。然后,创建了 3 个 Worker 线程,并在每个线程中执行任务。每个 Worker 线程会先执行一些工作,然后调用 await() 方法等待其他线程到达屏障。当所有线程都到达屏障时,会执行指定的屏障动作,然后所有线程继续执行后续的工作。
第一部分小结
-
可变状态是至关重要的(It's the mutable state,stupid)。 所有的并发问题都可以归结为如何协调对并发状态的访问。可变状态越少,就越容易确保线程安全性。
-
尽量将域声明为final 类型,除非需要它们是可变的。
-
不可变对象一定是线程安全的。 不可变对象能极大地降低并发编程的复杂性。他们更为简单而且安全,可以任意共享而无需使用加锁或保护性复制等机制。
-
封装有利于管理复杂性。 在编写线程安全的程序时,虽然可以将所有数据都保存在全局变量中,但为什么要这样做?将数据封装在对象中,更易于维持不变性条件:将机制封装在对象中更易于遵循同步策略。
-
用锁来保护每个可变变量。
-
当保护同一个不变性条件中的所有变量时,要使用同一个锁。
-
在执行复合操作期间,要持有锁。
-
如果从多个线程中访问同一个可变变量没有同步机制,那么程序就会出现问题。
-
在设计过程中考虑线程安全,或者在文档中明确地指出它不是线程安全的。
-
将同步策略文档化。