农场主的黑科技.

Java并发编程实战-第2章-线程安全性

字数统计: 2.3k阅读时长: 14 min
2018/10/25 Share

编写线程安全的代码 就是对共享的可变的状态访问操作进行管理。那么什么是状态?

状态是指存储在状态变量总的数据。比如某个HashMap的状态,不仅是HashMap本身,也包括存储的Map.Entry对象。对外部有影响的数据都能称为是状态。

  • 共享的 : 可以被多个线程同时访问的变量
  • 可变的 : 变量的值在生命周期内可以发生变化

如果一个对象会被多个线程同时访问,那么他就需要是线程安全的,因此需要采用同步机制来协同对象可变状态的访问。如果不能协同,那么就有可能产生不该出现的结果。

防止发生线程不安全的状况 : 1. 不在线程之间共享该状态变量 2. 将状态变量修改为不可变的变量 3. 在访问状态变量时使用同步

另外,程序的封装性越好,就越容易实现程序的线程安全性,且更容易维护。

什么是线程安全性

线程安全性 : 当多个线程访问某个类使,这个类始终都能表现出正确的行为,那么就称这个类为线程安全的

无状态的对象一定是线程安全的.

😊 一个无状态的Servlet

1
2
3
4
5
6
7
8
9
@ThreadSafe
public class StatelessFactorizer extends GenericServlet implements Servlet {

public void service(ServletRequest req, ServletResponse resp) {
BigInteger i = extractFromRequest(req);
BigInteger[] factors = factor(i);
encodeIntoResponse(resp, factors);
}
}

在这个Servlet中,不包含任何变量,也不包含其他类的变量。计算过程中的临时状态都存于局部变量中,并且只能由当前线程访问。所以访问Servlet的线程不会影响到另一个同时访问Servlet的计算结果。因为他们没有共享状态。

原子性

😧 一个线程不安全的Hit Counter程序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@NotThreadSafe
public class UnsafeCountingFactorizer extends GenericServlet 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操作实际上是分三步,”读取-修改-写入”,而且其结果依赖于之前的状态。比如,当三个线程同时执行时,他们起初读到的都是9,三个线程执行结束后结果却更新为10,这显然不是我们想要的结果。像这种因为执行时序导致产生不正确的结果,被称为竞态条件

竞态条件

指计算结果取决于执行顺序,换句话说就是结果的正确性靠运气。

  • 常见的竞争 : “先检查后执行(Check-Then-Act)”操作。即通过一个可能事项的观测结果来决定下一步的动作
  • 单例设计模式的懒汉式就是典型的”先检查后执行”

复合操作

那么如何避免竞态条件?需要保证在修改该变量时,通过某种方式防止其他线程使用这个变量。也就是说,把”读取-修改-写入”这种复合操作以原子性方式执行。

😊 一个线程安全的Hit Counter程序

1
2
3
4
5
6
7
8
9
10
11
12
13
@ThreadSafe
public class CountingFactorizer extends GenericServlet implements Servlet {
private final AtomicLong count = new AtomicLong(0);

public long getCount() { return count.get(); }

public void service(ServletRequest req, ServletResponse resp) {
BigInteger i = extractFromRequest(req);
BigInteger[] factors = factor(i);
count.incrementAndGet();
encodeIntoResponse(resp, factors);
}
}

通过用AtomicLong来代替long。

尽可能使用现有的线程安全对象(如AtomicLong)来管理类的状态

加锁机制

那么是不是只要把状态全部存为AtomicLong那种线程安全的变量就行了? 假设我们想提升性能,在之前的Counter中加入因数分解的缓存机制来保存上一次的计算结果。并同样尝试用Atomic来管理这个缓存。

😧 一个线程不安全的cache程序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@NotThreadSafe
public class UnsafeCachingFactorizer extends GenericServlet implements Servlet {
private final AtomicReference<BigInteger> lastNumber
= new AtomicReference<BigInteger>();
private final AtomicReference<BigInteger[]> lastFactors
= new AtomicReference<BigInteger[]>();

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); //<---
}
}
}

