JCIP阅读笔记之线程安全性

本文是做者在阅读JCIP过程当中的部分笔记和思考,纯手敲,若有误处,请指正,很是感谢~java

可能会有人对书中代码示例中的注解有疑问,这里说一下,JCIP中示例代码的注解都是自定义的,并不是官方JDK的注解,所以若是想要在本身的代码中使用,须要添加依赖。移步:jcip.net编程

1、什么是线程安全性?

当多个线程访问某个类时,这个类始终都能表现出正确的行为,那么这个类就是线程安全的。缓存

示例:一个无状态的Servlet安全

从request中获取数值,而后因数分解,最后将结果封装到response中网络

@ThreadSafe
    public class StatelessFactorizer implements Servlet {
        public void service(ServletRequest req, ServletResponse resp) {
            BigInteger i = extractFromRequest(req);
            BigInteger[] factors = factor(i);
            encodeIntoResponse(resp, factors);
        }
    }

这是一个无状态的Servlet,什么是无状态的?不包含任何域或者对其余类的域的引用。service里仅仅是用到了存在线程栈上的局部变量的临时状态,而且只能由正在执行的线程访问。并发

因此,若是有一个线程A正在访问StatelessFactorizer类,线程B也在访问StatelessFactorizer类,可是两者不会相互影响,最后的计算结果仍然是正确的,为何呢?由于这两个线程并无共享状态,他们各自访问的都是本身的局部变量,因此像这样 无状态的对象都是线程安全的less

大多数Servlet都是线程安全的,因此极大下降了在实现Servlet线程安全性的复杂性。只有在Servlet处理请求须要保存一些信息的状况下,线程安全性才会成为一个问题。ide

2、原子性

我理解的原子性就是指一个操做是最小范围的操做,这个操做要么完整的作要么不作,是一个不可分割的操做。好比一个简单的赋值语句 x = 1,就是一个原子操做,可是像复杂的运算符好比++, --这样的不是原子操做,由于这涉及到“读取-修改-写入”的一个操做序列,而且结果依赖于以前的状态。性能

示例:在没有同步的状况下统计已处理请求数量的Servlet(非线程安全)优化

@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++; // *1
            encodeIntoResponse(resp, factors);
        }
    }

在上面这段代码中,count是一个公共的资源,若是有多个线程,好比线程A, B同时进入到 *1 这行,那么他们都读取到count = 0,而后进行自增,那么count就会变成1,很明显这不是咱们想要的结果,由于咱们丢失了一次自增。

1. 竞态条件

这里有一个概念:竞态条件(Race Condition),指的是,在并发编程中,因为不恰当的执行时序而出现不正确的结果。

在count自增的这个计算过程当中,他的正确性取决于线程交替执行的时序,那么就会发生竞态条件。

大多数竞态条件的本质是,基于一种可能失效的观察结果来作出判断 or 执行某个计算,即“先检查后执行”。

仍是拿这个count自增的计算过程举例:

  • count++大体包含三步:

    • 取当前count值 *1
    • count加一 *2
    • 写回count *3

那么在这个过程当中,线程A首先去获取当前count,而后很不幸,线程A被挂起了,线程B此时进入到 1,他取得的count仍然为0,而后继续 2,count = 1,如今线程B又被挂起了,线程A被唤醒继续 2,此时线程A观察到的仍然是本身被挂起以前count = 0的结果,其实是已经失效的结果,线程A再继续 2,count = 1,而后 *3,最后获得结果是count = 1,而后线程B被唤醒后继续执行,获得的结果也是count = 1。

这就是一个典型的因为不恰当的执行时序而产生不正确的结果的例子,即发生竞态条件。

2. 延迟初始化中的竞态条件

这是一个典型的懒汉式的单例模式的实现(非线程安全)

@NotThreadSafe
    public class Singleton {
        private static Singleton instance;

        private Singleton() {}

        public static Singleton getInstance() {
            if (instance == null) { // *1
                instance = new Singleton();
            }

            return instance;
        }
    }

在 *1 判空后,即实际须要使用时才初始化对象,也就是延迟初始化。这种方式首先判断 instance 是否已经被初始化,若是已经初始化过则返回现有的instance,不然再建立新的instance,而后再返回,这样就能够避免在后来的调用中执行这段高开销的代码路径。

