可见性
可见性是指当一个线程修改了共享变量的值,其他线程能够立即得知这个修改。
在多线程下,面临一个问题就是,无法确保执行读操作的线程能适时地看到其他线程写入的值。为了确保多个线程之间对内存写入操作的可见性,必须使用同步机制。
public class NoVisibility { |
就是因为可见性问题,NoVisibility可能会持续循环下去,因为读线程可能永远都看不到ready值,还可能的是,NoVisibility可能会输出0,因为读线程看到了写入的ready值,但是却没有看到之后写入的number值,这种现象被称为"重排序(Reordering)"。只要在某个线程中无法检测到重排序情况(即使在其他线程中可以很明显地看到该线程中的重排序),那么就无法确保线程中的操作将按照程序中指定的顺序来执行。
失效数据
上面NoVisibility的读线程所获得的数据称为失效数据。失效数据可能不会同时出现:一个线程获得某个变量的最新值,而获得另一个变量的失效值。失效数据会导致一些让人意想不到的错误,不精确的计算以及无限循环。
public class MutableInteger { |
如果某个线程调用了set,那么另一个正在调用get的线程可能会看到更新之后的最新值,也可能看到的是过期值(失效值)。可以加上synchronized同步使其线程安全。
最低安全性
当某个线程在没有同步的情况下读写一个变量,可能会得到失效值,但是这个值至少了之前某个线程设置的值,而不是一个随机值,这中安全性保证称为"最低安全性(out-of-thin-air-safety)"。
绝大部分变量都符合最低安全性,但是存在一个例外:非volatile类型的64位数值变量(double,long)。Java内存模型要求,变量的读取操作和写入操作都必须是原子操作,但是对于非volatile类型的64位数值变量,JVM允许将64位的读操作或写操作分解位32位的操作,那么当读写操作在不同线程执行时,可能会读取到某个值的高32位和另一个值的低32位。
因此,即使不考虑失效数据问题,在多线程程序中使用共享且可变的long和double等类型变量都是不安全的,除非使用volatile声明或加锁。
加锁和可见性
内置锁可以确保某个线程以一种可预测的方式来查看另一个线程的结果。
当线程B执行由锁保护的同步代码块时,可以看到线程A之前在同一个同步代码块中的所有操作结果。
加锁的含义步仅仅局限于互斥行为,还包括内存可见性。
volatile
Java 提供了一种稍弱的同步机制,即volatile变量,用来确保将变量的更新操作通知到其他线程,即保证了新值能够立即同步回主存,以及每次使用前立即从主存刷新,总是返回最新写入的值。当把变量声明为volatile类型时,编译器和运行时都会注意到这个变量是共享的,因此不会将该变量上的操作与其他内存操作一起重排序,volatile变量也不会被缓存在寄存器或者对处理器不可见的地方。
volatile的特性主要是通过内存屏障和禁止重排序优化来实现。
- 内存屏障:对volatile变量写操作时,会在写操作之后加入一条store屏障指令,将工作内存中的共享变量同步回主存中;对volatile变量读操作时,会在读操作之前加入一条load屏障指令,将主存中最新的值刷新到工作内存中;
- 禁止重排序优化。
访问volatile变量不会执行加锁操作,因此也就不会执行线程阻塞,所以说volatile是一种比synchronized关键字更加轻量级的同步机制。
仅当volatile变量能简化代码的实现以及对同步策略的验证时,才应该使用它。volatile正确的使用方式包括:确保它们自身状态的可见性,确保它们所引用对象的状态的可见性,以及标识一些重要的程序生命周期事件的发生。
volatile常用的一个场景作为状态标识量,可以用来作为某个操作完成,发生中断或者状态的标志:
volatile boolean inited=false; |
单独使用volatile并不能保证对共享变量操作的线程安全。例如volatile的语义并不能保证递增操作(Count++)的线程安全性(原子性)。
当且仅当满足以下条件时,才应该使用volatile变量:
- 对变量的写入操作不依赖变量的当前值,或者保证只有单个线程更新变量的值;
- 该变量不会与其他状态变量一起纳入不变性条件中;
- 在访问变量时不需要加锁。
加锁和volatile
加锁机制即可以确保可见性又可以保证原子性,而volatile变量只能保证可见性。
其他可见性方案
-
synchronized
synchronized的可见性是由"对一个变量执行unlock操作之前,必须先把此变量同步回主存中(执行store,write)"这条规则得到的。可以详细解释为:- 线程解锁时,必须把共享变量的最新值刷新到主存;
- 线程加锁时,将清空工作内存中共享变量的值,从而使用共享变量时需要从主内存中得到最新的值。
-
final
final关键字的可见性是指: 被final修饰的字段在构造器中一旦被初始化完成,并且构造器没有把"this" 的引用传递出去,那么在其他线程中就能看到final字段的值。
线程封闭
当访问共享的可变数组时,通常需要使用同步。一种避免使用同步的方式就是不共享数据,如果仅在单线程内访问数据,就不需要同步,这种技术就称为线程封闭。当某个对象封闭在一个线程中时,这种用法将自动实现线程安全性,即使被封闭的对象本身不是线程安全的。
Java 语言中并没有强制规定某个变量必须由锁来保护,也无法强制将对象封闭在线程中。线程封闭时程序设计的一个考虑因素,必须在程序中实现。
Ad-hoc线程封闭
Ad-hoc线程封闭是指,维护线程封闭性的职责完全由程序来承担。此种线程封闭时不可靠且脆弱的,因为没有任何一种语言特性能将对象封闭到目标线程上。
栈封闭
在栈封闭中,只能通过局部变量才能访问对象。局部变量的固有属性之一就是封闭在执行线程中,它们位于执行线程的栈中,其他线程无法访问这个栈。栈封闭比Ad-hoc线程封闭更易于维护,也更加健壮。
对于基本类型的局部变量,任何方法都无法获得对于基本类型的引用,因此确保了基本类型的局部变量始终封闭在线程内。
在维持对象引用的栈封闭性时,需要多做一些工作以确保被引用的对象不会逸出。
如果在线程内部(Within-Thread)上下文使用非线程安全的对象,那么该对象仍然是线程安全的。
ThreadLocal线程封闭
ThreadLocal 类提供了get和set方法,这些方法为每个使用该变量的线程都存有一份独立的副本。ThreadLocal通常用来防止对可变的单实例变量(Singleton)或全局变量进行共享。
private static ThreadLocal<Connection> connectioHolder |
可以将TheadLocal
对象发布和逸出
对象发布
发布对象:使一个对象能够被当前作用域之外的代码所使用。
例如:将一个指向该对象的引用保存在其他代码可以访问的地方;在某一个非私有的方法返回该对象的引用;将引用传递到其他类的方法中。
-
将一个指向该对象的引用保存在其他代码可以访问的地方
public static Set<Person> knowPerson;
public void initalize () {
knowPerson = new HashSet<Person>();
}当发布某个对象时,可能会间接发布其他对象。比如若Set集合中保存了若干Person 对象,那么发布了KnowPerson之后,里面的Person对象也会被发布出去。
-
在某个非私有的方法返回该对象的引用
public class publish {
// 定义一个字符串对象数组
public String[] strings = {"HB", "QQL", "HY"};
// 通过一个公有方法发布它, 使得当前范围之外代码所使用
public String[] getStrings () {
return strings;
}
public static void main(String[] args) {
Publish publish = new Publish();
log.info("Strings: {}", Arrays.toString(publish.getStrings()));
}
} -
将引用传递到其他类的方法中
public class publish {
class Person {
private String name;
private int age;
}
public static void main (String[] args) {
OtherClass otherClass = new OtherClass();
otherClass.doSomething(new Person());
}
}在上述代码中,发布了Person对象。通常接受被发布对象的方法称为外部方法(Alien),即定义一个类C,对于C来说,外部方法是指行为并不完全由C来规定的方法,包括其它类中定义的方法以及类C中可以被改写的方法。当把一个对象传递给一个外部方法时,就相当于发布了这个对象。
另外当发布一个对象时,在该对象的非私有域中引用的所有对象同样会被发布。总结来说,一个已经发布的对象能够通过非私有的变量引用或方法调用到达其他的对象,那么这些对象也会被间接发布。
对象逸出
对象逸出:一种错误的发布,某个不应该发布的对象被发布。
当一个对象逸出后,其他类或线程可能会误用该对象。
几种常见的对象逸出可看下述代码示例:
-
内部状态的逸出
public class Escape {
// 定义一个私有的字符串对象数组
private String[] strings = {"HB", "QQL", "HY"};
// 通过一个公有方法发布它, 使得当前范围之外代码所使用
public String[] getStrings () {
return strings;
}
public static void main(String[] args) {
UnsafePublish unsafePublish = new UnsafePublish();
log.info("Stings: {}", Arrays.toString(unsafePublish.getStrings()));
unsafePublish.getStrings()[0] = "CCZ";
// 不安全, 其他线程会够对私有对象进行修改
log.info("Stings: {}", Arrays.toString(unsafePublish.getStrings()));
}
}字符串数组作为私有变量但是被发布了,逸出了它所在的作用域。在发布一个对象时,要确保对象的内部状态不被发布,可能会破坏封装性。
-
this引用在构造函数逸出
public class ThisEscape {
public ThisEscape (EventSource source) {
source.registerListener (
// 匿名内部类, 持有指向外部类对象的引用
new EventListener() {
public void onEvent (Event e) {
doSomething(e);
}
}
);
}
}上述发布隐式地使this引用逸出,当ThisEscape发布了EventListener时,也隐含了发布了ThisEscape本身,并且发布的ThisEscape时一个尚未构造完成的对象,会造成线程安全性问题。当且仅当对象的构造函数返回时,对象才处于可预测的和一致的状态。
通常一下几种操作会造成this应用逸出:- 在构造函数中启动一个线程。当对象在其构造函数中创建一个线程时,无论时显示创建(通过将它传给构造函数)还是隐式创建(由于Thread或Runnable是该对象的一个内部类),this引用都会被新创建的线程共享。在对象尚未构造完成之前,新的线程就可以看见它。
在构造函数中创建线程并没有错误,但最好不要立即启动它,而是通过一个start或initalize方法来启动; - 在构造函数中调用一个可改写的示例方法时(既不是私有方法,也不是final方法),同样会导致this应用在构造过程中逸出。
public class ThisEscape {
private int thisCanBeEscape = 89;
public ThisEscape () throws InterruptedException {
// a. 创建一个线程
Thread thread = new Thread(() ->{
log.info("Escape caused by create thread {}", ThisEscape.this.thisCanBeEscape);
});
thread.run();
// b. 调用一个实例方法
doSomething();
}
public void doSomething() {
log.info("{Escape caused by call method}", UnsafeEscape.this.thisCanBeEscape);
}
public static void main(String[] args) throws InterruptedException {
ThisEscape thisEscape = new ThisEscape ();
}
}可以通过使用一个私有的构造函数和一个公共的工厂方法来避免不正确的构造过程:
public class SafeListener {
private final EventListener listener;
private SafeListener () {
listener = new EventListener() {
public void onEvent(Event e) {
doSomething(e);
}
}
}
public static SafeListener newInstance (EventSource source) {
SafeListener safe = new SafeListener(); // 构造函数已完成
source.registerListener(safe.listener);
return safe;
}
} - 在构造函数中启动一个线程。当对象在其构造函数中创建一个线程时,无论时显示创建(通过将它传给构造函数)还是隐式创建(由于Thread或Runnable是该对象的一个内部类),this引用都会被新创建的线程共享。在对象尚未构造完成之前,新的线程就可以看见它。
对象分类
不可变对象
如果某个对象在被创建后其状态就不能被修改,称这个对象为不可变对象。线程安全性是不可变对象的固有属性之一(不可变对象一定是线程安全的,可以安全地发布和共享),它们的不变性条件是由构造函数创建的(不可变对象只有一种状态,并且该状态由构造函数来控制),只要它们的状态不改变,那么这些不变性条件就能得以维持。
当满足以下条件时,对象才是不可变的:
- 对象创建以后其状态就不能修改;
- 对象的所有域都是final类型;
- 对象是正确创建的(在对象的创建期间,this引用没有逸出)。
对于不可变对象,不得不提的就为final关键字,它用来构造不可变对象,final域能确保初始化过程的安全性,从而可以不受限制地访问不可变对象,并在共享这些对象时无需同步。
但是要知道的是,不可变性并不等于将对象中所有的域声明为final类型就可以,就算声明为final,这个对象仍是可变的,因为final类型的域中可以保存可变对象的引用。
除了final,下面这些容器也可以创建不可变对象,Collections.unmodifiableXXX:Collection,List,Set,Map… 以及 Guava:ImmutableXXX:Collection,List,Set,Map…
事实不可变对象
如果对象从技术上来看是可变的,但其状态在发布后不会再改变,那么称这种对象为事实不可变对象(Effectively Immutable Object)。
在没有额外的同步的情况下,任何线程都可以安全地使用被安全发布的事实不可变对象,所以对于事实不可变对象,安全发布就足够了。
可变对象
对于可变对象,不仅在发布对象时需要使用同步(因为安全发布只能确保"发布当时"状态的可见性),而且在每次访问时同样需要使用同步来确保后续修改的操作的可见性。
安全发布对象
所有的安全发布机制都能确保,当对象的引用对所有访问该对象的线程可见时,对象发布时的状态对于所有线程也将是可见的。
任何线程都可以在不需要额外同步的情况下安全地访问不可变对象,即使在发布这些对象时没有同步。
可变对象必须通过安全的方式来发布,这通常意味着在发布和使用该对象的线程都必须使用同步。要安全地发布一个对象,对象的引用以及对象的状态必须同时对其他线程可见。
下面是一个不安全的发布例子:
// 不安全的发布 |
由于存在可见性问题,其他线程看到的Holder对象可能处于不一致状态,这种不正确的发布导致其他线程看到尚未创建完成的对象。在未被正确发布的对象中存在两个问题:
- 除了发布对象的线程外,其他线程可能看到的Holder域是一个失效值,可能是一个空引用或者一个之前的旧值;
- 线程看到Holder 引用的值是最新的,但Holder状态的值确实失效的。
一个正确构造的对象可以通过以下方式来安全地发布:
- 在静态初始化函数中初始化一个对象引用;
- 将对象的引用保存到volatile类型的域中或者AtomicReferance对象中;
- 将对象的引用保存在某个正确构造的对象的final类型域中;
- 将对象的引用保存到一个由锁保护的域中。
其中需要对第一项进行以下解释,静态初始化器(例如静态代码块)由JVM在类的初始化阶段执行,由于在JVM的内部存在着同步机制,因此通过这种方式初始化的任何对象都是可以被安全的发布。
在线程安全容器内部的同步意味着,在将某个对象放入到某个容器,将满足上述最后一条请求。线程安全库中的容器类提供了以下的安全发布保证:
- 通过将一个键或者值放入Hashtable,synchronizedMap或ConcurrentMap中,可以安全地将它发布给任何访问它的线程(无论是直接访问还是迭代器访问);
- 通过将某个元素放入Vector,CopyOnWriteArrayList,CopyOnWriteSet,synchronizedList,synchronizedSet中,可以安全地将它安全地发布任何从这些容器中访问该元素的线程;
- 通过将某个元素放入BlockingQueue,ConcurrentLinkedQueue,可以安全地将它安全地发布任何从这些容器中访问该元素的线程。
如果对象在构造后可以修改,那么安全发布只能确保"发布当时"状态的可见性。对于可变对象,不仅在发布对象时需要使用同步,而且在每次对象访问时同样需要使用同步来确保后续修改操作的可见性。要安全的共享可变对象,这些对象就必须被安全地发布,并且必须时线程安全的或者由某个锁保护起来。
综上,对象的发布需求取决于它的可变性,可以概括为:
- 不可变对象可以通过任何机制来发布;
- 事实不可变对象必须通过安全方式来发布;
- 可变对象必须通过安全方式来发布,并且必须是线程安全的或者由某个锁保护起来。
总结
在并发程序中使用和共享对象时,可以使用一下一些使用的策略:
- 线程封闭:线程封闭的对象只能由一个线程拥有,对象被封闭在该线程中,并且只能由这个线程修改,常见的线程封闭为使用本地变量;
- 只读共享:在没有额外同步的情况下,共享的只读对象可以由多个线程并发访问,但任何线程都不能修改它。共享的只读对象包括不可变对象和事实不可变对象;
- 线程安全共享:线程安全共享对象在其内部实现同步,因此多个线程可以通过对象的公有接口来进行访问而不需要进一步同步。
- 保护对象:被保护的对象只能通过持有特定的锁来访问。保护的对象包括封装在其他线程安全对象中的对象,以及已发布的并且由某个特定的锁保护的对象。
扩展
- 先检查再执行:if(condition(a)) {handle(a)}; 即使对象a和b都是线程安全对象,但是这种先检查再执行的操作是线程不安全的,因为这种操作不属于原子性操作。
- 遍历(迭代器遍历,foreach遍历,因为foreach是一种实际使用迭代器实现的语法糖)Vertor,ArrayList等,不能同时进行添加和删除操作。
具体解析见:https://www.cnblogs.com/kobelieve/p/10626473.html - Java 非同步容器:HashMap,HashSet,ArrayList,StringBuilder等。
- Java同步容器:Vector,Stack,HashTable(Key,Value不能同时为空),Collections.synchronizedXXX(List,Set,Map)。
参考资料
[1] 《Java编程实战》