java 并发实践 - Chapter 2(Thread Safety) 笔记

原创 2016年06月01日 14:13:17

Thread Safety


要做到线程安全,核心是控制对状态(state)的访问。
(对象的)状态:通常是指它那些共享的(shared)、可变的(not final)的成员变量。

我们知道,线程之间是共享内存的(成员变量都分配在内存中)。所以它们有能力同时访问同一个 state ,这将破坏线程安全。我们需要某种机制进行访问的同步。
相比之下,由于线程之间各自持有堆栈,这些堆栈不是共享的。因此,当不同的线程访问同一个函数的局部变量时(局部变量都分配在堆栈中),是线程安全的。

2.1 What is thread safety?

来看书中的一个例子:

// 一个大整数提取因子,并返回
// 只访问了局部变量,所以是线程安全的
@ThreadSafe
public class StatelessFactorizer implements Servlet {
    public void service(ServletRequest req, ServletResponse resp) {
        BigInteger i = extractFromRequest(req);
        BigInteger[] factors = factor(i);
        encodeIntoResponse(resp, factors);
    }
}

这是一个无状态(stateless)的例子:
不同的线程访问 service(req, resp) 时,在各自堆栈中生成一个 i 和 factors。正如之前所说,这些堆栈是相互隔离的(线程A的修改不会影响到线程B),是线程安全的。
也就是说,无状态的对象总是线程安全的

2.2 Atomicity(原子性)

原子性的必要性 (read-modify-write)

再来看一个相近的例子:

// 一个大整数提取因子,并返回
// 访问了成员变量 count,存在隐患
@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);
    }
}

在该例子中,仅仅是对成员变量 count进行修改,便不再是线程安全的。
原因在于,count++并非原子操作
事实上,count ++ 可以分解为三条指令:

1. 读取到寄存器(cache): MOV cache, count;
2. 在寄存器中计算:       ADD cache, 13. 写回内存(count):    MOV count,cache

这3步应该保持原子性,不可被打断。一旦被线程切换打断,将会得到不可预测的错误答案。
这种读取-修改-写回的操作经常出现,书中称之为 read-modify-write

原子性的保持

我们已经知道保持原子性的必要性,但是我们应该怎么做呢?
好在 java.util.concurrent.* 中,为我们提供了解决方案。
从名称上看,这是一个关于并发的包,里面封装了一些线程安全的类。
让我们用线程安全的 AtomicLong 来替换原来的 long:

// 一个大整数提取因子,并返回
// 访问了线程安全的成员变量 count,保持了原子性
@ThreadSafe
public class CountingFactorizer implements Servlet {
    //private long count = 0;
    private final AtomicLong count = new AtomicLong(0);

    //public long getCount() { return count; }
    public long getCount() { return count.get(); }

    public void service(ServletRequest req, ServletResponse resp) {
        BigInteger i = extractFromRequest(req);
        BigInteger[] factors = factor(i);
        //++count; 
        count.incrementAndGet(); // 增加的计数变量
        encodeIntoResponse(resp, factors);
    }
}

感想

好奇地点开了 AtomicLong ,看了一下实现,有两点值得注意的:
1.它使用了 volatile 关键字:(对它比较陌生,有空可以了解一下)

    private volatile long value;

2.从头至尾没有发现 synchronized 关键字。

那我就疑惑了,它是怎么保持同步的呢?
后来搜到了这篇文章:http://www.cnblogs.com/Mainz/p/3556430.html
里面提到了“CAS(Compare and Swap)无锁算法”,听上去好像不用锁的,先mark一下。
AtomicLong 确实调用了一个 compareAndSwapLong(),不过是 native 方法,暂时看不到源码。

2.3 Locking(上锁)

注意到BigInteger[] factors = factor(i);这行,其中的factor()可能是一个比较耗时的操作。很自然的,我们想到用缓存来优化,保存上一次的结果lastNumber&&lastFactors。于是,我们写出了如下代码:

@NotThreadSafe
public class UnsafeCachingFactorizer implements Servlet {
    private final AtomicReference<BigInteger> lastNumber
            = new AtomicReference<BigInteger>();
    private final AtomicReference<BigInteger[]>  lastFactors
            = new AtomicReference<BigInteger[]>();
    public void service(ServletRequest req, ServletResponse resp) {
        BigInteger i = extractFromRequest(req);
        if (i.equals(lastNumber.get())) // 1. 从缓存取 lastNumber
            encodeIntoResponse(resp,  lastFactors.get() ); // 2. 从缓存取 lastFactors

        else {
            BigInteger[] factors = factor(i);
            lastNumber.set(i);          // 3. 缓存 lastNumber
            lastFactors.set(factors);   // 4. 缓存 lastFactors
            encodeIntoResponse(resp, factors);
        }
    }
}

To preserve state consistency, update related state variables in a single atomic operation.

很遗憾,尽管lastNumber&&lastFactors都是原子类,但是组合在一起却保证不了原子性。注意到它们须满足不变式(invariant)lastFactors = factor(lastNumber),这就要求对这两个变量的读和写必须各自原子的。也就是说,步骤 1、2 是原子的(一起读),不允许切换线程。步骤 3、4 同理,否则就会破坏不变式。 那么如何解决呢,请听下文分解。

2.3.1. Intrinsic Locks (固有锁)

java 在语言层面上提供了锁机制:synchronized关键字。该关键字可以修饰一个代码块(the synchronized block),以保证该 block 的原子性:

