[Java多线程编程之九] 线程安全 介绍了并发编程中常见的问题包括原子性、可见性,以及如何解决问题的方法包括锁机制(sychronized、Lock)、volatile和JUC包中提供的原子类等,并阐述了线程安全的各种概念。在 [Java多线程编程之十] 深入理解Java的锁机制 中针对锁机制进行了详细的介绍。
并发编程涉及多线程的共享变量的操作,而变量在使用前需要先发布,本文将介绍对象共享发布的细节,以及其他能保证线程安全的手段。
一、发布与逸出
1、发布
发布一个对象指是对象能够在当前的作用域之外的代码中使用
发布对象最简单的方式是将对象的引用保存在一个公有的静态变量中,eg:
public Object obj;
其他任何能让调用代码获取到对象引用的方式,都算是发布了对象,eg:
private Object obj;
public Object getObj() {
return obj;
}
但是发布一个对象不意味着要发布对象所有的成员,比如一些表示对象内部状态的成员有时我们不想发布就会将其声明为私有,并不对外提供访问和修改该成员的方法,这就是面向对象中的封装。刚接触面向对象编程时,老师总告诉我们要将对象的成员属性声明为私有变量,并对外提供getter和setter,这样有利于类提供者更好地实现类成员的操控,并限定了代码访问路径。
public class Person {
private int age;
private String name;
public Person(int age, String name) {
this.age = age;
this.name = name;
}
public int getAge() {
return age;
}
public void setAge(int age) {
this.age = age;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
}
如上面的类,要访问Person
的age属性,只能通过getAge()
方法,封装限定了代码路径能为一些新增的需求提供非侵入式的支持,比如现在要保证访问age
属性的线程安全,则可以将getAge()
方法改写为:
public synchronized int getAge() {
return age;
}
public synchronized void setAge(int age) {
this.age = age;
}
但是调用代码person.getAge()
不需要修改,如果没有封装,调用者代码为person.age
,实现同步的需求需要调用代码显式加锁,如:
sychronized (person) {
person.age;
}
2、逸出
逸出:如果某个对象或对象成员不想被发布,但是被发布时,就称为逸出。
逸出会产生潜在的问题和隐患,因为你不知道调用者代码会如何使用逸出的对象,eg:
public class UnsafeStates {
private String[] states = new String[] {"AK", "AL", "AM"};
public String[] getStates() {
return states;
}
@Override
public String toString() {
StringBuffer sb = new StringBuffer();
for (String state : states) {
sb.append(state).append(" ");
}
return sb.toString();
}
}
调用代码如下:
UnsafeStates unsafeStates = new UnsafeStates();
System.out.println(unsafeStates.toString());
unsafeStates.getStates()[0] = "AA";
System.out.println(unsafeStates.toString());
执行结果如下:
AK AL AM
AA AL AM
UnsafeStates
中的states
属性被声明为private
,本意是为了防止被调用代码修改,但是getStates()
方法返回的是对象引用,导致states逸出了类的作用域,本来应是私有的变量被发布了。
当发布一个对象时,在该对象的非私有域中引用的所有对象同样会被发布,这也会导致逸出。
3、安全发布
发布对象时,如果在对象构造完成之前对象引用就显式或隐式被外界获取,那么使用该引用操作对象时,可能看到的对象状态是不可预测的。
public class ThisEscape {
// Other Field And Method
public ThisEscape(EventSource source) {
source.registerListener(
new EventListener() {
public void onEvent(Event e) {
doSomething(e);
}
});
}
public void doSomething(Event e) {
// xxx
}
}
如上面的代码,在构造函数中,创建的EventListener匿名实现类的 onEvent
方法中,隐式地调用到了当前的 ThisEscape
对象,即 doSomething(e) 等同于 this.doSomething(e)
, 但是此时该对象还未构造完成,如果 doSomething
中操作了对象的其他属性,那么由于构造函数尚未完成,执行 doSomething
时看到的属性值可能不是一个未初始化的失效值或非法值。
因此:
不要在构造过程中使this引用逸出
上面的代码在构造函数中注册一个事件监听器或启动线程会导致逸出,可以使用一个私有构造方法和一个公共的工厂方法来实现正确的构造过程和安全发布,代码如下:
public class SafeListener {
private 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;
}
}
如果构造函数中掉用了一个可改写的实例方法(非private final),由于可以被子类重写,同样会导致this引用在构造过程中逸出,因为为了防止逸出,应该将实例方法加上private或final声明。
二、线程封闭
线程安全问题的根源是多个线程共享了可变变量,如果线程间不共享数据,变量只被单个线程使用,则不需要同步,这就是线程封闭,线程封闭有几种实现手段:Ad-hoc线程封闭、栈封闭、使用ThreadLocal。
1、Ad-hoc线程封闭
Ad-hoc线程封闭是指维护线程封闭性的职责完全由程序实现来承担。但是没有什么语言特性做保障,使得它很脆弱,因为随时可能因为代码的误用或者接手的程序员对原来的设计不清楚导致封闭被打破。
2、栈封闭
每个线程在执行方法时,JVM都会为其创建一个线程私有的方法栈,在方法栈中定义的局部变量只要通过方法return发布出去,则这些局部变量的生命周期局限在方法内部,不会被其他线程共享,从而保证线程安全。
3、ThreadLocal
ThreadLocal是一个特殊的类,会为每个使用该类型变量的线程都保存一份独立的副本。
public class ThreadLocalTest {
public static void main(String[] args) throws InterruptedException {
ThreadLocal<String> threadLocal = new ThreadLocal<String>();
Thread thread = new Thread() {
@Override
public void run() {
threadLocal.set("haha");
System.out.println(threadLocal.get());
}
};
thread.start();
Thread.sleep(1000);
System.out.println(threadLocal.get());
}
}
测试程序中,主线程休眠1秒等待thread执行完再获取,如果ThreadLocal保存的变量是可以共享的,那主线程的输出应该是haha,但实际执行输出是null,因为主线程没有调用过set方法设置值。
haha
null
ThreadLocal特别适合保存线程上下文信息的场景,但是要注意防止滥用,而且使用ThreadLocal也会引入耦合。
三、不变性
线程封闭是从禁止多线程共享变量的角度实现线程安全,如果被线程共享的数据是不变的,那么不需要额外的同步,也能保持线程安全。
假如是一个简单基本类型被声明了final,如果想尝试修改它,IDE会提示编译错误
如果一个引用类型变量(即对象)被声明为final,只代表引用指向的对象内存地址不可修改,实际上对象成员只要不是final,或者没有用private封装起来,实际上成员还是可变的。
public class TestFinal {
public static void main(String[] args) {
final NonfinalObject obj = new NonfinalObject();
System.out.println(obj.num);
obj.num = 2;
System.out.println(obj.num);
}
}
class NonfinalObject {
public int num = 1;
}
执行结果:
1
2
要满足线程同步需求,我们不仅希望对象引用不可被修改,还希望不可变对象的成员无法被修改,可以将对象的所有域声明为final,也可以将将域封装起来(比如声明为private,不提供对外的setter并且避免发布),如下所示:
class NonfinalObject {
private int num = 1;
public int getNum() {
return num;
}
}
同时对象还应该被正确地创建,避免逸出(这其实是所有对象的要求,特别是多线程中使用的对象,安全的构造可避免难以预料的错误),满足这些需求的对象就可以被称为不可变对象。
总结一下,满足下面条件的对象是不可变的:
- 对象创建以后其状态不能修改。
- 对象的所有域都是无法被外界修改的,手段有final和封装。
- 对象是正确创建的(在对象的创建期间,this引用没有逸出,被正确构造)
不可变对象一定是线程安全的。
1、final
被final修饰的域不可被修改,同时能确保初始化过程的安全性,从而可以不受限制地访问不可变对象,并在共享这些对象时无需同步。如果一个对象的某些域不能或者不需要被修改,则应该用final进行修饰,有利于简化状态判断和防止被修改。
“除非需要更高的可见性,否则应将所有的域声明为私有域。”
“除非需要某个域是可变的,否则应将其声明为final域。”
2、使用volatile发布不可变对象
使用volatile发布不可变对象,可以达到不加锁但是类似锁的效果,可以保证共享数据的可见性和原子性。
如下面的代码所示,OneValueCache类的每个域都是不可变域,所以该类的对象都是不可变对象,对象用作一个最近一次请求的缓存,通过getFactors
方法获取缓存,当请求数据与被缓存结果的请求值相等时,返回缓存结果,因为缓存结果lastFactors
是一个数组类型,为了防止被篡改,返回的是一个数组的拷贝对象。
public class OneValueCache {
private final BigInteger lastNumber;
private final BigInteger[] lastFactors;
public OneValueCache(BigInteger i, BigInteger[] factors) {
lastNumber = i;
lastFactors = Arrays.copyOf(factors, factors.length);
}
public BigInteger[] getFactors(BigInteger i) {
if (lastNumber == null || !lastNumber.equals(i))
return null;
else
return Arrays.copyOf(lastFactors, lastFactors.length);
}
}
调用代码示例如下:
public class VolatileCachedFactorizer {
private volatile OneValueCache cache = new OneValueCache(null, null);
public void service(BigInteger req) {
BigInteger i = extractFromRequest(req);
BigInteger[] factors = cache.getFactors(req);
if (factors == null) {
factors = factor(i);
cache = new OneValueCache(i, factors);
}
encodeIntoResonse(resp, factors);
}
}
使用volatile修饰不可变对象,是为了保证对象的可见性,当引用指向的对象变化时所有线程都能看到,不可变对象是线程安全的,所以不存在原子性和可见性问题,两者结合,可以满足对共享数据的读取和写入的线程安全,虽然没有使用锁但是达到了使用锁的效果。
四、安全发布
当共享对象需要被多个线程使用时,如果要避免多线程看到被不正确构造且状态变化存在可见性的问题,对象就应该被安全地发布。
1、初始化安全性
如果一个对象在初始化构造完成之前就被逸出了,调用代码可能会看到对象处于不一致的状态,或者失效的值,如下面的代码所示:
public Holder holder;
public void initialize() {
holder = new Holder(42);
}
在没有足够同步的情况下发布对象,应该将holder声明为private
,保证发布holder对象时,已经正确地构造对象。
如果对象还未构造完或者构造中就被发布,那么某个线程调用assertSanity()
,可能会抛出错误,如下面的代码所示:
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");
}
}
线程调用assertSanity()
方法时,会去读取两次对象成员n的值,由于此时对象尚未完成创建,所以两次读取到的值可能是不一样的,所以可能会抛出错误。
2、使用不可变对象保证初始化安全性
上面的代码,如果将Holder类中的成员n,用final
修饰,那么holder对象就变成了一个不可变对象,这时即使对象没有被安全发布,调用assertSanity()
方法时也不会抛出错误,因为:
Java内存模型为不可变对象的共享提供了一种特殊的初始化安全性保证。
使用不可变对象时,不需要对其进行同步,比如加锁,从一定程度上看,效率更高。
任何线程都可以在不需要额外同步的情况下安全地访问不可变对象,即使在发布这些对象时没有使用同步。
3、常用安全发布模式
要安全地发布一个对象,对象的引用以及对象的状态必须同时对其他线程可见。一个正确构造的对象可以通过以下方式来安全地发布:
- 在静态初始化函数中初始化一个对象引用(保证使用前已完成初始化)。
- 将对象的引用保存到volatile类型的域或者AtomicReference对象中。
- 将对象的引用保存到某个正确构造对象的final类型域中。
- 将对象的引用保存到一个由锁保护的域中。
在安全发布模式中,优先使用final
修饰不可变成员和对象,其次构造初始化可以充分利用static
带来的静态初始化加载机制保证正确构造。
4、事实不可变对象
还有一类对象,发布后虽然没有采用额外同步机制(这可以减少开销),但是发布时状态对所有线程可见,并且在实际使用中一经发布就不会被修改,称之为 “事实不可变对象”。
对事实不可变对象,不需要特别处理,因为:
在没有额外同步的情况下,任何线程都可以安全地使用被安全发布的事实不可变对象。
5、可变对象
但是在并发编程中,更多情况下使用的还是共享可变变量,要安全地共享可变对象,除了要安全地发布,还必须是线程安全的或者由某个锁保护起来。
对象的发布需求取决于它的可变性:
- 不可变对象可以通过任意机制来发布。
- 事实不可变对象必须通过安全方式来发布。
- 可变对象必须通过安全方式来发布,并且必须是线程安全的或者由某个锁保护起来。
五、并发编程共享策略
在并发编程中,数据的共享度越高,安全性保障的代价越大,根据共享度的变化,共享策略总结如下:
- 线程封闭。不共享,就没有线程安全问题。
- 只读共享。多线程并发访问,但不修改数据,可以不用额外的同步,但是需要安全地初始化,共享的只读对象包括不可变对象和事实不可变对象。
- 线程安全共享。被使用的对象的类,已经在类的内部实现了同步,多线程可以安全地调用类的方法而无需额外同步,但是如果想将对象跟其他数据绑定成一个整体,则需要额外同步。
- 保护对象。被保护的对象的类没有内置同步机制,只能通过持有特定的锁来访问。保护对象包括封装在其他线程安全对象中的对象,以及已发布的并且由某个特定锁保护的对象。