在这段代码中包含一个竞态条件,可能会破坏该类的正确性。假设有两个线程A, B,同时进入到了getInstance()方法,线程A在 *1 判断为true,而后开始建立Singleton实例,可是A会花费多久能建立完,以及线程的调度方式都是不肯定的,因此有可能A还没建立完实例,B已经判空返回true,最终结果就是建立了两个实例对象,没有达到单例模式想要达到的效果。

固然,单例模式有不少其余经典的线程安全的实现方式,像DCL、静态内部类、枚举均可以保证线程安全,在这里就不赘述了。

3、加锁机制

仍是回到因数分解那个例子,若是但愿提高Servlet的性能,将刚计算的结果缓存起来,当两个连续的请求对相同的值进行因数分解时,能够直接用上一次的结果,无需从新计算。

具体实现以下:

该Servlet在没有足够原子性保证的状况下对其最近计算结果进行缓存(非线程安全)

@NotThreadSafe
public class UnsafeCachingFactorizer implements Servlet {
    private final AtomicReference<BigInteger> lastNumber
            = new AtomicReference<>();
    private final AtomicReference<BigInteger[]> lastFactors
            = new AtomicReference<>();

    public void service (ServletRequest req, ServletResponse resp) {
        BigInteger i = extractFromRequest(req);
        if (i.equals(lastNumber.get())) // *2
            encodeIntoResponse(resp, lastFactors.get()); // *3
        else {
            BigInteger[] factors = factor(i);
            lastNumber.set(i); // *1
            lastFactors.set(factors);
            encodeIntoResponse(resp, factors);
        }
    }
}

很明显这个Servlet不是线程安全的,尽管使用了AtomicReference(替代对象引用的线程安全类)来保证每一个操做的原子性,可是整个过程仍然存在竞态条件,咱们没法同时更新lastNumber和lastFactors,好比线程A执行到 1以后set了新的lastNumber,但此时尚未更新lastFactors,而后线程B进入到了 2,发现已经该数字已经有缓存,便进入 *3,但此时线程A并无同时更新lastFactors,因此线程B如今get的i的因数分解结果是错误的。

Java提供了一些锁的机制来解决这样的问题。

1. 内置锁

synchronized (lock) {
    // 访问或修改由锁保护的共享状态
}

在Java中,最基本的互斥同步手段就是synchronized关键字了

好比,咱们对一个计数操做进行同步

public class Test implements Runnable {
    private static int count;

    public Test() {
        count = 0;
    }