synchronized (lock) { // 这里的 lock 可以是任意的 Object
    // synchronized block
    // Access or modify shared state guarded by lock
}

顺便一提,synchronized可重入锁(Reentrant Lock),这点后面会详细解释。
一个小的变种是,synchronized可以修饰函数,此时函数体即作为代码块。值得注意的是,此时被上锁的对象默认为函数调用者,即我们常见的synchronized (this){}中的this;静态函数则对应class


答案已经浮出水面了,我们加上 synchronized ,即可解决之前的同步问题:

@ThreadSafe
public class SynchronizedFactorizer implements Servlet {
    @GuardedBy("this") private BigInteger lastNumber;
    @GuardedBy("this") private BigInteger[] lastFactors;
    // use synchronized
    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;
            encodeIntoResponse(resp, factors);
        } 
    }
}


2.5. Liveness and Performance(性能)

回顾上边的例子,尽管线程安全了,但是性能却变得很差。无脑使用同步块,导致不同的 client 无法同时访问这个service()。每个线程排队等候,完全不是并发了!!!
注意到不是所有的步骤都需要同步,我们做了如下调整:

@ThreadSafe
public class CachedFactorizer implements Servlet {
    @GuardedBy("this") private BigInteger lastNumber;
    @GuardedBy("this") private BigInteger[] lastFactors;
    public void service(ServletRequest req, ServletResponse resp) {
        BigInteger i = extractFromRequest(req);
        BigInteger[] factors = null;
        synchronized (this) {
            if (i.equals(lastNumber)) 
                factors = lastFactors.clone();  // 1. 深拷贝, 防止调用 encodeIntoResponse 时, 数据已变
        }
        if (factors == null) {
            factors = factor(i);    // 2. factor 是耗时操作, 不要包在 synchronized 里
            synchronized (this)  {
                lastNumber = i;
                lastFactors = factors.clone();
            }
        }
        encodeIntoResponse(resp, factors);
    }
}


注意点

  1. 1处的深拷贝有点ThreadLocal的味道,用空间换时间。具体而言,多个线程访问 lastFactors 的时候,各自拷贝了一个备份,那么调用encodeIntoResponse(resp, factors);的时候就不需要同步了。
  2. 正如文中所说,耗时操作不要放在同步块里,否则很影响性能:
    Avoid holding locks during lengthy computations or operations at risk of not completing quickly such as network or console I/O.


总结

在保证线程安全的情况下,同时得考虑其性能。大的同步块想一想能不能分解成几个小的。在代码复杂性和性能上寻求一个平衡点。

版权声明:本文为博主原创文章,未经博主允许不得转载。

Chapter 2. Thread Safety

informally, an object's state is its data, stored in state variables such as instance or static fiel...

Java并发编程(Java Concurrency)(9)- 线程安全与共享资源(Thread Safety and Shared Resources)

摘要:这是翻译自一个大概30个小节的关于Java并发编程的入门级教程,原作者Jakob Jenkov,译者Zhenning Lang,转载请注明出处,thanks and have a good ti...

Java并发编程实践笔记(四)——chapter3(重排序优化,可见性)

重排序 编译器重排序。 CPU重排序。 重排序会造成程序运行的结果和我们预期的不同。 同步机制会解决这个问题,但是重排序是一种优化手段,同步机制会破坏这一种优化,会在一定程度上影响性能。 非原子的64...
  • xxcupid
  • xxcupid
  • 2016年10月24日 19:13
  • 266

Java并发编程实践笔记(二)——chapter1(线程安全)

1.原子性与线程安全 多个线程操作一个全局变量int i,操作是i++。 1.可见性 通过volatile关键字是解决线程安全问题的必要条件。 2.原子性 保证原子性也是解决线程安全问题的必要条件。因...
  • xxcupid
  • xxcupid
  • 2016年10月11日 23:21
  • 214

Java并发编程实践笔记(三)——chapter1(synchronized锁)

锁 synchronized 内部锁(intrinsic locks),又称监视器锁(Monitor locks)。 互斥锁 synchronized块就是一种互斥锁(mutual Exclusive...
  • xxcupid
  • xxcupid
  • 2016年10月24日 14:04
  • 209

Java并发编程实践笔记(一)——chapter1(IO,连接,线程)

1.IO,多线程和多核 原文:程序有时候需要等待外部的操作,比如输入和输出。并且在等待的时候不可能进行有价值的工作。在等待的时候,让其他程序运行会提高效率。 这里可以把IO比作烧开水,其他操作比作看报...
  • xxcupid
  • xxcupid
  • 2016年10月11日 23:14
  • 287

Thread-safety with the Java final keyword

As of Java 5, one particular use of the final keyword is a very important and often overlooked weapo...

JCIP-2-Thread Safety

2-Thread Safety 2.1-What is thread safety start Writing thread-safe code ...

【Java多线程】之六:Synchronization and Thread Safety

Java provide multi-threaded environment support using Java Threads, we know that multiple threads cr...

Java多线程学习(二)——Thread Safety

写多线程程序的核心不在于使用锁或者线程,他们都只是工具,真正核心的要点在于管理mutable state....
内容举报
返回顶部
收藏助手
不良信息举报
您举报文章:java 并发实践 - Chapter 2(Thread Safety) 笔记
举报原因:
原因补充:

(最多只允许输入30个字)