Java并发编程之并发代码设计
引子
之前的文章我们探讨了引发线程安全的原因主要是由于多线程的对共享内存的操作导致的可见性或有序性被破坏,从而导致内存一致性的错误。
那么如何设计并发代码解决这个问题呐?
我们一般使用这几种方式:
- 线程封闭
- 不可变对象
- 同步
发布和逸出
在此之前 我们先来了解一下发布和逸出的概念。
发布是指让对象在当前作用域之外使用,例如将对象的引用传递到其他类的方法,在一个方法中返回其引用等。
在许多情况下我们要保证内部对象不被发布,发布一些内部状态可能会破坏封装性,让使用者可以随意改变其状态,从而破坏线程安全。
而在某些情况下,我们又需要发布某些内部对象,如果需要线程安全的情况下,则需要正确的同步。
当一个对象在不应该被发布的时候发布了,这种情况就叫逸出。
public class Escape { private List<User> users = Lists.newArrayList(); public List<User> getUsers() { return users; } public void setUsers(List<User> users) { this.users = users; } }
getUsers已经逸出了它的作用域,这个私有变量被发布了,因为任何调用者都可能修改数组。
同时发布users的时候也间接发布了User对象的引用。
public class OuterEscape { private String str = "Outer's string"; public class Inner { public void write() { System.out.println(OuterEscape.this.str); } } public static void main(String[] args) { OuterEscape out = new OuterEscape(); OuterEscape.Inner in = out.new Inner(); in.write(); } }
在内部类中保存了一个指向创建该内部类的外围类的引用,所以内部类中可以使用创建该内部类的外围类的私有属性、方法。
public class ConstructorEscape { private Thread t; public ConstructorEscape() { System.out.println(this); t = new Thread() { public void run() { System.out.println(ConstructorEscape.this); } }; t.start(); } public static void main(String[] args) { ConstructorEscape a = new ConstructorEscape(); } }
this引用被线程t共享,故线程t的发布将导致ConstructorEscape对象的发布,由于ConstructorEscape对象被发布时还未构造完成,这将导致ConstructorEscape对象逸出
总结一下如何安全发布的步骤
- 找出构成对象状态的所有变量
- 找出约束状态变量的不变性条件
- 建立对象状态的并发访问策略
线程封闭
线程封闭的思想很简单,既然线程安全问题是由于多线程对共享变量的访问造成的,那么如果我们可以避免操作共享变量,每个线程访问自己的变量,就不会有线程安全的问题,这是实现线程安全最简单的方法。
通过线程控制逃逸规则可以帮助你判断代码中对某些资源的访问是否是线程安全的,如果一个资源的创建,使用,销毁都在同一个线程内完成,且永远不会脱离该线程的控制,则该资源的使用就是线程安全的。
资源可以是对象,数组,文件,数据库连接,套接字等等。Java中你无需主动销毁对象,所以“销毁”指不再有引用指向对象。即使对象本身线程安全,但如果该对象中包含其他资源(文件,数据库连接),整个应用也许就不再是线程安全的了。比如2个线程都创建了各自的数据库连接,每个连接自身是线程安全的,但它们所连接到的同一个数据库也许不是线程安全的
我们再来看线程封闭的几种实现方式:
栈封闭
栈封闭是线程封闭的一个特例,在栈封闭中只能通过局部变量来访问对象,局部变量存储在线程自己的栈中。也就是说,局部变量永远也不会被多个线程共享。所以,基础类型的局部变量是线程安全的。
对象的局部引用和基础类型的局部变量不太一样。尽管引用本身没有被共享,但引用所指的对象并没有存储在线程的栈内。所有的对象都存在共享堆中。如果在某个方法中创建的对象不会逸出该方法,那么它就是线程安全的。实际上,哪怕将这个对象作为参数传给其它方法,只要别的线程获取不到这个对象,那它仍是线程安全的。
public void someMethod() { LocalObject localObject = new LocalObject(); localObject.callMethod(); method2(localObject); } public void method2(LocalObject localObject) { localObject.setValue("value"); }
如上,LocalObject对象没有被方法返回,也没有被传递给someMethod()方法外的对象。每个执行someMethod()的线程都会创建自己的LocalObject对象,并赋值给localObject引用。因此,这里的LocalObject是线程安全的。事实上,整个someMethod()都是线程安全的。即使将LocalObject作为参数传给同一个类的其它方法或其它类的方法时,它仍然是线程安全的。当然,如果LocalObject通过某些方法被传给了别的线程,那它就不再是线程安全的了
程序控制线程封闭
通过程序实现来进行线程封闭,也就是说我们无法利用语言特性将对象封闭到特定的线程上,这一点导致这种方式显得不那么可靠假设我们保证只有一个线程可以对某个共享的对象进行写入操作,那么这个对象的"读取-修改-写入"在任何情况下都不会出现竟态条件。如果我们为这个对象加上volatile修饰则可以保证该对象的可见性,任何线程都可以读取该对象,但只有一个线程可以对其进行写入。这样,仅仅通过volatile修饰就适当地保证了其安全性,相比直接使用synchoronized修饰,虽然更适合,但实现起来稍微复杂。
程序控制线程封闭,这个不是一种具体的技术,而是一种设计思路,从设计上把处理一个对象状态的代码都放到一个线程中去,从而避免线程安全的问题。
ThreadLocal
ThreadLocal机制本质上是程序控制线程封闭,只不过是Java本身帮忙处理了 。来看Java的Thread类和ThreadLocal类:
- Thread线程类维护了一个ThreadLocalMap的实例变量
- ThreadLocalMap就是一个Map结构
- ThreadLocal的set方法取到当前线程,拿到当前线程的threadLocalMap对象,然后把ThreadLocal对象作为key,把要放入的值作为value,放到Map
- ThreadLocal的get方法取到当前线程,拿到当前线程的threadLocalMap对象,然后把ThreadLocal对象作为key,拿到对应的value
public class Thread implements Runnable { ThreadLocal.ThreadLocalMap threadLocals = null; } public class ThreadLocal<T> { public T get() { Thread t = Thread.currentThread(); ThreadLocalMap map = getMap(t); if (map != null) { ThreadLocalMap.Entry e = map.getEntry(this); if (e != null) return (T)e.value; } return setInitialValue(); } ThreadLocalMap getMap(Thread t) { return t.threadLocals; } public void set(T value) { Thread t = Thread.currentThread(); ThreadLocalMap map = getMap(t); if (map != null) map.set(this, value); else createMap(t, value); } }
ThreadLocal的设计很简单,就是给线程对象设置了一个内部的Map,可以放置一些数据。JVM从底层保证了Thread对象之间不会看到对方的数据。
使用ThreadLocal前提是给每个ThreadLocal保存一个单独的对象,这个对象不能是在多个ThreadLocal共享的,否则这个对象也是线程不安全的
ThreadLocal 内存泄漏
ThreadLocalMap使用ThreadLocal的弱引用作为key,如果一个ThreadLocal没有外部强引用来引用它,那么系统 GC 的时候,这个ThreadLocal势必会被回收,这样一来,ThreadLocalMap中就会出现key为null的Entry,就没有办法访问这些key为null的Entry的value,如果当前线程再迟迟不结束的话,这些key为null的Entry的value就会一直存在一条强引用链:Thread Ref -> Thread -> ThreaLocalMap -> Entry -> value永远无法回收,造成内存泄漏。
其实,ThreadLocalMap的设计中已经考虑到这种情况,也加上了一些防护措施:在ThreadLocal的get(),set(),remove()的时候都会清除线程ThreadLocalMap里所有key为null的value。
所以每次使用完ThreadLocal,都调用它的remove()方法,清除数据就可以避免这个问题
不可变对象
一个对象如果在创建后不能被修改,那么就称为不可变对象。在并发编程中,一种被普遍认可的原则就是:尽可能的使用不可变对象来创建简单、可靠的代码
在并发编程中,不可变对象特别有用。由于创建后不能被修改,所以不会出现操作共享变量导致的内存一致性错误
但是程序员们通常并不热衷于使用不可变对象,因为他们担心每次创建新对象的开销。实际上这种开销常常被过分高估,而且使用不可变对象所带来的一些效率提升也抵消了这种开销
我们先来看一个使用同步来解决线程安全的例子
public class SynchronizedRGB { // Values must be between 0 and 255. private int red; private int green; private int blue; private String name; private void check(int red, int green, int blue) { if (red < 0 || red > 255 || green < 0 || green > 255 || blue < 0 || blue > 255) { throw new IllegalArgumentException(); } } public SynchronizedRGB(int red, int green, int blue, String name) { check(red, green, blue); this.red = red; this.green = green; this.blue = blue; this.name = name; } public void set(int red, int green, int blue, String name) { check(red, green, blue); synchronized (this) { this.red = red; this.green = green; this.blue = blue; this.name = name; } } public synchronized int getRGB() { return ((red << 16) | (green << 8) | blue); } public synchronized String getName() { return name; } }
SynchronizedRGB color = new SynchronizedRGB(0, 0, 0, "Pitch Black"); ... int myColorInt = color.getRGB(); // 1 String myColorName = color.getName(); // 2 //如果其他线程在1执行后调用set方法 就会导致 getName 跟getRGB的值不匹配 synchronized (color) { int myColorInt = color.getRGB(); String myColorName = color.getName(); } //必需使这2个语句同步执行
创建不可变对象的几条原则
- 不提供修改可变对象的方法。(包括修改字段的方法和修改字段引用对象的方法)
- 将类的所有字段定义为final、private的。
- 不允许子类重写方法。简单的办法是将类声明为final,更好的方法是将构造函数声明为私有的,通过工厂方法创建对象。
- 如果类的字段是对可变对象的引用,不允许修改被引用对象。
- 不共享可变对象的引用。当一个引用被当做参数传递给构造函数,而这个引用指向的是一个外部的可变对象时,一定不要保存这个引用。如果必须要保存,那么创建可变对象的拷贝,然后保存拷贝对象的引用。同样如果需要返回内部的可变对象时,不要返回可变对象本身,而是返回其拷贝
修改后的例子
final public class ImmutableRGB { // Values must be between 0 and 255. final private int red; final private int green; final private int blue; final private String name; private void check(int red, int green, int blue) { if (red < 0 || red > 255 || green < 0 || green > 255 || blue < 0 || blue > 255) { throw new IllegalArgumentException(); } } public ImmutableRGB(int red, int green, int blue, String name) { check(red, green, blue); this.red = red; this.green = green; this.blue = blue; this.name = name; } public int getRGB() { return ((red << 16) | (green << 8) | blue); } public String getName() { return name; } }
事实不可变对象
如果对象本事是可变的,但是程序运行过程中,不存在改变的可能,那么就称为事实不可变对象,这样也不需要额外的线程安全的保护
同步
当我们不得不使用共享变量,而且需要经常修改的时候我们就需要使用同步来实现线程安全了。
Java我们可以使用 Synchronized/Lock volatite CAS 来实现同步。
synchronized是一种独占锁,它假设最坏的情况,并且只有在确保其它线程不会造成干扰的情况下执行,会导致其它所有需要锁的线程挂起,等待持有锁的线程释放锁。
与锁相比,volatile变量是一和更轻量级的同步机制,因为在使用这些变量时不会发生上下文切换和线程调度等操作,但是volatile变量也存在一些局限:不能用于构建原子的复合操作,因此当一个变量依赖旧值时就不能使用volatile变量。
CAS是一种乐观锁,每次不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止。
同步解决了三个相互关联的问题:
- 原子性:哪些指令必须是不可分割的
- 可见性:一个线程执行的结果对另一个线程是可见的
- 有序性:某个线程的操作结果对其它线程来看是无序的
总结
理解线程安全的概念很重要, 所谓线程安全问题,就是处理对象状态的问题 。如果要处理的对象是无状态的(不变性),或者可以避免多个线程共享的(线程封闭),那么我们可以放心,这个对象可能是线程安全的。当无法避免,必须要共享这个对象状态给多线程访问时,这时候才用到线程同步的一系列技术。
这个理解放大到架构层面,我们来设计业务层代码时,业务层最好做到无状态,这样就业务层就具备了可伸缩性,可以通过横向扩展平滑应对高并发。
所以我们处理线程安全可以有几个层次:
- 能否做成无状态的不变对象。无状态是最安全的。
- 能否线程封闭
- 采用何种同步技术 (Synchronized/Lock volatite CAS)