前面《线程安全性》介绍了竞态条件导致状态共享成为不可能,需要原子性操作,要确保原子性操作需要加锁。
可能大家都觉得加锁只是为了确保原子性操作,但是当前线程修改了对象状态之后怎么让其他线程发现呢(内存可见性)? 所以下面我们将结束对象的发布和共享,和《线程安全性》合起来我们就知道如何构建线程安全类和利用J.U.C类库来构建并发应用程序。
可见性
在单线程环境中,从上到下先对变量进行修改再用变量做其他操作,很合理,因为代码是从上到下执行的,不会发生任何问题。那么多线程环境下呢?一个线程修改了一个变量,修改后的变量如何能让那些等待的线程适时的看到其他线程写入的值呢?也就是说当一个线程修改一个共享变量时,对于其他线程这个变量不可见,需要可见。这需要同步机制。
例子:
public class NoVisibility {
private static boolean ready;
private static int number;
private static class ReadyThread extends Thread {
@Override
public void run() {
while (!ready) Thread.yield();
System.out.println(number);
}
}
public static void main(String[] args) {
(new ReadyThread()).start();
ready = true;
number = 5;
}
}
主线程和读线程共享ready和number变量,主线程启动读线程,读线程一直在循环直到ready被主线程修改为true,那么是不是很有可能输出0?是不是觉得这种代码无意义,结果不可测?所以需要对ready和number的可见性进行控制,如果想要主线程对ready和number被赋值后才对读线程可见,那就要做好同步。
失效数据
失效数据指的是获得的数据是失效的,比如一个共享值正在被修改,你却获得了修改之前的值,它正在修改说明已经不用过去没用的值了。如下:
public class DataValidity {
private int value = 2;
public int getValue() {
return value;
}
public void setValue(int value) {
this.value = value;
}
}
一个线程在进行set,一个线程又来进行get由于get比set快,所以get的线程很可能获得了修改之前的值了。
public class DataValidity {
private int value = 2;
public synchronized int getValue() {
return value;
}
public synchronized void setValue(int value) {
this.value = value;
}
}
这样就能修改的时候另一个线程获得的值不是失效的。
Volatile变量
java提供一种削弱的同步机制,即Volatile变量,来确保将变量的更新操作通知给其他线程。当把变量声明为Volatile后,编译器和运行时都会注意到这个变量是共享的,因此不会将该变量的操作和其他内存的操作一起排序。Volatile变量也不会被缓存在寄存器或者对其他处理器不可见的地方,因此获取Volatile变量的值时都会获得最新写入的值。
比如上面类DataValidity 的value可以用Volatile修饰,这样synchronized就可以不用了,不同之处是访问Volatile变量时不会使线程阻塞,这样就没机制使线程“排队”,所以一般情况下还是使用锁不使用Volatile变量。
非原子的64位操作
java内存模型要求,对变量读和写操作都必须是原子性的,但对于非Volatile的long和double变量,JVM允许将64位的读写分为两个32位的进行,因此在对一个64位非volatile变量进行读写但分别在不同线程进行时,那么很可能读到变量高或低32位的值,因此需要加锁或者用volatile修饰。
发布与逸出
发布对象最简单的方法是将对象的引用保存到一个公共的静态变量中,如下:
public static Set<Secret> konwsecrets;
public void initialize(){
konwsecrets=new HashSet<Secret>;
}
同样,如果一个非私有的方法返回一个引用,那么同样会发布返回的对象,如下:
class UnsafeStates{
private String[] states=new String[]{
"ad","df"...
};
public String[] getSates(){
return states;
}
}
按照这样的方式发布states,会导致任何调用者都能修改这个数组的内容,很明显states数组已经逸除了它的作用域,因为本应私有的变量已经发布了。
无论其他线程使不使用已发布的引用执行任何操作,误用该引用的风险始终都存在。当某个对象被逸出后,你必须假设某个类或者线程可能会误用该对象。这正是需要使用封装的主要原因:封装能够使程序的正确性分析变得可能,并使得无意中破坏设计约束条件变得更难。
隐式的使用this引用逸出
public class ThisEscape {
private int j = 2;
public ThisEscape() {
new Thread() {
public void run() {
for (int i = 1; i <= 5; i++) {
i = j;
put();
System.out.print(i + " ");
}
}
}.start();
j = 5;
}
public void put() {
System.out.print(j + " ");
}
}
Thread是一个内部类,创建内部类对象之前就隐式创建了一个ThisEscape的实例所以在发布这个内部类对象时也隐式的发不了ThisEscape实例本身。
安全对象构造的过程
前面ThisEscape的例子在ThisEscape对象构造之前就随着Thread发布了一个“残缺”的ThisEscape对象,这样是出问题的。
什么情况下会出现问题?当在构造函数中启动一个线程,那么这个残缺”的ThisEscape对象就会被线程使用也就是使用了一个失效的对象,后面就会出现一系列的问题。在构造函数中创建线程是没有错的,但是不要启动它,而是通过start或者initailize后面来启动它。
那么怎么解决这个问题呢?那就把内部类放在一个私有方法中创建,如下:
...
private Thread thread;
private void newInternalClass(){
thread= new Thread() {
public void run() {
for (int i = 1; i <= 5; i++) {
i = j;
put();
System.out.print(i + " ");
}
}
};
}
...
这样是个好办法去隔离会逸出的代码,还有要注意的是构造方法特别是构造方法内对本对象进行改造时要加锁不然在改造过程中就被线程读取就是读取了失效对象。
线程封闭
保证线程安全最简单最稳定的方法是使同步代码块没有共享资源,这种技术叫做“线程封闭”。所以在设计程序时需要考虑怎么实现“线程封闭性”,java语言及核心库对线程封闭性提供了一些支持,如局部变量和ThreadLocal类,但是这不够需要程序员确保封闭在线程的对象不会逸出。、
Ad-hoc线程封闭
Ad-hoc线程封闭是指,维护线程封闭完全依赖程序实现,由于没有任何语言特性(可见性修饰符、局部变量能将对象封闭在目标线程上),所以封闭性是非常脆弱的。在某种情况下将某个特定子系统实现为单线程子系统的简便性要胜过Ad-hoc的脆弱性。
为什么说完全通过实现线程封闭性是脆弱的呢? 因为java不能通过代码控制内存等,所以如果你用程序保证封闭性是不可靠的,所以你尽可能在程序的基础上加上其他线程封闭技术如栈封闭、ThreadLocal类。
栈封闭
在栈封闭中只能通过局部变量访问对象。通过局部变量访问对象能更容易使对象封闭于线程中,局部变量的固有特性就是封闭在执行线程的栈中,其他线程无法访问这个栈。
看下面代码:
public int StackClosed(ArrayList<String> arrayList) {
int i = 0;
ArrayList<String> al = new ArrayList(arrayList);
for (int j = 0; j < al.size(); j++) {
i++;
}
return i;
}
对于基本类型的局部变量(例如上面程序的i),由于任何方法都无法获得对基本类型的引用所以它能一直封闭在线程内。
注意到外部传过来的arrayList,我们并没有直接使用而是把它的内容复制了给同类型al,这是为什么呢?因为其他线程可能在这个方法进行中修改了arrayList导致数据失效,所以这样弄了一个局部变量封闭在这个方法之内。
如果在线程内部上下文使用非线程安全的对象,那么该对象仍然是线程安全的。然而后期维护可能会修改代码把该对象引用到其他地方所以要注意。
ThreadLocal类
这个类提供“线程局部变量”,这些变量与它们在每个线程中的正常值不同,因为线程通过访问get或者set来拥有自己的独立初始化的变量副本。ThreadLocal实例在类中是典型的私有静态字段,是希望将状态与线程联系起来(例如:一个用户ID或事务ID)。
只要线程存活并且ThreadLocal实例可以访问,每个线程都保存对其线程局部变量副本的隐含引用;线程消失之后,线程本地实例的所有副本都将受到垃圾回收(除非存在对这些副本的其他引用)。
这段是我翻译jdk1.8而来的,说实话看到这里我也不是很清楚这文档表述的意思,但是大概知道线程类中应该有这个类变量相关的字段来存储那些独立的变量副本,和通过get或者set来进行初始化,所以我们下面来看看源代码验证下,大概了解下。
先看下Thread的变量是否有这个类变量相关的字段来存储那些独立的变量副本:
在看下ThreadLocal的get方法:
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();
}
可以看到获得副本变量是根据当前线程获得一个ThreadLocalMap,如果这个map不为空就根据当前ThreadLocal对象获取,如果副本变量为空就进入setInitialValue()设置初始化变量
private T setInitialValue() {
T value = initialValue();
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null)
map.set(this, value);
else
createMap(t, value);
return value;
}
但这里的value为null具体值应该是set来完成。
现在看看get()里面的getMap(t)。
ThreadLocalMap getMap(Thread t) {
return t.threadLocals;
}
可以看到是获得当前线程的ThreadLocalMap 变量。
总结:
副本是存放在threadLocals map中的,键是ThreadLocal类当前变量,值就是副本,通过get可以获得也可以赋初始值,通过set进行修改。
例子如下:
private static ThreadLocal<Connection> connectionHolder
= new ThreadLocal<Connection>() {
public Connection initialValue() {
return DriverManager.getConnection(DB_URL);
}
};
public static Connection getConnection() {
return connectionHolder.get();
}
不变性
满足同步需求的另一种方法是使用不可变对象。线程安全是不可变的固有属性之一,它们的不变条件是由构造函数构建的,只要它们的状态不改变,那么这些不变性条件就得以维持。
在不可变对象的内部仍可以使用可变对象来管理它们的状态,如下:
import java.util.HashSet;
import java.util.Set;
class ThreeStooges {
private final Set<String> names = new HashSet<String>();
public ThreeStooges() {
names.add("sdd");
names.add("sd");
names.add("po");
}
public Set<String> getNames() {
return names;
}
public static void main(String[] args) {
ThreeStooges threeStooges = new ThreeStooges();
HashSet<String> ns = (HashSet<String>) threeStooges.getNames();
ns.remove("sd");
System.out.println(threeStooges.getNames());
}
}
注意:不可变对象不是真的不可变,上面代码中我通过改变引用来修改threeStooges 对象成员是可以的,这里的不可变是指你不能直接通过对象引用threeStooges来对对象进行修改,这也在一定程度上隔离了修改。
正如除非要更高的可见性,否则应将所有的域都声明为私有是一个良好的习惯,除非需要某个域是可改变的,否则将其声明为final域也是一个好的习惯。
安全发布
到目前为止我们讨论的重点是如何确保对象不被发布,例如让对象封闭在线程或者另一个对象内部。当然我们希望在某些情况下多个线程间共享对象,此时必须安全地进行共享。
没有做足够同步的情况下发布对象:
//不安全的发布
public Holder holder;
public void initialize() {
holder = new Holder(42);
}
public class Holder{
private int n;
public Holder(int n){
this.n=n;
}
public void assertSanirty(){
if(n!=n)
throw new AssertionError("This statement is false");
}
}
由于存在可见性问题,其他线程可能看到不一致的holder状态,即使构造函数中已经正确地构建了不变性条件,这种不正确的发布将导致其他线程看到尚未构造完成的对象。
由于没有使用同步来确保Holder对象对其他线程的可见,因此Holder称为“未被正确发布”。其他线程可以看到Holder域是一个失效值;线程看到Holder引用的值是最新的,但是Holder状态的值却是失效的。情况变得不可预测的是,某个线程在第一次读取的域是失效的,而再次读取这个域的时得到一个新的值,这也是assertSanirty抛出AssertionError的原因。
安全发布的常用模式
可变对象必须通过安全的方式来发布,接下来我们将介绍如何确保使用对象的线程能够看到该对象处于已发布的状态,稍后介绍发布后如何对其可见性进行修改。
- 在静态初始化函数中初始化一个对象引用。
- 将对象的引用保存到Volatile类型的域或者AtomicReference对象中。
- 将对象引用保存到正确构造对象的final类型域中。
- 将对象的引用保存到一个由锁保护的域中。
在线程安全容器内部的同步意味着,在将对象放入到容器,例如Vector或synchronizedList时,将满足上述最后一个要求。线程安全库中的容器类提供了以下的安全发布保证。
- 放在Hashtable、synchronizedMap或者ConcurrentMap中的键或者值,可以安全地将它发布给访问这些容器的线程。
- 放在Vector、CopyOnWriteArrayList、CopyOnWriteArraySet、synchronizedList或synchronizedSet中的元素,可以安全地将它发布给访问这些容器的线程。
- 放在BlockingQueue或者ConcurrentLinkedQueue中的元素,可以安全地将它发布给访问这些队列的线程。
类库中其他数据传递机制同样能实现安全发布,在介绍这些机制时将讨论它们的安全发布功能。
发布一个静态的构造对象,最简单和最安全的方式是使用静态的初始化构造器:
public static Holder holder=new Holder(42);
静态初始化器由JVM在类的初始化阶段进行。由于在JVM存在同步机制,因此这种方式是安全的。
安全的共享对象
当把对象发布时,需要考虑对象的访问方式,你需要知道在这个引用上可以执行那些操作。在使用它之前是否需要获得一个锁?是否需要修改它?或者只能读取它?
在并发程序中使用和共享对象时,可以使用一些实用的策略:
- 线程封闭: 对象被封闭在线程中,由一个线程拥有,并且只能在这个线程内修改。
- 只读共享:只能读的对象,可以被多个线程同时访问,不存在失效情况。
- 线程安全共享:线程安全的对象在其内部实现同步,因此多个线程通过对象的公有接口来访问它,不需要再来做同步。
- 保护对象:被保护的对象只能通过持有特定的锁来访问。包括封装在其他线程安全对象中的对象和由锁保护的对象。