编写线程安全的代码 就是对共享的
和可变的
状态访问操作进行管理。那么什么是状态?
状态是指存储在状态变量总的数据。比如某个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); } }
|