1 概要
本系列包括了《Java并发编程实战》的前5章内容,并进行了精炼和内容的重新组织,重点介绍关于Java并发编程的基础知识,认识一下并发编程的常见概念,以及如何使用好并发编程进行开发。
在学习本节内容之前,我们先来了解一下什么是多线程编程,先看一个简单的代码示例:
public class MultiThreadExample implements Runnable {
@Override
public void run() {
System.out.println("Sub-Thread started!");
for (int i = 0; i < 3; i++) {
System.out.println(i);
}
System.out.println("Sub-Thread finished!");
}
public static void main(String[] args) {
System.out.println("Main-Thread started!");
MultiThreadExample example = new MultiThreadExample();
new Thread(example, "example").start();
System.out.println("Main-Thread finished!");
}
}
运行结果如下:
Main-Thread started!
Main-Thread finished!
Sub-Thread started!
0
1
2
Sub-Thread finished!
从这个示例可以看到,for循环的代码是在main方法的主线程执行结果后,在新建的子线程中完成执行的,这就是一个最基本的多线程编程场景。
那么从多线程编程中,我们可以得到什么好处呢?这里从概念上进行一下介绍。
- 充分利用多核处理器:线程是程序执行的基本调度单位,每个线程可以完整的使用一个CPU核来执行,使用多线程编程可以提高多核处理器的CPU利用率。同时也可以提高多核处理器的吞吐率,比如在同步I/O操作时,同时进行CPU计算,提升整体执行效率。
- 简化建模模型:通过将一个复杂任务分解为一组简单并且同步的工作流,每个工作流在一个单独的线程中运行,并在特定同步的位置进行交互,可以大幅简化复杂应用的开发成本。
- 简化异步事件的处理:例如server在接收来自client的socket请求时,如果为每个请求都分配各自的线程,就可以避免线程阻塞对其他请求的影响,降低开发难度。
在享受这些便利的同时,使用多线程编程还将带来以下三个风险:
- 安全性问题:多线程使用同一共享资源时,可能会由于非原子操作导致该资源的状态出现不确定性,从而出现竞态条件等问题。因此这里需要保证的是“永远不发生糟糕的事情”。
- 活跃性问题:线程之前的等待可以导致出现死锁、饥饿、活锁等问题。因此这里需要保证的是“某件正确的事情最终会发生”。
- 性能问题:线程切换时出现的上下文切换操作,会带来极大的开销。
2 线程安全
我们先来看看安全性问题,也就是我们常说的线程安全,先来给一个书里的线程安全的定义:
当多个线程访问某个类时,不管运行时环境采用何种调度方式或者这些线程将如何交替执行,并且在主调代码中不需要任何额外的同步或协同,这个类都能表现出正确的行为,那么就称这个类是线程安全的。
简单说,就是在使用这个类的时候,不用再关心在多线程场景中调用其中的方法,就可以说这个类是线程安全的。
以下是关于线程安全的一些描述性语句,以及相关的解释说明。
- 无状态的代码一定是线程安全的
下面举一个例子,是一个基于Servlet的因数分解服务,以下的代码实现的是一个无状态的服务:
@ThreadSafe
public class StatelessFactorizer implements Servlet {
public void service(ServletRequest req, ServletResponse resp) {
BigInteger i = extractFromRequest(req);
BigInteger[] factors = factor(i);
encodeIntoResponse(resp, factors);
}
}
这里无状态指的是类中不包含任何成员变量,以及对其他类中成员变量的引用。在计算中的本地临时变量仅存在于线程栈中,并且只能由正在执行的线程访问,线程之间无共享状态,互相不影响。那么,这个类一定是线程安全的。
- 原子操作是线程安全的
现在想在Servlet服务中,增加一个计数器,用于统计所处理的请求数量,直接的方式是加一个long类型的字段,每处理一个请求就将这个值加1,代码如下:
@NotThreadSafe
public class UnsafeCountingFactorizer implements Servlet {
private long count = 0;
public long getCount() { return count; }
public void service(ServletRequest req, ServletResponse resp) {
BigInteger i = extractFromRequest(req);
BigInteger[] factors = factor(i);
++count;
encodeIntoResponse(resp, factors);
}
}
这段代码在单线程环境运行是正常的,但在多线程环境中,会丢失一些更新操作。原因在于++count递增操作,实际上包含了三个独立的操作:读取count的值,将值加1,计算结果写入count。因此,在多个线程同时操作count时,可能会出现一个线程完成了count的读取和加1,但还未写入count,此时另一个线程正在读取count的值,就会导致读取旧的count值,丢失更新操作。这里我们针对这种情况,引入一个正式的名称:竞态条件。
竞态条件:当某个计算的正确性取决于多个线程的交替执行时序时,那么就会发生竞态条件。
另外一种常见的竞态条件类型就是“先检查后执行(Check-Then-Act)”,即通过观测结果来决定下一步的动作,比如进行延迟初始化。
解决竞态条件出现的方法是将复合操作进行原子化。解释一下原子操作的含义:
假定有两个操作A和B,如果从执行A的线程来看,当另一个线程执行B时,要么将B全部执行完,要么完全不执行B,那么A和B对彼此来说是原子的。原子操作是对于访问同一个状态的所有操作(包括该操作本身)来说,这个操作是一个以院子方式执行的操作。
因为为了确保线程安全,“先检查后执行”和“读取-修改-写入”等操作必须是原子的。采用这一机制修复本段开头的代码,结果如下:
@ThreadSafe
public class UnsafeCountingFactorizer implements Servlet {
private final AtomicLong count = 0;
public long getCount() { return count; }
public void service(ServletRequest req, ServletResponse resp) {
BigInteger i = extractFromRequest(req);
BigInteger[] factors = factor(i);
count.incrementAndGet();
encodeIntoResponse(resp, factors);
}
}
[扩展阅读]:Java中的CAS原理
- 使用加锁机制可以提升线程安全性
我们希望在Servlet中实现将最近的计算结果缓存起来,当两个连续的请求对相同的数值进行因数分解时,可以直接使用上一次的计算结果。那么如果想在Servlet中添加更多的状态,是否只需添加更多更多的线程安全状态变量就足够了?
@NotThreadSafe
public class UnsafeCachingFactorizer implements Servlet {
private final AtomicReference lastNumber = new AtomicReference();
private final AtomicReference lastFactors = new AtomicReference();
public void service(ServletRequest req, ServletResponse resp) {
BigInteger i = extractFromRequest(req);
if (i.equals(lastNumber.get())) {
encodeIntoResponse(resp, lastFactors.get());
} else {
BigInteger[] factors = factor(i);
lastNumber.set(i);
lastFactors.set(factors);
encodeIntoResponse(resp, factors);
}
}
}
这段程序的问题在于,尽管每个原子引用本身是线程安全的,但无法同时更新lastNumber和lastFactors,这样在某些执行时序中,不变性条件就会被破坏。
要保持状态的一致性,就需要在单个原子操作中更新所有相关的状态变量。
扩展阅读:Java中锁和监视器的区别
这里引入Java内置的锁机制来支持原子性:同步代码块(Synchronized Block),以关键字synchronized修饰的方法就是一种横跨整个方法体的同步代码块。
synchronized (lock) {
// 访问或修改由锁保护的共享状态
}
Java的内置锁是互斥锁,意味着最多只有一个线程能持有这种锁,由这个锁保护同步代码块以原子方式执行,多个线程在执行该代码时不会互相干扰,当一个线程获取锁时,其他线程会等待,直到获取锁的线程释放了锁。通过使用锁的机制,对本节开始的代码进行改造,结果如下:
@ThreadSafe
public class SynchronizedFactorizer implements Servlet {
@GuardedBy("this") private BigInteger lastNumber;
@GaurdedBy("this") private BigInteger[] lastFactors;
public synchronized void service(ServletRequest req, ServletResponse resp) {
BigInteger i = extractFromRequest(req);
if (i.equals(lastNumber.get())) {
encodeIntoResponse(resp, lastFactors.get());
} else {
BigInteger[] factors = factor(i);
lastNumber.set(i);
lastFactors.set(factors);
encodeIntoResponse(resp, factors);
}
}
}
@GuardedBy注解代表变量使用的是对象的内置锁,这样可以免去显示地创建锁对象。
使用这种方法,会使多个客户端无法同时使用因数分解的Servlet,服务的响应性会很低。后面会继续介绍关于提升性能的问题。
扩展阅读:什么是Java中的可重入锁
- 使用同一个锁控制访问变量的所有位置,才能保证线程安全
将复合操作封装到一个同步代码块中是不够的,在其他可以访问这个变量的位置,如果未使用锁,或使用的是另外一个锁,是不能保证线程安全的。
因此一种常用的加锁约定是,将所有的可变状态都封装在对象内部,并通过对象的内置锁对所有访问可变状态的代码进行同步,使得在该对象上不会发生并发访问。
3 性能优化
回到使用加锁机制实现的Servlet代码,使用synchronized同步整个方法,使得代码的执行效率非常糟糕。可以通过缩小同步代码块的范围,既确保Servlet的并发性,又维护线程安全性。尽量将不影响共享状态,且执行时间过长的操作从同步代码块中分离,从而使得在这些操作的执行过程中,其他线程可以访问共享状态。
我们来看来看看优化后的Servlet代码:
@ThreadSafe
public class CachedFactorizer implements Servlet {
@GuardedBy("this") private BigInteger lastNumber;
@GuardedBy("this") private BigInteger[] lastFactors;
@GuardedBy("this") private long hits;
@GuardedBy("this") private long cacheHits;
public synchronized long getHits() { return hits; }
public synchronized double getCachedHitRatio() {
return (double) cacheHits / (double) hits;
}
public void service(ServletRequest req, ServletResponse resp) {
BigInteger i = extractFromRequest(req);
BigInteger[] factors = null;
synchronized (this) {
++hits;
if (i.equals(lastNumber) {
++cacheHits;
factors = lastFactors.clone();
}
}
if (factors == null) {
factors = factor(i);
synchronized (this) {
lastNumber = i;
lastFactors = factors.clone();
}
}
encodeIntoResponse(resp, factors);
}
}
Q:为什么不使用原子变量?
A:原子变量在实现单个变量的原子操作是很有用的,但由于这里已经采用了同步代码块,使用两种不同的同步机制不仅会带来混乱,也不能在性能或安全性上带来任何好处,因此不使用原子变量。
重构后的CachedFactorizer将计算耗时较多的factor从同步代码块分离,并且尽量控制同步代码块的数量,提升代码的可读性,减少获取和释放锁等操作的开销,在简单性和并发性上达到了较好的平衡。
在本节中,我们介绍了如何保证共享状态仅会被单一线程访问,而不会出现多线程的竞态条件。在下一节中,我们会继续介绍如何在多线程中进行对象的共享。