并发(二)基础之访问安全篇
本篇文章我们尝试探讨多线程带来的问题,多个线程对共享数据的访问可能会产生多线程干涉和内存一致性问题,同步操作引起的竞争可能会带来新的病态行为,死锁、活锁、饿死等。 所有这些问题我们都必须仔细对待,透彻的理解,因为它将影响你程序的正确性。 这篇文章只能算作一个浅浅的导引,因为每次重新理解多线程机制,我总会有新的认识。
多线程干涉
当 多个线程访问同一个对象,那么在这个对象上的操作对单线程来说,顺序是一定的,但是多线程之间可能会相互交错重叠。比如线程A希望某个对象执行ABC三个步骤,而线程B希望这个对象执行CDE步骤,最终这个对象的操作顺序可能会交错执行。
NOTE:多个线程对同一个对象操作出现交错是步骤交错,而不是语句交错,即如果线程A执行c++,而线程B执行c--,看似单条语句不会交错,但是他们会被虚拟机翻译成多个步骤,在这些步骤中就会出现交错。
class Counter {
private int c = 0;
public void increment() {
c++;
}
public void decrement() {
c--;
}
public int value() {
return c;
}
}
我们来看看上面的代码,如果假设c++操作被翻译成三个步骤,1:获取c的值->2:将值增加1->3:存储计算后的值到c,线程A执行increment(),线程B执行decrement(),最终结果c就可能很多样,如果线程B在步骤一获取c的值是0,而线程B在步骤三存储-1到c是所有线程执行的最后一步,那么这个c值就是-1,线程A的结果丢失了。
解决这个问题的原则是让increment()这个操作不会被其它对变量c的操作交错,比如使用synchronized给方法加锁。
内存一致性错误
当多个线程对同一个对象数据有着不一致的视图会产生内存一致性错误,比如主存和线程栈中存储的副本不能保持同步刷新就会导致视图不一致。
我们在回到上面的示例上去,即使线程A所有步骤执行完了,此时线程B执行步骤一,也不一定会获取c的值是1,因为此时线程A中c的视图值为1,而线程B的视图值可能还是0,这就是不一致现象。
NOTE:共享变量被修改之后,什么时候被写入主存是不确定的,所以线程A执行完之后,线程B既有可能获得1,也有可能获得最初值0。
happens-before
为了避免这个问题,我们需要保证当共享变量被一个线程写之后,所有其它线程读这个变量都是可见的。Java内存模型为我们提供了一个happens-before机制,它确保了内存中的写对其它线程可见,如果一个操作happens-before其它操作,那么第一个操作对第二个操作是可见的,synchronized关键字已经创建了happens-before关系。
所以上面这个问题还是可以通过synchronized避免内存一致性问题,如果不考虑多线程干涉问题,或者increment()是一个原子操作,那么可以把变量c声明为volatile,这些将在下文中会介绍。能形成happens-before关系的除了 **synchronized,还有volatile、Thread.start()、Thread.join()**等,它们都是可见性的保证,下面这段happens-before描述摘自摘自《Java Language Specification》:
- An unlock on a monitor happens-before every subsequent lock on that monitor.
- A write to a volatile field (§8.3.1.4) happens-before every subsequent read of that field.
- A call to start() on a thread happens-before any actions in the started thread.
- All actions in a thread happen-before any other thread successfully returns from a join() on that thread.
活跃度问题
尽管我们通过一些有效的方法避免了多线程干涉和内存一致性问题,但是仍然产生活跃度问题,如何避免或者解决活跃度问题会是一个高级话题,这里只对这些问题的概念和产生的场景进行介绍。
死锁(deadlock)
线程永久阻塞,互相等待会导致死锁。
如何快速写出一个死锁的例子?
答案是两个线程等待对方占有的资源,我们设想有两个线程,并且有两个锁,线程A获得了锁1,线程B获得了锁2,之后线程A需要去占有锁2,就会产生阻塞,此时,线程B需要去占有锁1,也会产生阻塞,这两个线程就会死锁,永远无法执行下去。
解决死锁的方式是尝试让一个线程释放锁,先让另一个线程执行下去,然后再执行这个线程,后续章节我们会学习一个显示锁Lock,它会尝试获取锁,如果失败,可以释放自己占有的锁,进而让其它线程能够获得这个被释放的锁,继续执行。
哲学家就餐问题是一个典型的死锁问题:五个哲学家拥有了一只筷子,他们都等待其它哲学家释放筷子,每个人都进入了永久等待状态,按照上面解决死锁的方式,我们可以限制最多只能有四个哲学家去拥有一只筷子,这样就能保证至少有一个哲学家可以用餐。
活锁(livelock)
活锁是两个线程之间互相谦让,导致无法继续执行下去。相对于死锁线程被阻塞无法继续执行,活锁是线程继续执行(不断的谦让),但是无法执行下去了。
饿死(starvation)
当一个锁长久被一个线程占有时,其它线程无法获得调度的机会,这些线程就会阻塞进入饿死状态。
原子操作和volatile关键字
原子操作(Atomic)是不会被干涉且不可分割的操作,所有操作要么一次做完,要么不做。所以 原子操作不会产生多线程干涉问题,但是会产生内存一致性问题。JDK提供了一个原子操作的包java.util.concurrent.atomic,提供了诸如AtomicInteger、AtomicBoolean等类,我们可以直接使用这个类的方法而无需关心多线程干涉问题,而这些类的实现也同时避免了可视性问题,所以我们可以直接使用而无需使用任何关键字修饰,关于原子类的原理,我们会在下篇文章讲到。
对一个volatile变量的写happens-before后续对这个变量的读,它可以解决内存一致问题,变量的写对所有线程可见,但是它可能还会产生多线程干涉问题,即volatile不能保证原子性,对volatile变量进行非原子操作是危险和错误的,synchronized是可以解决可见性和多线程干涉问题。
问题: 这个时候我们就要问了,原子类提供了原子操作,那么除了这些类,Java中哪些操作还会是原子操作呢?我们已经知道int变量c++不是一个原子操作,那么给int变量赋值是原子操作吗?或者说,给一个boolean类型的变量是原子操作吗?给一个引用类型的变量赋值是原子操作吗?我们来看看《The Java™ Tutorials》中的一段话:
However, there are actions you can specify that are atomic:
- Reads and writes are atomic for reference variables and for most primitive variables (all types except long and double).
- Reads and writes are atomic for all variables declared volatile (including long and double variables).
Java内存模型规定了对任何变量(不包括long和duoble,因为可能会分为高32位和低32位操作)的读和写都是原子的,对任何声明了volatile的读和写都是原子的,这里注意是读和写,像c++这种就不属于原子的,它可以被认为是一次读和一次写。
单例模式有一个性能更优的双重检查方式,我们先来看下这种写法的代码:没有把synchronized放到整个方法上,不希望锁的范围过大,所以没有获得锁的情况下有了第一重检查,当多线程执行后,获得锁后我们就需要第二重检查,它用到了volatile和synchronized,synchronized已经解决了可视性问题,为何还需要使用volatile关键字呢?
private volatile FieldType field;
private FieldType getField() {
if (field == null) { // First check (no locking)
synchronized (this) {
if (field == null) // Second check (with locking)
field = new FieldType();
}
}
return field;
}
即使field被声明为volatile,由于不是读和写,所以它并不是原子的,虽然我们使用synchronized给这段代码加锁,但是第一重检查field的代码并没有在加锁的代码块内,如果不使用volatile,有可能会发生指令重排序,即field变量已经被赋值而field还没有初始化完毕的情形,volatile在这里的作用就是保证field = new FieldType()操作不会指令重排序!这里会引入一个新的问题,就是赋值操作是否是原子操作,通过上文知道变量的写是原子操作,例子中的赋值的确是写了,但是不仅仅是写,还有构造过程,所以它不是一个原子操作,如果是field=null这样的赋值,我会认为是简单的写原子操作,如同AtomicReference类的set方法。
综上,我们可以得出一个很重要的结论:volatile可以防止该变量的操作与其他内存操作一起重排序,保证变量的读和写是原子性的,但是不能保证其它操作是原子性,volatile最重要的功能是它的可见性它是一种弱一点的同步机制。最常见是用当作多线程共享的标志:
volatile boolean isShow;
volatile int 和AtomicInteger
如果你能彻底理解volatile的作用,就能理解这两个的区AtomicInteger保证了可见性和所有方法原子性,volatile int保证了可见性,但是只在读写的时候是原子性,可以看作是AtomicInteger的get和set方法,而++操作就不是原子操作。
不可变对象final or immutable
final常量在初始化后就不可更改,所以无需关心多线程问题,记住不可变对象是值得信赖的。final在内存模型中的语义不止是常量这么简单,这里就不延伸了,这个话题会很庞大,我们给出一个疑惑的例子:当一个线程调用write方法,另一个线程调用reader方法,i的值会被确保是3,而j的值不一定为4。
class FinalFieldExample {
final int x;
int y;
static FinalFieldExample f;
public FinalFieldExample() {
x = 3;
y = 4;
}
static void writer() {
f = new FinalFieldExample();
}
static void reader() {
if (f != null) {
int i = f.x; // guaranteed to see 3
int j = f.y; // could see 0
}
}
}
NOTE:关于不可变对象我们无需同步,但是不可变对象里面的可变部分我们要倍加小心,它们还是会产生多线程问题,比如一个final声明的集合对象,它的元素就是可变的。
ThreadLocal<T>源码
避免多线程的问题就是谨慎处理共享变量的访问,ThreadLocal<T>将共享变量T当每个线程的本地变量使用,简单来说,多线程共享了变量ThreadLocal<T>,但是T相当于每个线程内部的本地变量。
那么这和直接在线程内部声明局部变量有什么不同呢? 首先多个线程需要同一个类型的变量,但是他们的值是各自使用,这时候我们就可以使用ThreadLocal<T>供多线程访问,然后在一个线程内部可能在不同方法中访问这个局部变量,或者根本不在线程对象里面访问,这里就需要通过方法参数不断传递这个局部变量,如果使用ThreadLocal<T>,在同一个线程调用的任何方法内部,都能获取到这个局部变量值。
如何设计ThreadLocal<T>供多线程使用?一个普遍的想法是看作是Map<Thread, T>,这个变量存储了所有线程和他们局部变量的映射关系。这个做法是危险的,因为它存取了所有线程的局部变量,任何一个线程都能轻松通过ThreadLocal<T>对象获取到其它线程的数据,更正确的做法应该是将局部变量存储在每个线程内部,它的实现原理是每个Thread线程内部都维护一个这样的map = Map<ThreadLocal, T>,当我们通过ThreadLocal<T>.get()方法获取数据时,其实是通过Thread.currentThread().map.get(ThreadLocal<T>)来获取本地变量值,我们先来看看Thread源码中这个Map变量:
// Thread.java
// ThreadLocal values pertaining to this thread.
ThreadLocal.ThreadLocalMap threadLocals = null;
接下来我们先深入一下ThreadLocalMap类,它是一个通过哈希表+开放寻址法实现的HashMap,哈希表的实现参见《Java Collections Framework(五)Map》。
/**
* ThreadLocalMap is a customized hash map suitable only for
* maintaining thread local values. No operations are exported
* outside of the ThreadLocal class. The class is package private to
* allow declaration of fields in class Thread. To help deal with
* very large and long-lived usages, the hash table entries use
* WeakReferences for keys. However, since reference queues are not
* used, stale entries are guaranteed to be removed only when
* the table starts running out of space.
*/
static class ThreadLocalMap {
/**
* The entries in this hash map extend WeakReference, using
* its main ref field as the key (which is always a
* ThreadLocal object). Note that null keys (i.e. entry.get()
* == null) mean that the key is no longer referenced, so the
* entry can be expunged from table. Such entries are referred to
* as "stale entries" in the code that follows.
*/
static class Entry extends WeakReference<ThreadLocal<?>> {
/** The value associated with this ThreadLocal. */
Object value;
Entry(ThreadLocal<?> k, Object v) {
super(k);
value = v;
}
}
/**
* The initial capacity -- MUST be a power of two.
*/
private static final int INITIAL_CAPACITY = 16;
private Entry[] table;
ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue) {
table = new Entry[INITIAL_CAPACITY];
int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1);
table[i] = new Entry(firstKey, firstValue);
size = 1;
setThreshold(INITIAL_CAPACITY);
}
/**
* Expunge all stale entries in the table.
*/
private void expungeStaleEntries() {
Entry[] tab = table;
int len = tab.length;
for (int j = 0; j < len; j++) {
Entry e = tab[j];
if (e != null && e.get() == null)
expungeStaleEntry(j);
}
}
// 略
}
我们可以得到几个关于ThreadLocalMap的重要知识点:
- 它的每个Entry是一个<ThreadLocal> k, Object v>映射,而它的键是一个WeakReference类型,所以当某个ThreadLocal>变量没有强引用的时候会被JVM垃圾回收,这样这个Map里面就会产生陈旧(Stale)的Entry,其中key是null,value还是一个对象。
- Stale entries会被清理掉,具体看方法expungeStaleEntries(),那么清理时机是什么时候?我们重点看下这里的注释:since reference queues are not used, stale entries are guaranteed to be removed only when the table starts running out of space,意思是没有使用引用队列(参见WeakReference),所以这些key为null的entry会在表空间不足时清理掉,这句话可能不像翻译的这么理解,通过源码我们发现,get、set、remove方法都会清理这些Entry,如果这个ThreadLocal变量再也不会使用,线程长时间不会结束,那么这些Entry将永远不会释放,即使执行了GC,所以会导致内存泄漏问题,更重要的是 线程池里面的线程会复用,这些线程不会终止,每个线程的变量threadLocals将会永久保存所有使用过的ThreadLocal-Value映射,除非我们手动删除。
IMPORTANT:当我们使用完ThreadLocal变量后,必须调用ThreadLocal.remove()方法,清除数据并将当前本地变量的值从线程的变量里面删除。
核心操作都在ThreadLocalMap类,最后我们来看下ThreadLocal类,它很简单,只是通过当前线程获取到threadLocals,然后获取Entry。
public class ThreadLocal<T> {
protected T initialValue() {
return null;
}
public T get() {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null) {
ThreadLocalMap.Entry e = map.getEntry(this);
if (e != null) {
@SuppressWarnings("unchecked")
T result = (T) e.value;
return result;
}
}
return setInitialValue();
}
public void set(T value) {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null) map.set(this, value);
else createMap(t, value);
}
public void remove() {
ThreadLocalMap m = getMap(Thread.currentThread());
if (m != null) m.remove(this);
}
// 略
}
我们还要知道的是,我们可以给ThreadLocal设置初始值。
总结
很多多线程问题可能并不会轻易发现,但是当问题出现时,会带来迷惑,我们必须理解并发机制且谨慎并发程序来避免这些问题。