虽然变量都是线程安全的Atomic类,但仍存在竞争条件。因为此时两个变量之间不是彼此独立的,当更新某个变量时,需要对另一个也同时进行更新。如果在两个变量的更新过程中有别的线程破坏了不变性条件,就无法获取期待的值。

要保持状态的一致性,就需要在单个原子操作中更新所有相关的变量

内置锁

  • 代码同步块(synchronized):Java提供的一种内置锁机制,整体具有原子性
1
2
3
synchronized (lock) {	//<--作为锁的对象引用
//由上面的锁保护的代码块
}
  • synchronized修饰的方法: 锁就是方法调用所在的对象,如果是静态的synchronized方法,那么它的锁就是Class本身。

内置锁 : 每个java对象都能作为内置锁。线程在进入同步代码块之前会自动获得锁,在退出时自动释放锁。

内置锁相当于一种互斥锁,只有一个线程能持有这种锁。当线程尝试获取一个由线程B持有的锁时,A必须等待或阻塞,直到B释放锁。因为只有一个线程能进入被内置锁保护的代码块,所以在这过程中是线程安全的。

😕 一个线程安全的cache程序,把service方法修饰为synchronized(性能糟糕)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@ThreadSafe
public class SynchronizedFactorizer extends GenericServlet implements Servlet {
@GuardedBy("this") private BigInteger lastNumber;
@GuardedBy("this") private BigInteger[] lastFactors;

public synchronized void service(ServletRequest req,
ServletResponse resp) {
BigInteger i = extractFromRequest(req);
if (i.equals(lastNumber))
encodeIntoResponse(resp, lastFactors);
else {
BigInteger[] factors = factor(i);
lastNumber = i;
lastFactors = factors;
encodeIntoResponse(resp, factors);
}
}
}

重入

内置锁是可重入的。什么是重入?如果线程A,B需要同一把锁,当B持有时线程A就会被阻塞直到这把锁被释放。但如果当B结束了这次调用,进入下一次调用时的方法仍依赖这把锁,它会试图获得这个自己已经持有的锁,此时B是可以继续使用这把锁的。也就是说,如果多次操作需要的都是同一把锁时,线程可以一直用这把锁。“重入”意味着获取锁的操作的颗粒度是”线程”而不是”调用”

相反,如果内置锁是不可重入的,那么这段代码会发生死锁。

1
2
3
4
5
6
7
8
9
public class Widget {
public synchronized void doSomething() {...}
}
public class LoggingWidget extends Widget {
public synchronized void doSomething() {
System.out.println(toString() + ": calling doSomething");
super.doSomething();
}
}

在上面的代码中,如果不存在可重入锁的概念,子类的doSomething被调用后为了执行super.doSomething(),就必须进入父类的doSomething方法,然而父类的doSomething方法也是被synchronized修饰的,此时却无法获取Widget上的锁,因为这个锁已被(自己?)持有,所以会一直等待下去,造成死锁。

用锁来保护状态

串行访问:意味着多个线程依次以独占的方式访问对象,而不是并发的访问

如果用同步来访问某个变量时,访问该变量的所有位置都需要同步,而且都需要同一个锁。

一种常见的加锁约定就是,将所有的可变状态都封装在对象内部,并通过对象的内置锁对所有访问可变状态的代码路径进行同步,使得在该对象上不会发生并发访问。Vector和其他同步集合类都使用了这种方式。

对于每个包含多个变量的不变性条件,其中涉及的所有变量都需要由同一个锁保护

活跃性与性能

再次分析之前的线程安全,但性能糟糕的Servlet。通过缩小同步代码块的作用范围可以改善性能。

😊 改善后的线程安全cache程序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
@ThreadSafe
public class CachedFactorizer extends GenericServlet 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 getCacheHitRatio() {
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);
}
}
CATALOG
  1. 1. 什么是线程安全性
  2. 2. 原子性
    1. 2.1. 竞态条件
    2. 2.2. 复合操作
  3. 3. 加锁机制
    1. 3.1. 内置锁
    2. 3.2. 重入
  4. 4. 用锁来保护状态
  5. 5. 活跃性与性能