要编写正确的并发程序,关键在于:
在访问共享的可变状态时,需要进行正确的管理。
可见性
public class NoVisibility {
private static boolean ready;
private static int number;
private static class ReaderThread extends Thread {
public void run() {
while (!ready) {
Thread.yield();
System.out.println(number);
}
}
}
public static void main(String[] args) {
new ReaderThread().start();
new ReaderThread().start();
new ReaderThread().start();
number = 42;
ready = true;
}
}
-
这段代码可能出现的结果
- 输出0: 因为ReaderThread可能看到了ready的值,但却没看到number的值。
- 持续的循环下去:因为可能很长时间内,ReaderThread都无法看到ready的值。
- 输出42: ReaderThread同时看到了number和ready的值。
在没有同步的情况下,编译器,处理器以及运行时等都可能对操作的执行顺序进行一些意想不到的调整,在缺乏足够同步的多线程程序中,要想对内存操作执行顺序进行判断,几乎无法得出正确的结论。
失效数据
当ReaderThread查看ready变量时,可能会得到一个已经失效的值,而且失效值可能不会同时出现:一个线程可能获得了某个变量的最新值,而获得了另一个变量的失效值。
- 在SynchronizedInteger中,通过对get和set方法进行同步,使其成为一个线程安全类(需要将对象getter/setter方法都进行同步)。
非原子的64位操作
最低安全性:在没有进行同步时读取某个变量,可能会得到一个失效值,但这个值至少是由之前某个线程设置的,而非随机值。这种安全性保证也被称为最低安全性。
- 不符合最低安全性的变量:
非volatile类型的64位数值变量(double和long)
由于Java内存模型要求,变量的读取操作和写入操作必须都是原子操作,但对于非volatile类型的long和double变量,JVM允许将64位的读操作或写操作分解为两个32位操作。
- 所以当读取一个非volatile类型的long变量时,如果对该变量的读操作和写操作在不同的线程中进行,那么可能会只读取到这个变量的高32位或者低32位。
- 所以在多线程中,double和long需要用volatile声明,或者用锁保护起来。
加锁和可见性
- 加锁的含义不仅仅局限于互斥行为,还包括内存的可见性。为了确保所有线程都能看到共享变量的最新值,所有执行读操作或者写操作的线程都必须在同一个锁上同步。
volatile变量
- java提供了一种稍弱的同步机制:volatile变量,用来确保将变量的更新操作通知到其他线程。当把变量声明位volatile类型后,编译器与运行时都会足以到这个变量是共享的。因此不会将该变量上的操作与其他内存操作一起重排序。volatile变量不会被缓存在寄存器或者其他对处理器不可见的地方,因此在读取volatile类型的变量时总会返回最新写入的值。
- 从内存的可见性来看,
读取volatile相当于进入同步代码块,写入volatile变量相当于退出同步代码块。
- 但是不建议过度使用volatile提供的可见性,如果代码中依赖volatile变量来控制可见性,通常比使用锁的代码更脆弱,也更难理解。
- volatile正确的使用方式:
确保它们自身状态的可见性
,确保它们所引用对象状态的可见性
,以及表示一些重要的声明周期事件的发生
(例如初始化,关闭,循环退出条件等。)
/**
* 数绵羊
*/
volatile boolean asleep;
while(!asleep){
countSomeSheep();
}
-
加锁机制既能保证可见性,又可以确保原子性。而volatile变量只能保证可见性
。 -
当且仅当满足以下所有条件时,才应该使用volatile变量:
- 对变量的写入操作不依赖变量的当前值,或者你能确保只有单个线程更新变量的值。
- 该变量不会与其他状态变量一起纳入不变性条件中。
- 在访问变量时不需要加锁。
发布与溢出
-
发布(publish)
一个对象,指的是对象能够在当前作用域之外的代码中使用。-
例如,将一个指向该对象的引用
- 保存到其他代码可以访问的地方
- 或者在某一个非私有的方法中返回该引用。
- 或者即哪个引用传递到其他类的方法中。
-
-
但如果在发布时要确保线程安全性,则可能需要同步。发布内部状态可能会破坏封装性,并使程序难以维持不变性条件。
- 例如在对象构造完成之前就发布该对象,就会破坏线程安全性。
- 当某个不应该发布的对象被发布时,这种情况就被称为
逸出(Escape)
。
public class UnsafeState {
private String[] states = new String[]{
"A","B","C","D","E"
};
public String[] getStates(){
return states;
}
}
- 任何调用者都可以修改states里的内容,数组states已经逸出了它所在的作用域:因为作为私有变量的内容已经被发布了。
- 当发布一个对象时,在该对象的非私有域中的对象同样会被发布。
- 如果一个已经发布的对象能够通过非私有的变量引用和方法调用到达其他对象,那么这些对象也会被发布。
- 当某个对象逸出后,你必须假设有某个类或者线程在误用该对象。这正是使用封装最主要的原因:封装能够使得对程序的正确性进行分析变得可能,并使得无意中破坏设计约束条件变得更难。
安全的对象构造过程
- 不要在构造过程中使this引用逸出。
- 当且仅当对象的构造函数返回时,对象才处于可预测和一致的状态。
线程封闭
- 当访问共享的可变数据时,通常需要使用同步。
- 避免使用同步的方式就是不共享数据,如果仅在当线程内访问数据,就不需要同步。这种技术就被称为
线程封闭(Thread Confinement)
-
JDBC的Connection对象就使用了线程封闭技术。在典型的服务器应用程序中,线程从JDBC连接池中获得一个Connection对象,并且用该对象来处理请求,使用完后再将对象返回给连接池。
- 由于大多数请求(如Servlet)都是由单线程采用同步的方式来处理,并且在Connection对象返回之前,连接池不会再将它分给其他线程。因此这种连接管理模式其实是隐式的将Connection对象封闭在线程中。
Ad-hoc线程封闭
Ad-hoc线程封闭是指,维护线程封闭性的职责全部由程序实现来承担。
- Ad-hoc线程封闭是非常脆弱的,因为没有一种语言特性,例如可见性修饰符或局部变量,能够将对象封闭到目标线程上。
- 当决定使用线程封闭技术时,通常是因为要将某个特定的子系统实现为一个单线程子系统。在某些情况下,单线程子系统提供的简便性要胜过Ad-hoc线程封闭技术的脆弱性。
- 由于Ad-hoc的脆弱性,因此在程序里尽量少使用,尽可能的使用更强的线程封闭技术(如栈封闭和ThreadLocal类)
栈封闭
栈封闭是线程封闭的一种特例,在栈封闭中,只能通过局部变量才能访问对象。局部变量的特性之一就是封闭在执行线程中。它们位于执行线程的栈中,其他线程无法访问这个栈。
ThreadLocal类
维护线程封闭性的一种更规范方法是使用ThreadLocal,这个类能使线程中的某个值与保存值的对象关联起来。ThreadLocal提供了get/set等访问接口和方法,
ThreadLocal是什么
ThreadLocal是一个创建线程局部变量的类。
使用了ThreadLocal创建的变量只能被当且线程访问,其他线程无法访问和修改。
ThreadLocal的使用
private void testThreadLocal() {
Thread t = new Thread() {
ThreadLocal mStringThreadLocal = new ThreadLocal<>();
@Override
public void run() {
super.run();
mStringThreadLocal.set("123");
mStringThreadLocal.get();
}
};
t.start();
}
为ThreadLocal设置初始值的话,则需要重写initialValue
方法:
ThreadLocal mThreadLocal = new ThreadLocal() {
@Override
protected String initialValue() {
return Thread.currentThread().getName();
}
};
对象存放
本质上ThreadLocal是在堆上创建对象,但是将对象引用持有在线程的栈内存上。
许多事务性的框架功能,通过将事务的上下文保存在静态的ThreadLocal对象中,当需要判断是哪一个事务时,只需要从ThreadLocal对象中读取事务上下文即可。
不变性
满足同步需求的另一种方法是使用不可变对象(Immutable Object)
,之前的例如得到失效数据,丢失更新操作或者观察到某个对象处于不一致的状态等问题,都与多线程试图同时访问一个可变变量有关,如果这个变量是不可变的,那么这些问题也就自然消失了。
不可变对象一定是线程安全的
。
对象不可变的条件
当满足以下条件时,对象才是不可变的:
- 对象创建后其状态就不可修改。
- 对象的所有域都是final类型。
- 对象是正确创建的(对象创建期间,this引用没有逸出)
final域
关键字final用于构造不可变对象。final类型的域是不可修改的,但如果final域所引用的对象是可变的,那么这些被引用的对象是可以修改的。
在java的内存模型中,final域还有特殊的语义:final域能确保初始化过程的安全性,从而不受限制的访问不可变对象,并在共享这些对象时无需同步。
- 正如
除非需要更高的可见性,否则应将所有的域都声明为私有域
是个优秀的编程习惯一样,除非需要某个域是可变的,否则都应该声明为final域
也是一个良好的编程习惯。
安全发布
在某些情况下,我们需要在多个线程之间共享对象,此时必须确保安全地进行共享
不正确的发布
不能指望一个未被完全创建的对象拥有完整性。
public class Holder {
private int n;
public Holder(int n) {
this.n = n;
}
public void assertSanity() {
if (n != n) {
throw new AssertionError("this statement is false");
}
}
}
在发布Holder的线程发布完成之前,Holder域是个失效值,此时的n可能是空引用。
不可变对象与初始化安全性
Java内存模型对不可变对象的共享提供了一种特殊的初始化安全性保证。
任何线程都可以在不需要额外同步的情况下安全的访问不可变对象,即使在发布这些对象的时候没有使用同步
如果final类型的域所指向的是可变对象,那么在访问这些域所指向的对象的状态时仍需同步。
安全发布的常用模式
要安全的发布一个对象,对象的引用以及对象的状态必须同时对其他线程可见。一个正确构造的对象可以通过以下方式来安全的发布:
- 在静态初始化函数中初始化一个对象引用。
- 将对象的引用保存到volatile类型的域或者atomicReferance对象中。
- 将对象的引用保存到某个正确构造对象的final类型域中。
- 将对象的引用保存到一个由锁保护的域中。
线程安全库中的容器提供的安全发布保证:
- 通过将一个键或一个值放入
Hashtable
,synchonizedMap
,ConcurrentMap
中,可以安全的将它发布给任何从这些容器访问它的线程(不论直接访问还是迭代器访问) - 通过将某个元素放入
Vector
,CopyOnWriteArrayList
,CopyOnWriteArraySet
,SynchonizedList
,SynchonizedSet
中,可以将元素安全地发布到任何从这些容器中访问该元素的线程。 - 通过将某个元素放入
BlockingQueue
或者ConcurrentLinkedQueue
中,可以将元素安全地发布到任何从这些队列中访问该元素的线程。
通常,要发布一个静态构造的对象,最简单和最安全的方式就是使用静态的初始化构造器。
public static Holder holder = new Holder(1);
由于静态初始化构造器由JVM在类的初始化阶段执行,在JVM内部存在着同步机制,因此通过这种方式初始化的任何对象都可以被安全的发布。
事实不可变对象
- 即便对象从技术上来看是可变的,但是其状态在发布后不会被改变,就是
事实不可变对象(Effectively Immutable Object)
。 - 在没有额外同步的情况下,任何线程都可以安全地使用被安全发布的事实不可变对象。
可变对象
-
如果对象在构造后可以被修改,那么安全发布只能保证发布当时的可见性。对象的发布需求取决于它的可变性:
- 不可变对象可以通过任意机制发布。
- 事实不可变对象必须通过安全方式来发布。
- 可变对象必须通过安全方式来发布,并且必须是线程安全的,或者由某个锁保护起来。
安全的共享对象
在并发程序中使用和共享对象的时候,可以使用一些实用的策略,包括:
-
线程封闭
:线程封闭的对象只能由一个线程拥有,对象封闭在该线程中,并且只能由这个线程修改。 - 只读共享:在没有额外同步的情况下,共享的只读对象可以由多个线程并发访问,但任何线程都不能修改它。共享的只读对象包括不可变对象和事实不可变对象。
-
线程安全共享
:线程安全的对象在其内部实现同步,因此多个线程可以通过对象的公有接口来进行访问而不需要进一步的同步。 -
保护对象
:被保护的对象只有通过持有特定的锁来访问。保护对象包括封装在其他线程安全对象中的对象,以及发布的并且由某个特定锁保护的对象。