JCIP阅读笔记之线程安全性

本文是作者在阅读JCIP过程中的部分笔记和思考,纯手敲,如有误处,请指正,非常感谢~

可能会有人对书中代码示例中的注解有疑问,这里说一下,JCIP中示例代码的注解都是自定义的,并非官方JDK的注解,因此如果想要在自己的代码中使用,需要添加依赖。移步:jcip.net

一、什么是线程安全性?

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

示例:一个无状态的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类,但是二者不会相互影响,最后的计算结果仍然是正确的,为什么呢?因为这两个线程并没有共享状态,他们各自访问的都是自己的局部变量,所以像这样 无状态的对象都是线程安全的

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

二、原子性

我理解的原子性就是指一个操作是最小范围的操作,这个操作要么完整的做要么不做,是一个不可分割的操作。比如一个简单的赋值语句 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、静态内部类、枚举都可以保证线程安全,在这里就不赘述了。

三、加锁机制

还是回到因数分解那个例子,如果希望提升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()时将无法获取父类的锁,线程会永远停顿,等待一个永远也无法获得的锁,即发生了死锁。

四、活跃性与性能

前面在内置锁部分提到过,如果用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》
以及其他网络资源

;