    @Override
    public void run() {
        synchronized (this) {
            for (int i = 0; i < 5; i++) {
                try {
                    System.out.println(Thread.currentThread().getName() + ":" + (count++));
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    }

    public int getCount() {
        return count;
    }

    public static void main(String[] args) {
        Test test = new Test();
        Thread thread1 = new Thread(test, "thread1");
        Thread thread2 = new Thread(test, "thread2");
        thread1.start();
        thread2.start();
    }

}

最后输出的结果是:

thread1:0
thread1:1
thread1:2
thread1:3
thread1:4
thread2:5
thread2:6
thread2:7
thread2:8
thread2:9

synchronized关键字编译后会在同步块先后造成 monitorenter 和 monitorexit 这两个字节码指令

public void run();
    Code:
       0: aload_0
       1: dup
       2: astore_1
       3: monitorenter
       4: iconst_0
       5: istore_2
       6: iload_2
        // ......
      67: iinc          2, 1
      70: goto          6
      73: aload_1
      74: monitorexit
      75: goto          85
      78: astore        4
      80: aload_1
      81: monitorexit
      82: aload         4
      84: athrow
      85: return

在执行monitorenter时会尝试去获取对象的锁,若是这个对象没被锁定 or 当前线程已拥有了这个对象的锁,则计数器 +1 ,相应地,执行monitorexit时计数器 -1 ,计数器为0,则释放锁。若是获取对象失败,须要阻塞等待。

虽然这种方式能够保证线程安全,可是性能方面会有些问题。

由于Java的线程是映射到操做系统的原声线程上的,因此若是要阻塞 or 唤醒一个线程,须要操做系统在系统态和用户态之间转换,而这种转换会耗费不少处理器时间。

除此以外,这种同步机制在某些状况下有些极端,若是咱们用synchronized关键字修饰前面提到的因式分解的service方法,那么在同一时刻就只有一个线程能执行该方法,也就意味着多个客户端没法同时使用因式分解Servlet,服务的响应性很是低。

不过,虚拟机自己也在对其不断地进行一些优化。

2. 重入

什么是重入?

举个例子,一个加了X锁的方法A,这个方法内调用了方法B,方法B也加了X锁,那么,若是一个线程拿到了方法A的X锁,再调用方法B时,就会尝试获取一个本身已经拥有的X锁,这就是重入。

重入的一种实现方法是:每一个锁有一个计数值,若计数值为0,则该锁没被任何线程拥有。当一个线程想拿这个锁时,计数值加1;当一个线程退出同步块时,计数值减1。计数值为0时锁被释放。

synchronized就是一个可重入的锁,咱们能够用如下代码证实一下看看:

Parent.java

public class Parent {
    public synchronized void doSomething() {
        System.out.println("Parent: calling doSomething");
    }
}

Child.java

public class Child extends Parent {
    public synchronized void doSomething() {
        System.out.println("Child: calling doSomething");
        super.doSomething(); // 获取父类的锁
    }

    public static void main(String[] args) {
        Child child = new Child();
        child.doSomething();
    }
}

输出:

Child: calling doSomething
Parent: calling doSomething

若是synchronized不是一个可重入锁,那么上面代码必将产生死锁。Child和Parent类中doSomething方法都被synchronized修饰,咱们在调用子类的重载的方法时,已经获取到了synchronized锁,而该方法内又调用了父类的doSomething,会再次尝试获取该synchronized锁,若是synchronized不是可重入的锁,那么在调用super.doSomething()时将没法获取父类的锁,线程会永远停顿,等待一个永远也没法得到的锁,即发生了死锁。

4、活跃性与性能

前面在内置锁部分提到过,若是用synchronized关键字修饰因式分解的service方法,那么每次只有一个线程能够执行,程序的性能将会很是低下,当多个请求同时到达因式分解Servlet时,这个应用便会成为 Poor Concurrency。

那么,难道咱们就不能使用synchronized了吗?

固然不是的,只是咱们须要恰当且当心地使用。

咱们能够经过缩小同步块,来作到既能确保Servlet的并发性,又能保证线程安全性。咱们应该尽可能将不影响共享状态且执行时间较长的操做从同步块中分离,从而缩小同步块的范围。

下面来看在JCIP中,做者是怎么实如今简单性和并发性之间的平衡的:

缓存最近执行因数分解的数值及其计算结果的Servlet(线程安全且高效的)

@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;

        // 由于hits和cacheHits也是共享变量,因此须要使用同步 *3
        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) { // *2
                ++hits;
                // 命中缓存
                if (i.equals(lastNumber)) {
                    ++cacheHits;
                    factors = lastFactors.clone();
                }
            }

            // 没命中,则进行计算
            if (factors == null) {
                factors = factor(i); // *3
                // 同步更新两个共享变量
                synchronized (this) { // *1
                    lastNumber = i;
                    lastFactors = factors.clone();
                }
            }

            encodeIntoResponse(resp, factors);
        }

    }

首先,lastNumber和lastFactors做为两个共享变量是确定须要同步更新的,所以在 1 处进行了同步。而后,在 2 处,判断是否命中缓存的操做序列也必须同步。此外,在 *3 处,缓存命中计数器的实现也须要实现同步,由于计数器是共享的。

安全性是实现了,那么性能上呢?

前面咱们说过,应该尽可能将 不影响共享状态执行时间较长 的操做从同步块中分离,从而缩小同步块的范围。那么这个Servlet里不影响共享状态的就是i和factos这两个局部变量,能够看到做者已经将其分离出;执行时间较长的操做就是因式分解了,在 *3 处,CachedFactorizer已经释放了前面得到的锁,在执行因式分解时不须要持有锁。

所以,这样既确保了线程安全,又不会过多影响并发性,而且在每一个同步块内的代码都“足够短”。

总之,在并发代码的设计中,咱们要尽可能设计好每一个同步块的大小,在并发性和安全性上作好平衡。

参考自:《Java Concurrency in Practice》以及其余网络资源

相关文章
相关标签/搜索