并发运行的最佳实践
本文是我们名为“ 高级Java ”的学院课程的一部分。
本课程旨在帮助您最有效地使用Java。 它讨论了高级主题,包括对象创建,并发,序列化,反射等。 它将指导您完成Java掌握的旅程! 在这里查看 !
目录
1.简介
多处理器和多核硬件体系结构极大地影响了当今运行在其上的应用程序的设计和执行模型。 为了充分利用可用计算单元的功能,应用程序应准备好支持多个同时执行并竞争资源和内存的执行流。 并发编程带来了许多与数据访问和不确定的事件流相关的挑战,这些挑战可能导致意外的崩溃和奇怪的故障。
在本教程的这一部分中,我们将研究Java可以为开发人员提供什么,以帮助他们在并发世界中编写健壮而安全的应用程序。
2.线程和线程组
线程是Java中并发应用程序的基础构建块。 线程有时被称为轻量级进程 ,它们允许多个执行流并发进行。 Java中的每个应用程序都有至少一个称为主线程的线程 。 每个Java线程仅存在于JVM内部,并且可能不反映任何操作系统线程。
Java中的Thread
是Thread
类的实例。 通常,不建议使用Thread类的实例直接创建和管理线程(“ 期货和执行器”一节中介绍的执行器和线程池提供了一种更好的方法),但是这样做非常容易:
public static void main(String[] args) {
new Thread( new Runnable() {
@Override
public void run() {
// Some implementation here
}
} ).start();
}
或使用Java 8 lambda函数的相同示例:
public static void main(String[] args) {
new Thread( () -> { /* Some implementation here */ } ).start();
}
尽管如此,用Java创建新线程看起来非常简单,线程具有复杂的生命周期,并且可以处于以下状态之一(在给定的时间点,线程只能处于一种状态)。
线程状态 | 描述 |
NEW | 尚未启动的线程处于此状态。 |
RUNNABLE | 在Java虚拟机中执行的线程处于这种状态。 |
BLOCKED | 等待监视器锁定而被阻塞的线程处于此状态。 |
WAITING | 无限期地等待另一个线程执行特定操作的线程处于此状态。 |
TIMED_WAITING | 无限期地等待另一个线程执行特定操作的线程处于此状态。 |
TERMINATED | 退出的线程处于此状态。 |
表格1
目前并不是所有的线程状态都明确,但是在本教程的后面,我们将介绍其中的大部分内容,并讨论导致线程处于一种或另一种状态的事件类型。
线程可以组装成组。 线程组代表一组线程,也可以包括其他线程组(因此形成树)。 线程组旨在成为一个不错的功能,但是由于执行程序和线程池(请参阅Futures,Executor和Thread Pools )是更好的替代方法,因此如今不建议使用它们。
3.并发,同步和不变性
在几乎每个Java应用程序中,都需要多个运行线程相互通信并访问共享数据。 读取这些数据并不是什么大问题,但是对其进行不协调的修改将直接导致灾难(所谓的赛车状况)。 这就是英寸同步 同步踢是确保在同一时间几个同时运行的线程将不执行的应用程序代码的具体守卫(同步)块中的机构的点。 如果其中一个线程已开始执行代码的同步块,则任何其他试图执行同一块的线程都必须等待,直到第一个线程完成。
Java语言具有内置的形式同步支持synchronized
关键字。 该关键字可以应用于实例方法,静态方法,也可以在任意执行块周围使用,并确保一次只能有一个线程能够调用它。 例如:
public synchronized void performAction() {
// Some implementation here
}
public static synchronized void performClassAction() {
// Some implementation here
}
或者,使用与代码块同步的示例:
public void performActionBlock() {
synchronized( this ) {
// Some implementation here
}
}
synchronized
关键字的另一个非常重要的作用是:它会为同一对象的synchronized
方法或代码块的任何调用自动建立一个事前发生的关系( http://en.wikipedia.org/wiki/Happened-before )。 这保证了对象状态的更改对所有线程都是可见的。
请注意,构造函数无法同步(将synchronized
关键字与构造函数一起使用会引起编译器错误),因为在构造实例时,只有创建实例的线程才能访问它。
在Java中,同步是围绕称为监视器的内部实体(或固有/监视器锁, http://en.wikipedia.org/wiki/Monitor_ (synchronization))构建的。 Monitor强制对对象状态进行独占访问,并建立事前关联。 当任何线程调用synchronized
方法时,它会自动获取该方法实例(或静态方法中的类)的内在(监视器)锁,并在方法返回时释放它。
最后,同步是Java可重入的 :这意味着线程可以获取它已经拥有的锁。 由于线程具有较少的阻塞自身的机会,因此可重入性大大简化了并发应用程序的编程模型。
如您所见,并发在Java应用程序中引入了很多复杂性。 但是,有一种解决方法: 不变性 。 我们已经讨论过很多次了,但是对于多线程应用程序来说,这确实非常重要:不可变对象不需要同步,因为它们永远不会被多个线程更新。
4.期货,执行人和线程池
用Java创建新线程很容易,但是管理它们确实很困难。 Java标准库以执行程序和线程池的形式提供了极其有用的抽象,旨在简化线程管理。
本质上,在其最简单的实现中,线程池创建并维护线程列表,这些线程列表可立即使用。 应用程序无需每次都生成新线程,而只是从池中借用一个(或需要的数量)。 一旦借用的线程完成其工作,它将返回到池中,并可以用来接管下一个任务。
尽管可以直接使用线程池,但是Java标准库提供了执行程序外观,该外观具有一组工厂方法来创建常用的线程池配置。 例如,下面的代码段创建了一个具有固定线程数(10)的线程池:
ExecutorService executor = Executors.newFixedThreadPool( 10 );
执行程序可用于卸载任何任务,因此它将在线程池中的单独线程中执行(注意,不建议将执行程序用于长时间运行的任务)。 执行程序的外观允许自定义基础线程池的行为,并支持以下配置:
方法 | 描述 |
Executors.newCachedThreadPool | 创建一个线程池,该线程池根据需要创建新线程,但是将在先前构造的线程可用时重用它们。 |
Executors.newFixedThreadPool | 创建一个线程池,该线程池重用在共享的无边界队列上运行的固定数量的线程。 |
Executors.newScheduledThreadPool | 创建一个线程池,该线程池可以计划命令在给定的延迟后运行或定期执行。 |
Executors.newSingleThreadExecutor | 创建一个执行程序,该执行程序使用在不受限制的队列上操作的单个工作线程。 |
Executors.newSingleThreadScheduledExecutor | 创建一个单线程执行器,该执行器可以安排命令在给定的延迟后运行或定期执行。 |
表2
在某些情况下,执行的结果不是很重要,因此执行器支持即发即弃的语义,例如:
executor.execute( new Runnable() {
@Override
public void run() {
// Some implementation here
}
} );
等效的Java 8示例更加简洁:
executor.execute( () -> {
// Some implementation here
} );
但是,如果执行结果很重要,则Java标准库将提供另一个抽象来表示将来将要发生的计算,称为Future<T>
。 例如:
Future< Long > result = executor.submit( new Callable< Long >() {
@Override
public Long call() throws Exception {
// Some implementation here
return ...;
}
} );
Future<T>
的结果可能无法立即获得,因此应用程序应使用一系列get(…)
方法来等待它。 例如:
Long value = result.get( 1, TimeUnit.SECONDS );
如果计算结果在指定的超时时间内不可用,则将引发TimeoutException
异常。 有一个重载版本的get()
会一直等待,但是请您优先使用超时的版本。
自Java 8发行以来,开发人员拥有Future<T>
另一个版本CompletableFuture<T>
,该版本支持在完成时触发的附加功能和操作。 不仅如此,随着流的引入,Java 8引入了一种简单,非常直接的方式来使用parallelStream()
方法执行并行收集处理,例如:
final Collection< String > strings = new ArrayList<>();
// Some implementation here
final int sumOfLengths = strings.parallelStream()
.filter( str -> !str.isEmpty() )
.mapToInt( str -> str.length() )
.sum();
执行程序和并行流带到Java平台的简单性使Java中的并行和并行编程变得更加容易。 但是有一个陷阱:线程池和并行流的不受控制的创建可能会破坏应用程序的性能,因此相应地对其进行管理很重要。
5.锁
除了监视器之外,Java还支持可重入互斥锁(具有与监视器锁相同的基本行为和语义,但具有更多功能)。 这些锁可通过java.util.concurrent.locks
包中的ReentrantLock
类获得。 这是一个典型的锁用法习惯用法:
private final ReentrantLock lock = new ReentrantLock();
public void performAction() {
lock.lock();
try {
// Some implementation here
} finally {
lock.unlock();
}
}
请注意,必须通过调用unlock()
方法显式地释放任何锁(对于synchronized
方法和执行块,Java编译器会发出释放监视器锁的指令)。 如果锁需要编写更多的代码,为什么它们比监视器更好? 好吧,出于几个原因,但最重要的是,锁可以在等待获取时使用超时并快速失败(监控器始终无限期地等待,并且无法指定所需的超时)。 例如:
public void performActionWithTimeout() throws InterruptedException {
if( lock.tryLock( 1, TimeUnit.SECONDS ) ) {
try {
// Some implementation here
} finally {
lock.unlock();
}
}
}
现在,当我们对监视器和锁有了足够的了解时,让我们讨论它们的使用如何影响线程状态。
当任何线程正在使用lock()
方法调用等待锁(由另一个线程获取lock()
,它处于WAITING
状态。 但是,当任何线程正在使用带有超时的tryLock()
方法调用来等待锁(由另一个线程获取tryLock()
时,它处于TIMED_WAITING
状态。 相反,当任何线程正在使用synchronized
方法或执行块等待监视器(由另一个线程获取)时,它处于BLOCKED
状态。
到目前为止,我们看到的示例非常简单,但是锁管理确实很困难,而且充满陷阱。 其中最臭名昭著的是僵局:两个或两个以上竞争线程正在彼此等待进行的情况,因此从来没有这样做。 当涉及多个锁或监视器锁时,通常会发生死锁。 JVM通常能够检测正在运行的应用程序中的死锁并警告开发人员(请参阅“并发问题疑难解答”部分)。 僵局的典型示例如下所示:
private final ReentrantLock lock1 = new ReentrantLock();
private final ReentrantLock lock2 = new ReentrantLock();
public void performAction() {
lock1.lock();
try {
// Some implementation here
try {
lock2.lock();
// Some implementation here
} finally {
lock2.unlock();
}
// Some implementation here
} finally {
lock1.unlock();
}
}
public void performAnotherAction() {
lock2.lock();
try {
// Some implementation here
try {
lock1.lock();
// Some implementation here
} finally {
lock1.unlock();
}
// Some implementation here
} finally {
lock2.unlock();
}
}
所述performAction()
方法试图获取lock1
,然后lock2
,而方法performAnotherAction()
做它在不同的顺序, lock2
,然后lock1
。 如果通过程序执行流在两个不同线程中的同一类实例上调用这两个方法,则极有可能发生死锁:第一个线程将无限期地等待第二个线程获取的lock2
,而第二个线程将无限期等待无限期地等待第一个获得的lock1
。
6.线程调度器
在JVM中,线程调度程序确定应运行哪个线程以及运行多长时间。 由Java应用程序创建的所有线程都具有优先级,该优先级在决定何时调度线程及其时间范围时会基本影响线程调度算法。 但是,此功能的声誉是不可移植的(因为大多数技巧都依赖于线程调度程序的特定行为)。
Thread
类还通过使用yield()
方法提供了另一种干预线程调度实现的方法。 它暗示线程调度程序当前线程愿意放弃其当前使用的处理器时间(并且还具有不可移植的声誉)。
总的来说,依靠Java线程调度程序的实现细节并不是一个好主意。 这就是为什么Java标准库中的执行者和线程池(请参阅“ 期货,执行者和线程池”部分)尝试不向开发人员公开那些不可移植的细节(但如果确实有必要的话,仍然可以这样做) )。 没有什么比精心设计更好地工作了,精心设计试图考虑应用程序所运行的实际硬件(例如,使用Runtime
类可以轻松检索可用CPU和内核的数量)。
7.原子操作
在多线程世界中,有一组特定的指令称为比较交换 (CAS)。 这些指令将其值与给定值进行比较,并且只有它们相同时,才设置新的给定值。 这是作为单个原子操作完成的,通常无锁且高效。
Java标准库中有大量支持原子操作的类列表,所有这些类都位于java.util.concurrent.atomic
包下。
类 | 描述 |
AtomicBoolean | 可以自动更新的布尔值 |
AtomicInteger | 一个可以自动更新的int值。 |
AtomicIntegerArray | 一个长值,可以原子更新。 |
AtomicLongArray | 一个长数组,其中的元素可以原子更新。 |
AtomicReference<V> | 可以原子更新的对象引用。 |
AtomicReferenceArray<E> | 对象引用的数组,其中的元素可以原子更新。 |
表3
Java 8版本通过一组新的原子操作(累加器和加法器)扩展了java.util.concurrent.atomic
。
类 | 描述 |
DoubleAccumulator | 一个或多个变量一起保持使用提供的函数更新的运行双精度值。 |
DoubleAdder | 一个或多个变量共同保持初始为零的双和。 |
LongAccumulator | 一个或多个变量一起保持使用提供的函数更新的运行时长值。 |
LongAdder | 一个或多个变量共同保持初始为零的长和。 |
表4
8.并行收集
可以由多个线程访问和修改的共享集合不是一个例外,而是一个规则。 Java标准库在Collections
类中提供了两个有用的静态方法,这些方法使任何现有的collection都是线程安全的。 例如:
final Set< String > strings =
Collections.synchronizedSet( new HashSet< String >() );
final Map< String, String > keys =
Collections.synchronizedMap( new HashMap< String, String >() );
但是,返回的通用集合包装器是线程安全的,通常不是最好的选择,因为它们在实际应用程序中的性能相当中等。 这就是为什么Java标准库包含一组针对并发调整的丰富的收集类的原因。 以下只是使用最广泛的列表,所有列表都托管在java.util.concurrent
包下。
类 | 描述 |
ArrayBlockingQueue<E> | 由数组支持的有界阻塞队列。 |
ConcurrentHashMap<K,V> | 一个哈希表,它支持检索的完全并发性和可更新的可调整预期并发性。 |
ConcurrentLinkedDeque<E> | 基于链接节点的无限制并发双端队列。 |
ConcurrentLinkedQueue<E> | 基于链接节点的无界线程安全队列。 |
ConcurrentSkipListMap<K,V> | 可扩展的并发地图实现 |
ConcurrentSkipListSet<E> | 基于ConcurrentSkipListMap 可伸缩并发集实现。 |
CopyOnWriteArrayList<E> | ArrayList的线程安全变体,其中所有可变操作(添加,设置等)都通过对基础数组进行全新复制来实现。 |
CopyOnWriteArraySet<E> | 一个使用内部CopyOnWriteArrayList 进行所有操作的Set。 |
DelayQueue<E extends Delayed> | 延迟元素的无限制阻塞队列,在该队列中,仅当元素的延迟到期时才可以使用该元素。 |
LinkedBlockingDeque<E> | 基于链接节点的可选绑定的阻塞双端队列。 |
LinkedBlockingQueue<E> | 基于链接节点的可选绑定的阻塞队列。 |
LinkedTransferQueue<E> | 基于链接节点的无界TransferQueue 。 |
PriorityBlockingQueue<E> | 一个无界阻塞队列,它使用与类PriorityQueue 相同的排序规则,并提供阻塞检索操作。 |
SynchronousQueue<E> | 一个阻塞队列,其中每个插入操作必须等待另一个线程进行相应的删除操作,反之亦然。 |
表5
这些类专门设计用于多线程应用程序。 他们利用许多技术来使对集合的并发访问尽可能高效,并且是synchronized
集合包装器的推荐替代者。
9.探索Java标准库
对于编写并发应用程序的Java开发人员而言, java.util.concurrent
和java.util.concurrent.locks
包是真正的瑰宝。 由于那里有很多类,因此在本节中,我们将介绍其中最有用的类,但是请不要犹豫地查阅Java官方文档并进行全部探索。
类 | 描述 |
CountDownLatch | 一种同步帮助,它允许一个或多个线程等待,直到在其他线程中执行的一组操作完成为止。 |
CyclicBarrier | 一种同步帮助,它允许一组线程全部互相等待以到达一个公共的障碍点。 |
Exchanger<V> | 线程可以配对并在配对中交换元素的同步点。 |
Phaser | 可重用的同步屏障,其功能类似于CyclicBarrier 和CountDownLatch 但支持更灵活的用法。 |
Semaphore | 计数信号量。 |
ThreadLocalRandom | 隔离到当前线程的随机数生成器 |
ReentrantReadWriteLock | 读/写锁的实现 |
表6
不幸的是, ReentrantReadWriteLock
的Java实现并不那么出色,而从Java 8开始,有了新的锁:
类 | 描述 |
StampedLock | 基于功能的锁,具有三种模式来控制读/写访问。 |
表7
10.明智地使用同步
锁定和synchronized
关键字是功能强大的工具,可以在多线程应用程序中极大地帮助保持数据模型和程序状态的一致性。 但是,不明智地使用它们会导致线程争用,并且可能会大大降低应用程序性能。 从另一方面来说,不使用同步原语可能(并且将)导致怪异的程序状态和数据损坏,最终导致应用程序崩溃。 因此,平衡很重要。
建议是在确实需要的地方尝试使用锁或/和synchronized
。 这样做时,请确保尽快释放锁定,并且将需要锁定或同步的执行块保持在最小限度。 那些技术至少应该有助于减少竞争,但不会消除竞争。
近年来,出现了许多所谓的无锁算法和数据结构(例如,来自Atomic Operations部分的Java中的原子操作 )。 与使用同步原语构建的等效实现相比,它们提供了更好的性能。
很高兴知道JVM有一些运行时优化,以消除可能不必要的锁定。 最有名的是偏压锁定 :一种优化,它通过消除与Java同步原语相关的操作来提高无竞争的同步性能(有关更多详细信息,请参阅http://www.oracle.com/technetwork/java/6-performance-137236 .html#2.1.1 )。
不过,JVM会尽力而为,消除应用程序中不必要的同步是更好的选择。 过多使用同步会对应用程序性能产生负面影响,因为线程将浪费昂贵的CPU周期来争夺资源,而不是进行实际工作。
11.等待/通知
在Java标准库( java.util.concurrent
)中引入并发实用程序之前,使用Object
的wait()/notify()/notifyAll()
方法是在Java中建立线程之间通信的方法。 仅当线程拥有所讨论对象的监视器时,才必须调用所有这些方法 。 例如:
private Object lock = new Object();
public void performAction() {
synchronized( lock ) {
while( <condition> ) {
// Causes the current thread to wait until
// another thread invokes the notify() or notifyAll() methods.
lock.wait();
}
// Some implementation here
}
}
方法wait()
释放当前线程持有的监视器锁定,因为它尚未满足其等待的条件( wait()方法必须在循环中调用,并且绝不能在循环外部调用 )。 因此,在同一监视器上等待的另一个线程有机会运行。 完成此线程后,应调用notify()/notifyAll()
方法之一来唤醒等待监视器锁定的一个或多个线程。 例如:
public void performAnotherAction() {
synchronized( lock ) {
// Some implementation here
// Wakes up a single thread that is waiting on this object's monitor.
lock.notify();
}
}
notify()
和notifyAll()
之间的区别在于,第一个唤醒单个线程,而第二个唤醒所有等待的线程(它们开始争夺监视器锁定)。
不建议在现代Java应用程序中使用wait()/notify()
习惯用法。 它不仅复杂,还需要遵循一组强制性规则。 因此,它可能会在正在运行的程序中引起细微的错误,这将是非常困难且耗时的调查。 java.util.concurrent
提供了很多方法来用更简单的替代方法替换wait()/notify()
(在现实情况下,很有可能会获得更好的性能)。
12.对并发问题进行故障排除
在多线程应用程序中,很多事情可能出错。 复制问题成为噩梦。 调试和故障排除可能需要数小时甚至数天甚至数周的时间。 Java开发工具包(JDK)包括几个工具,这些工具至少能够提供有关应用程序线程及其状态的一些详细信息,并诊断死锁条件(请参阅线程和线程组以及锁部分)。 首先是一个好点。 这些工具是(但不限于):
- JVisualVM ( http://docs.oracle.com/javase/7/docs/technotes/tools/share/jvisualvm.html )
- Java任务控制 ( http://docs.oracle.com/javacomponents/jmc.htm )
- jstack ( https://docs.oracle.com/javase/7/docs/technotes/tools/share/jstack.html )
13.接下来是什么
在这一部分中,我们研究了现代软件和硬件平台的非常重要的方面-并发性。 特别是,我们已经看到Java作为一种语言及其标准库为开发人员提供了哪些工具,以帮助他们处理并发和异步执行。 在本教程的下一部分中,我们将介绍Java中的序列化技术。
14.下载
您可以在此处下载本课程的源代码: advanced-java-part-9
翻译自: https://www.javacodegeeks.com/2015/09/concurrency-best-practices.html
并发运行的最佳实践