"共享"意味着变量可以由多个线程同时访问,而"可变"则意味着变量的值在其声明周期内可以发生变化。
一个对象是否需要线程安全,取决于他是否被多个线程访问。
当多个线程访问某个状态变量并且其中一个线程写入操作时,必须采用同步机制来协同这些线程对变量的访问。java中主要的同步机制关键字synchronized他提供了一种独占的加锁方式,但"同步这个术语"还包括volatile类型的变量,显示锁(Explicit Lock)以及原子变量。
如果当多个线程访问同一个可变的状态变量时没有使用合适的同步,那么程序就会出现错误。有三种方式可以修复这个问题
- 不在线程之间共享该状态变量
- 将状态变量修改为不可变的变量
- 在访问状态变量时使用同步
在编写并发应用程序时,一种正确的编程方法就是:首先使代码正确运行,然后再提高代码的速度。
当多个线程访问某个类时,这个类始终都能表现出正确的行为,那么就称这个类的线程是安全的。
示例:一个无状态的servlet
下面代码给出了一个简单的因数分解Servlet。这个Servlet从请求中提取出数值,执行因数分解,然后将结果封装到该Servlet的响应中。
@ThreadSafe
public class StateLessFactorizer implements Servet{
public void service(ServletRequest req,ServletResponse resp){
BigInteger i = extractFromRequest(req);
BigInteger[] factors = factor(i);
encodeIntoResponse(resp,factors);
}
}
与大多数Servlet相同,StateLessFactorizer是无状态的:它既不包含任何域,也不包含任何对其他类中域的引用。计算过程中的临时状态仅存在于线程栈上的局部变量,并且只能由正在执行的线程访问。访问StateLessFactorizer的线程不影响另一个访问同一个StateLessFactorizer的线程的计算结果,因为这两个线程并没有共享状态,就好像他们都在访问不同的示例。由于线程访问无状态对象的行为并不会因为影响其他线程中操作的正确性,因此无状态对象线程是安全的
当我们在无状态对象中添加一个状态时,会出现什么情况?假设我们希望增加一个"命中计数器"(HitCounter)来统计所请求的数量。一种直观的方法是在Servlet中增加一个long类型的域,并且每处理一个请求就将这个值加1。如下代码所示
在没有同步的情况下统计已处理请求数量的Servlet(不要这么做)
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);
}
}
不幸的是,UnsafeCountingFactorizer并非是线程安全的,尽管他在单线程环境中能正常运行。与前面的UnsafeSequence一样,这个类很可能丢失一些更新操作。虽然递增操作++count是一种紧凑的语法,使其看上去只是一个操作,但这个操作并非原子的,因而他并不会作为一个不可分割的操作来执行。实际上,它包含三个独立的操作:读取count的值,将值增加1,然后将计算结果写入count。这是一个"读取-修改-写入"的操作序列,并且,并且其结果状态依赖于之前的状态。
在并发编程中,这种由于不恰当的执行时序而出现不正确的结果是一种非常重要的情况,它有一个正式的名字:竞态条件(RaceCondition)
在UnsafeCountingFactorizer中存在多个竞争条件,从而使结果变得不可靠。当某个计算的正确性取决于多个线程的交替执行时序时,那么就会发生竞争条件。最常见的竟态条件类型就是“先检查后执行"操作。
要避免竟态条件问题,就必须在某个线程修改该变量时,通过某种方式防止其他线程使用这个变量,从而确保其他线程只能在修改操作完成之后或者之前读取和修改状态,而不是在修改状态的过程中。
假定有两个操作A和B,如果从执行A的线程来看,当另一个线程执行B时,要么将B全部执行完,要么完全不执行B,那么A和B对彼此来说就是原子的。原子操作是指,对于访问同一个状态的所有操作(包括该操作本身)来说,这个操作是一个以原子方式执行的操作。
如果UnsaeSequence中的递增操作都是原子操作,那么竞态条件就不会发生,并且递增操作在每次执行时都会把计数器增加1。
package app;
public class CountingFactorizer 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);
}
}
在java.util.concurrent.atomic包中包含了一些原子变量类,用于实现在数值和对象引用上的原子状态转换。通过AtomicLong来代替long类型的计数器,能够确保所有对计数器状态的访问都是原子的。
在实际情况中,应尽可能地使用现有地线程安全对象(例如AtomicLong)来管理类地状态。与非线程安全的对象相比,判断线程安全对象地可能状态及其状态转换情况要更为容易,从而也更容易维护和验证线程安全性。
java提供了一种内置地锁机制来支持原子性:同步代码块(Synchronized Block)。同步代码块包括两部分:一个作为所地对象引用,一个作为由这个锁保护地代码块。以关键字synchronized来修饰地方法是一种横跨整个方法体地同步代码块,其中该同步代码块地锁就是方法所调用所在地休想。静态地sychronized方法以Class对象作为锁。
sychronized(lock){
//访问或修改由锁保护地工现象状态
}
每个java对象都可以用做一个实现同步地锁,这些做被称为内置锁(Intrinsic Lock)或监视器锁(Monitor Lock)。线程在进入同步代码快之前会自动获得锁,并且在退出同步代码块时自动释放锁,而无论时通过正常地控制路径退出,还是通过从代码中抛出异常退出。获得内置锁地唯一途径就是进入由这个所保护地同步代码块或方法。
虽然sychronized 可以保证线程安全,单却影响性能。
当某个小县城请求一个由其他线程持有地锁时,发出请求地线程就会被阻塞。
重入:荣一个线程再次进入同步代码地时候,可以使用自己已经获取到地锁。
重入进一步提升了加锁行为地封装性。
怎么确保线程安全同时又确保性能呢?
可以通过缩小同步块地作用范围。要确保同步代码块不要过小,并且不要将本应该时原子地操作拆分到多个同步代码块中。应该尽量将不影响共享状态且执行时间较长地操作从同步代码块中分离出去,从而在这些操作地执行过程中,其他线程可以访问共享状态。
public class CachedFactorizer implements Servlet {
private BigInteger lastNumber;
private BigInteger[] lastFactors;
private long hits;
private long cacheHits;
public synchronized long getHits() {
return Hits;
}
public synchronized double getCahedHits(){
return (double) cachehits / (double)hits;
}
public void service(ServletRequest req,ServletResponse resp){
BigInteger i = extractFromRequest(req);
BigInteger[] factors = null;
synchronized(this){
++this;
if(i.equals(lastNumber)){
++cacheHits;
}
}
if(factors == null){
factors = factor(i);
synchronized (this){
lastNumber = i;
lastFactors = factors.clone;
}
}
encodeintoResponse(resp,factors);
}
重新构造后的CachedFactorizer实现了简单性(对整个方法进行同步)与并发性(对尽可能短的代码路径进行同步)之间的平衡。这样即保证了线程安全性,也不会过多地影响并发性,而且在每个同步代码块中的代码路径都“足够短”。
当执行时间较长的计算或者可能无法快速完成的操作时(例如,网络I/O或者控制台I/O),一定不要hi有锁。