Java并发_1_线程安全性

引:要编写线程安全的代码,其核心在于要对状态访问操作进行管理,特别是对“共享的(Shared)”和“可变的(Mutable)”状态的访问。而对象的状态是指存储在状态变量(类变量和成员变量)中的数据。“共享”意味着变量可以由多个线程同时访问,而“可变”则意味着变量的值在其生命周期可以发生变化。

什么是线程的安全性

线程安全性简单点说就是所见即所知,这是对正确性的认识。在书中还有一个比较长的定义:

当多个线程访问某个类时,不管运行时环境采用何种调度方法或者这些线程将如何交替执行,
并且在主调代码中不需要任何额外的同步或协同,这个类都能表现出正确地行为,
那么这个类是线程安全的。

自己再开始也简单介绍了对象的状态,这里需要明确一点:

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

原子性

  1. 原子操作:不可再分割为几个操作的操作
  2. 竞态条件:由于不恰当的执行时序而出现不正确的结果(不是原子操作引起的)
  3. 竞态条件的类型:
    • 先检查后执行,例如延迟初始化
    • 读取-修改-写入,例如统计命中数操作
  4. 复合操作:将几个操作变为一个原子操作,在java.util.concurrent.atomic包中包含了一些原子变量类,用于实现在数值和对象引用上的原子状态转换。
  5. 在无状态的类中添加一个状态时,如果该状态完全由线程安全的对象来管理,那么这个类仍然是线程安全的。

加锁机制

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

内置锁

Java提供了一种内置的锁机制来支持原子性:同步代码块。同步代码块包括两部分:一个“作为锁”的对象引用,一个“作为由这个锁保护”的代码块。这个锁称为内置锁或监视器锁。线程在进入同步代码块之前会自动获得锁,并且在退出同步代码块时自动释放锁。Java的内置锁相当于一种互斥体,最多只有一个线程能够持有这种锁。由于每次只能有一个线程执行内置锁保护的代码块,因此,有这个锁保护的同步代码块会以原子方式执行。

重入

内置锁是可重入的,如果某个线程试图获得一个已经有它自己持有的锁,这个请求将会成功,“重入”意味着获取锁的操作的粒度是“线程”。

重入的一种是实现方法是:为每个锁关联一个获取计数值和一个所有者线程。当计数值为0时,这个锁就被认为是没有被任何线程持有。当线程请求一个未被持有的锁时,JVM将记下锁的持有者,并且将获取计数值置为1。如果同个线程再次获取这个锁,计数值将递增,而当线程退出同步代码块时,计数器会相应地递减,当计数值为0时,这个锁将被释放。

用锁来保护状态

  1. 只把复合操作包装在synchronized块中并不够,如果对一个变量的访问需要使用同步,那所有访问该变量的地方都要加上同步。而且在使用锁来实现对变量的同步时,所有访问该变量的地方都要使用同一把锁。
  2. 获取一个对象关联的锁并不能阻止其他线程访问该对象,只有所有线程都获取的是相同的锁才能确保该对象被串行访问。所以每个共享的可变变量要被同一把锁保护。并非所有数据都需要锁的保护,只有被多个线程同时访问的可变数据才需要通过锁来保护。
  3. 一种常见的加锁约定是,将所有的可变状态都封装在对象内部,并通过对象的内置锁对所有访问可变状态的代码进行同步,使得在该对象上不会发生并发访问。

活跃性与性能

这里展示两个代码:

利用同步方法实现锁的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import java.math.BigInteger;

public class SynchronizedFactorizer 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.clone();
encodeIntoResponse(resp, factors);
}
}

}

通过缩小同步代码块的作用范围实现锁的代码:

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
36
37
38
39
import java.math.BigInteger;

import com.sun.org.apache.bcel.internal.generic.IF_ACMPEQ;

@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 getCachedHits() {
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);
}
}

要判断同步代码块的合理大小,需要在各种设计需求之间进行权衡,包括安全性(这个需求必须要满足)、简单性和性能。我们需要权衡。

tip:当执行时间较长的计算或者可能无法快速完成的操作时(例如,网络I/O或控制台I/O),一定不要持有锁。

总结

安全性需要保证,活跃性和性能也要在权衡之中。

参考

  1. 《Java并发编程实战》