Java 同步工具与组合类的线程安全性分析( 二 )


  1. 对同一个变量 ( 反复 ) 读-改-写 。
  2. 修改受某个不变性条件约束的多个变量 。
正确地拓展同步策略在大部分情况下,我们不能通过直接修改类源码的形式补充同步策略 。比如,普通的 List<T> 接口不保证底下的各种实现是线程安全的,但我们可以通过类似代理的方式将线程安全委托给第三方 。比如:
class ThreadSafeArrayList {private final List<Integer> list;public ThreadSafeArrayList(List<Integer> l){list =l;}// 添加新的方法public synchronized boolean putIfAbsent(Integer a){if(list.contains(a)) {list.add(a);return true;}return false;}// 代理 add 方法,其它略public synchronized boolean add(Integer a) {return list.add(a);}// ...}复制代码事实上,Java 类库已经有了对应的线程安全类 。通常,我们应当优先重用这些已有的类 。在下方的代码块中,我们使用
Collection.synchronizedList 工厂方法创建一个线程安全的 list 对象,这样似乎就只需要为新拓展的 putIfAbsent() 方法加锁了 。
class ThreadUnSafeArrayList {private final List<Integer> list = Collections.synchronizedList(new ArrayList<>());// 添加新的方法public synchronized boolean putIfAbsent(Integer a){if(list.contains(a)) {list.add(a);return true;}return false;}public boolean add(Integer a){return list.add(a);}//...}复制代码但是,上述的代码是错误的 。为什么?问题在于,我们使用了错误的锁进行了同步 。当调用的是 add 方法时,使用的是列表对象的内置锁;但调用 putIfAbsent 方法时,我们使用的却是 ThreadUnsafeArrayList 对象的内置锁 。这意味着 putIfAbsent 方法对于其它的方法来说不是原子的,因此无法确保一个线程执行 putIfAbsent 方法时,其它线程是否会通过调用其它方法修改列表 。
因此,想要让这个方法正确执行,我们必须要在正确的地方上锁 。
class ThreadUnSafeArrayList {private final List<Integer> list = Collections.synchronizedList(new ArrayList<>());public boolean putIfAbsent(Integer a){synchronized (list){if(list.contains(a)) {list.add(a);return true;}return false;}}}复制代码同步容器同步容器是安全的,但在某些情况下仍然需要客户端加锁 。常见的操作如:
  1. 迭代;
  2. 跳转 ( 比如,寻找下一个元素 );
  3. 条件运算,如 "若没有则 XX 操作" ( 一种常见的复合操作 );
复合操作不受同步容器保护这里有两个线程 T1,T2 分别会以不可预测的次序执行两个代码块,它们负责删除和读取 list 中的末尾元素 。我们在这里使用的是库中的同步列表,因此可以确保 size() , remove() , get() 方法全部是原子的 。但是,当程序以 x1 , y1 , x2 , y2 的操作次序执行时,主程序最终仍然会抛出 IndexOutOfBoundsException 异常 。
class DemoOfConcurrentFail {public final List<Integer> list = Collections.synchronizedList(new ArrayList<>());{Collections.addAll(list, 1, 2, 3, 4, 5);}public static void main(String[] args) {var testList = new DemoOfConcurrentFail().list;Runnable t1 = () -> {var last = testList.size() - 1;// x1testList.remove(last);// x2};Runnable t2 = () -> {var last = testList.size() -1;// y1varr = testList.get(last);// y2System.out.println(r);};new Thread(t1).start();new Thread(t2).start();}}复制代码究其原因,两个线程 T1,T2 执行的复合操作没有受锁保护 ( 实际上就是前文银行转账的例子中犯过的错误 ) 。所以正确的做法是对复合操作整体加锁 。比如:
var mutex = new Object();Runnable t1 = () -> {synchronized (mutex){var last = testList.size() - 1;// x1testList.remove(last);// x2}};Runnable t2 = () -> {synchronized (mutex){var last = testList.size() -1;// y1varr = testList.get(last);// y2System.out.println(r);}};// ...复制代码同步容器的迭代问题在迭代操作中,类似的问题也仍然存在 。无论是直接的 for 循环还是 for-each 循环,对容器的遍历方式是使用 Iterator 。而使用迭代器本身也是先判断 ( hasNext ) 再读取 ( next ) 的复合过程 。Java 对同步容器的迭代处理是:假设某一个线程在迭代的过程中发现容器被修改过了,则立刻失败 ( 也称及时失败 ),并抛出一个
ConcurrentModificationException 异常 。
// 可能需要运行多次才能抛出 ConcurrentModificationExceptionRunnable t1 = () -> {// 删除中间的元素int mid =testList.size() / 2;testList.remove(mid);};Runnable t2 = () -> {for(var item : testList){System.out.println(item);}};new Thread(t1).start();new Thread(t2).start();复制代码


推荐阅读