线程安全

Posted nativestack

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了线程安全相关的知识,希望对你有一定的参考价值。

线程安全定义

"A  class is thread-safe if it behaves correctly when accessed from multiple threads, regardless of the scheduling or interleaving of the execution of those threads, and with no additional synchronization or other coordination on the part of the calling code" .

线程安全的类封装了必要的同步机制,所以,客户端代码不需要care.

如果多个线程访问同一可变状态变量(对象,数值等)时没有正确的同步,则程序就会出错。有三种办法解决:

  • 线程之间不共享变量
  • 使用不变量(immutable)
  • 访问变量时,使用同步

java的同步机制不仅仅包含关键字synchronized , 还有volatile变量,原子性和显示锁。

无状态对象是线程安全的

函数中定义的变量,对象都是线程安全的,因为他们只存在于线程栈上,只能被当前执行线程访问。你在servlet中的service方法内声明的变量,只能被当前线程(处理某个http请求的线程)看到。

原子性(Atomicity)

假设我们要实现一个servlet计数的功能,每次处理一个请求时,在线程中将计数器count加1. 之前的文章中已经说明,此操作(++count)在多线程中执行会有race condition。因为++count是一个复合操作,包含了读取->增加1->写入,即read->modify->write.在没有同步的情况下,多个线程会以任意的交替方式执行,结果肯定不对。

@NotThreadSafe
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);
    }
}

还有一种常见的race condition是check->then->act,也是一种复合操作,在并发访问下,有可能基于过期的状态做出决定或者执行动作。比如检查了某个文件a.txt不存在,然后创建这个文件,但在这两个之间,别的线程有可能创建了此文件,结果造成没能捕获的错误,文件覆盖等问题。再举一个常见的延迟初始化的例子,代码如下,不同的线程有可能都会创建一个实例,并且拿到不同的引用,想想后面会发生什么就感到可怕。

技术分享图片
@NotThreadSafe
public class LazyInitRace {
    private ExpensiveObject instance = null;
    public ExpensiveObject getInstance() {
        if (instance == null)
            instance = new ExpensiveObject();
        return instance;
    }
}
View Code

为解决复合操作带来的安全性问题,我们可以通过保证:一个线程A只能在线程B操作变量开始之前或者结束之后(而不是中间某个时候)才能使用(读,写)它。对于上面的例子,变量就是count和instance。而原子性可以保证这一点(atomicity)。java.util.concurrent.atomic 包里面包含了一些原子变量类,实现数字和对象引用上的原子状态转换。通过使用AtomicLong实现计数器代码如下(我们的Servlet恢复了线程安全性,是线程安全的):

@ThreadSafe
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);
    }
}

这种 原子性本质也是通过锁来实现的。

原子操作很方便,简单,但是它很难或者无法处理复杂的或者多个状态变量的同步。假设我们有几个变量需要一起使用同步维护,那么将多个原子操作放在一起同样是不安全的(新的复合操作)。比如我们想缓存上一次请求的参数(lastNumber)和计算的结果(factor函数的运算结果)。如果新请求的参数和上一次相同,我们就直接返回结果从而省去CPU密集型的计算。在13,14行中,我们试图更新缓存,不幸的是它们是复合操作而不安全,在交替的执行环境中,很可能发生竞态条件,造成两者不一致(lastNumber对应的结果是可能另外一个线程计算的结果)。9,10行的读取也会出现类似问题。所以,为了达到一致性要求,所有相关的变量必须执行原子操作。

 1 @NotThreadSafe
 2 public class UnsafeCachingFactorizer implements Servlet {
 3     private final AtomicReference<BigInteger> lastNumber
 4     = new AtomicReference<BigInteger>();
 5     private final AtomicReference<BigInteger[]> lastFactors
 6     = new AtomicReference<BigInteger[]>();
 7     public void service(ServletRequest req, ServletResponse resp) {
 8         BigInteger i = extractFromRequest(req);
 9         if (i.equals(lastNumber.get()))
10             encodeIntoResponse(resp, lastFactors.get());
11         else {
12             BigInteger[] factors = factor(i);
13             lastNumber.set(i);
14             lastFactors.set(factors);
15             encodeIntoResponse(resp, factors);
16         }
17     }
18 }

 

Synchronized

同步块是java内置的实现原子性的方式之一,使用如下:

synchronized (lock) {
// Access or modify shared state guarded by lock
}

也可以加到方法签名部分,则lock是当前对象或者当前类。 当线程执行代码时,必须先获得对象上的锁,由于synchronized 锁是互斥/排它的,如果被其他线程持有,则必须等待(即同一个锁只能同一时间被一个线程持有),线程退出保护的代码块后,锁自动释放(无论是否发生错误)。这样就实现了多变量的操作的原子性(其他线程只能在当前持有锁的线程操作之前或者之后使用变量)。

