概述
1.在构建稳健的并发程序时,必须正确地使用线程和锁。
2.要编写线程安全的代码,其核心在于要对状态访问操作进行管理,特别是对共享的和可变的状态的访问。
3.”共享“指变量可以由多个线程同时访问,”可变“意味着变量的值在其生命周期内可以发生变化。
2.1什么是线程安全性
1.当多个线程访问某个类时,不管运行时环境采用何种调度方式,或者这些线程将如何交替执行,并且在主调代码中不需要任何额外的同步或者协同,这个类都能表现出正确的行为,那么就称这个类是线程安全的。
2.无状态对象(既不包含任何域,也不包含对其他类中域的引用)一定是线程安全的。
2.2原子性
@NotThreadSafe
public class UnsafeCountingFactorizer implements Servlet{
private long count = 0;
public long getCount(){return count;}
public void service(ServletRequest req,ServletResponse response){
BigInteger i = extractFromRequest(req);
BigInter[] factors = factor(i);
++count;
encodeIntoResponse(resp,factors);
}
}
1.上述代码中++count不是一个原子性的操作,它包含了三个独立的操作:
- 把count从内存加载到cpu寄存器,读取count的值
- 在寄存器中执行+1操作
- 将新值写到内存(或者cpu缓存)
操作系统做切换任务,可以在任何一条CPU指令执行完。假设count= 0;那么当线程A在指令1执行完后做任务切换,线程A和线程B按如下图所示执行,就会发现count+1操作两个线程都执行了,最后count结果是1,而不是所期望的2.
在并发编程中,这种由于不恰当的执行时序而出现不正确的结果是一种非常重要的情况,成为:竞态条件。
2.2.1竞态条件
1.当某个计算的正确性取决于多个线程的交替执行时,那么就会发生竞态条件。最常见的就是”先检查后执行“操作,即通过一个可能失效的观测结果来决定下一步的动作。
2.2.2示例:延迟初始化中的竞态条件
1.使用”先检查后执行“的常见情况就是延迟初始化。延迟初始化的目的是将对象初始化操作推迟到实际使用时才进行。
@NotThreadSafe
public class LazyInitRace{
private ExpensiveObject instance = null;
public ExpensiveObject getInstance(){
if(instance == null)
instance = new ExpensiveObject();
return ;
}
}
2.在LazyInitRace
中包含了一个竞态条件。假定线程A和线程B同时执行getInstance
,A看到instance
为空,因而创建一个新的实例,因为new对象操作并非原子性,假设此时A刚好执行了new对象过程的第一个指令,为对象分配一块存储空间,而instance此时并没有指向new出来的新对象,此时操作系统进行了线程切换。而B也要判断instance
是否为空,此时instance依旧为空,B又去创建新的对象,那么在两次调用getInstance()
时可能会得到不同的结果。
3.竞态条件并不总是会发生错误,还需要某种不恰当的执行时序。假设LazyInitRace
被用作初始化应用程序范围内的注册表,如果在多次调用中返回不同的实例,那么要么会丢失部分注册信息,要么多个行为对同一组注册对象表现出不一致的视图。
2.2.3复合操作
1.通过使用AtomicLong修复UnsafeCountingFactorizer
@ThreadSafe
public class CountingFactorizer implements Servlet{
private final AtomicLong count = new AtomicLong(0);
public getCount(){return count.get();}
public void service(ServletRequest req,ServletResponse rep){
BigInteger i = extractFromRequest(req);
BigInteger[] factors = factor(i);
//确保对计数器状态的访问操作都是原子的
count.incrementAndGet();
encodeIntoResponse(resp,factors);
}
}
2.由于Servlet的状态就是计数器的状态,并且计数器是线程安全的,因此Servlet也是线程安全的。
3.在实际情况中,尽可能的使用现有的线程安全对象来管理类的状态。
@NotThreadSafe
public class UsafeCachingFactorizer implements Servlet {
private final AtomicReference<BigInteger> lastNumber = new AtomicReference<>();
//将最近的结果缓存起来
private final AtomicReference<BigInteger[]> lastFactors = new AtomicReference<>();
@Override
public void service(ServletRequest servletRequest, ServletResponse servletResponse) throws ServletException, IOException {
BigInteger i = extractFromRequest(servletRequest);
//当两个连续的请求对相同的数值进行因数分解时,直接使用上一次的计算结果
if (i.equals(lastFactors.get())){
encodeIntoResponse(servletResponse,lastFactors.get());
}else {
BigInteger[] factors = factor(i);
lastNumber.set(i);
//缓存因数分解的值
lastFactors.set(factors);
encodeIntoResponse(servletResponse,factors);
}
}
4.UsafeCachingFactorizer
中存在竞态条件,尽管对set
方法的每次调用都是原子的,但是仍然无法同时更新lastNumber和lastFactors。
2.2.3内置锁
1.以关键字synchronized
修饰的方法就是一种横跨整个方法体的同步代码块。
2.静态synchronized
方法以Class对象作为锁。
synchronized(lock){
}
3.每个Java对象都可以用作一个实现同步的锁,这些锁被称为内置锁或者监视器锁。
4.线程在进入同步代码块之前会自动获得锁,并且在退出同步代码块时自动释放锁。无论是正常退出还是从抛出异常退出。
5.最多只有一个线程能持有这种锁。当线程A尝试获取一个由线程B持有的锁时,线程A必须阻塞或者等待。直到B释放这个锁。
6.如下图,锁和保护的资源是有对应关系的,你的锁保护你的资源,我的锁保护我的资源。需要同步的代码称为临界区。受保护的资源称为R,需要给其上一把锁称为LR。
@ThreadSafe
public class SynchronizedFactorizer implements Servlet {
private BigInteger lastNumber;
private BigInteger[] lastFactors;
@Override
//synchronized同步方法,一次只能有一个线程进入临界区执行其中代码。
public synchronized void service(ServletRequest servletRequest, ServletResponse servletResponse) throws ServletException, IOException {
BigInteger i = extractFromRequest(servletRequest);
if (i.equals(lastNumber)){
encodeIntoResponse(servletResponse,lastFactors);
}else {
BigInteger[] factors = factor(i);
lastNumber = i;
lastFactors = factors;
encodeIntoResponse(servletResponse,factors);
}
}
7.SynchronizedFactorizer
是线程安全的,但是其并发性能却非常差。需要细化锁的粒度。
2.3.2重入
1.当某个线程请求一个由其他线程持有的锁时,发出请求的线程就会阻塞。
2.由于内置锁是可重入的,因此某个线程试图获得一个已经由它自己持有的锁,这个请求就会成功。
3.重入意味着获取锁的操作的粒度是”线程“,而不是”调用“。
4.重入的一种实现方法是:为每个锁关联一个获取计数值和一个所有者线程。当计数值为0时,这个锁就认为是没有被任何线程持有。当线程请求一个未被持有的锁时,JVM会记下锁的持有者,并且将计数值加1。如果同一个线程再次获取这个锁,计数值将递增。而当线程退出同步代码块时,计数值递减。当计数值为0时,锁被释放。
public class Widget {
public synchronized void doSomething(){
}
}
public class LoggingWidget extends Widget {
@Override
public synchronized void doSomething() {
System.out.println(toString()+": calling doSomething");
//如果内置锁是不可重入的,那么调用如下代码时将发生死锁。因为
//在Widget的锁在此时已经被持有,从而线程将永远等待下去。重入避免了此类情况发生
super.doSomething();
}
}
2.4用锁来保护状态
1.如果用同步来协调对某个变量的访问,那么在访问这个变量的所有位置上都要使用同步。并且需要使用同一个锁。
2.每个共享的和可变的变量都应该只有一个锁来保护。
3.不是所有数据都需要锁的保护,只有被多个线程同时访问的可变数据才需要通过锁来保护。
4.对于每个包含多个变量的不变性条件,其中涉及到的所有变量都需要由同一个锁保护。
2.5活跃性与性能
1.在以上的示例代码SynchronizedFactorizer
中的同步方式是简单且粗粒度的,虽然能够保证线程安全性,但是付出的代价却很高。
2.由于service
方法是synchronized修饰的,因此每次只要一个线程可以执行。这背离了Servlet设计的初衷,即Servlet需要一次能够处理多个请求。如果Servlet在进行某个大数因式分解时需要消耗很长时间,那么其他客户端必须一直等待直到处理完成当前的请求。并且如果系统中有多个CPU,当负载很高时,仍然有处理器会处于空闲状态。
3.通过缩小同步代码块的作用范围,很容易做到既确保Servlet的并发性,又维护线程安全。
4.当执行较长时间的计算或者可能无法快速完成的操作时,一定不要持有锁。
public class CachedFactorizer implements Servlet {
private BigInteger lastNumber;
private BigInteger[] lastFactors;
private long hits; //命中计数器
private long cachedHits; //缓存命中计数器
@Override
public void service(ServletRequest servletRequest, ServletResponse servletResponse) throws ServletException, IOException {
BigInteger i = extractFromRequest(servletRequest);
BigInteger[] factors = null;
synchronized (this){
++hits;
if (i.equals(lastNumber)){
++cachedHits;
factors = lastFactors.clone();
}
}
//执行因式分解时释放锁,因为可能比较耗时
if (factors == null){
factors = factor(i);
synchronized (this){
lastNumber = i;
lastFactors = factors.clone();
}
}
encodeIntoResponse(servletResponse,factors);
}
5.阿姆达尔定律:
S
=
1
(
1
−
p
)
+
p
n
S = \frac{1}{(1-p)+\frac{p}{n}}
S=(1−p)+np1
公式中n可以认为是CPU核数,p可以理解为并行百分比。则1-p为串行百分比。假设1-p=5%,n =
∞
\infty
∞,则
lim
n
→
∞
S
=
20
\lim^{n \to \infty} S=20
limn→∞S=20,也就是说无论怎么样,最高只能提升20倍的性能。
6.活锁:线程没有发生阻塞,确仍然会发生执行不下去的情况称之为活锁。比如两人相遇互相谦让,甲往左边走让乙,乙往右边让甲,互相谦让。结果谁也走不过去。活锁的解决方案是让线程等待一个随机的时间就可以。Raft 这样的分布式一致性算法中也用到了它。
7.饥饿:指的是线程因无法访问所需资源而执行不下去的情况。在CPU繁忙的情况下,优先级较低的线程很难得到执行的机会。一个线程执行时间过长,也可能发生饥饿的情况。饥饿的解决方案是:保证资源充足、公平地分配资源、避免持有锁的线程长时间执行。