我们使用同步块、方法实现的安全缓存的代码如下:

 1 @ThreadSafe
 2 public class SynchronizedFactorizer implements Servlet {
 3     @GuardedBy("this") private BigInteger lastNumber;
 4     @GuardedBy("this") private BigInteger[] lastFactors;
 5     public synchronized void service(ServletRequest req,
 6         ServletResponse resp) {
 7         BigInteger i = extractFromRequest(req);
 8         if (i.equals(lastNumber))
 9             encodeIntoResponse(resp, lastFactors);
10         else {
11             BigInteger[] factors = factor(i);
12             lastNumber = i;
13             lastFactors = factors;
14             encodeIntoResponse(resp, factors);//factors 和 lastFactors是指向同一对象
15         }
16     }
17 }

使用synchronized 关键字就可以解决安全性问题,但是稍加思考,就明白这个实现有很大的性能问题,简单粗暴,所有的请求处理都成了串行化的了(由于整个函数的同步了,共享变量,非共享变量及其代码块都被同步了),我们之后会解决这个性能问题。

重入(解决死锁)

如果客户端调用loggingWidget.doSomething()的时候,它会首先获取this上的锁,其后又要调用父类的doSomething(),而此方法也是同步代码块,并且也是this上的锁。当它视图获取一个自己已经占有的锁时,会发生什么? 不会发生死锁,JAVA允许重入,即允许线程多次获取同一个对象上的锁。虚拟机维护了线程和锁的计数器数据结构,当获取一个锁时,计数器加1,退出保护的代码块时,计数器减1.当计数器为0时,锁被释放。

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

用锁来保护对象

对我们需要访问的对象进行保护,需要在每个使用它(们)的地方都使用同步,无论是读取还是写入,并且要使用同一个锁进行同步。

好的实践是将线程安全封装在对象内部,并使用对象的内置锁来保护。但是并没有技术上的机制强制我们这样做,并且,通过简单地添加一个方法(忘记加同步保护),就可以很容易地打破类的线程安全性。

为了实现线程安全,简单地将每个方法都标记为同步是非常不可取的,两个同步方法组合一起,如同之前将两个原子操作放在一起一样有安全性问题。比如将Vector的put - if - absent. 

if (!vector.contains(element))
  vector.add(element);

同时也会带来活跃性和性能问题。

活跃性与性能

我们提过之前的代码SynchronizedFactorizer 有严重的性能问题,他使整个请求处理变成了串行化,违反了servlets的设计。如果代码里面存在I/O阻塞,耗时操作,那将是非常糟糕的。多CPU的利用率也非常低(即使负载很高的情况下)。为了解决此问题,并维持安全性,我们会缩小同步块的范围,将不需要共享的,耗时的,阻塞的操作从其中移出去,从而减少别的线程的等待时间。有时候这样会使代码变得负责,这也是性能和简单性之间的权衡。以下代码修正了SynchronizedFactorizer 的性能问题:

 1 @ThreadSafe
 2 public class CachedFactorizer implements Servlet {
 3     @GuardedBy("this") private BigInteger lastNumber;
 4     @GuardedBy("this") private BigInteger[] lastFactors;
 5     @GuardedBy("this") private long hits;
 6     @GuardedBy("this") private long cacheHits;
 7     public synchronized long getHits() { return hits; }
 8     public synchronized double getCacheHitRatio() {
 9         return (double) cacheHits / (double) hits;
10     }
11     public void service(ServletRequest req, ServletResponse resp) {
12         BigInteger i = extractFromRequest(req);
13         BigInteger[] factors = null;
14         synchronized (this) {
15             ++hits;//shared
16             if (i.equals(lastNumber)) {//check then action.
17                 ++cacheHits;
18                 factors = lastFactors.clone();
19             }
20         }
21         if (factors == null) {
22             factors = factor(i);
23             synchronized (this) {
24                 lastNumber = i;  //read modify write
25                 lastFactors = factors.clone();//read modify write , and make them consistent to put them together.
26             }
27         }
28         encodeIntoResponse(resp, factors);
29     }
30 }

需要共享的数据,任何访问的地方要使用同步,需要原子操作来保证一致性的代码必须放在一起同步,在不引入过多代码复杂性的情况下,将不共享的都移出同步块。 另外,代码使用了clone将共享变量转为本地变量,从而减少了同步的使用。


以上是关于线程安全的主要内容,如果未能解决你的问题,请参考以下文章

HashMap 和 ConcurrentHashMap 的区别

线程同步-使用ReaderWriterLockSlim类

newCacheThreadPool()newFixedThreadPool()newScheduledThreadPool()newSingleThreadExecutor()自定义线程池(代码片段

多线程 Thread 线程同步 synchronized

活动到片段方法调用带有进度条的线程

第十次总结 线程的异步和同步