前言
虽然客户端开发接触Java并发没有server那么多,但是基本的同步类以及实现原理还是要有所了解。不然遇到最基础的ConcurrentModificationException都可能不知道怎么处理。这些是最近总结的一些同步知识。
synchronized关键字
我们都知道synchronized关键字对同一个线程具备可重入性,可以修饰block和method,并且都可以作用在类和对象上,但是修饰block和method的时候具体区别在哪儿呢?以下面一段代码为例我们来看下其字节码就知道了。
1 | import java.util.List; |
通过命令行:javac Test.java
然后javap -verbose Test
可以看到其对应的字节码:
1 | // 省略了很多不重要的信息,加了一点点注释 |
可以看出 synchronized 修饰代码块的时候会在执行到代码块的前将操作数栈的引用指向锁对象,对于对象锁就是该对象,对于类锁就是这个类名的常量(类锁和对象锁之间不会发生竞争),然后插入一个monitorenter指令;当执行完block的时候会多出2个monitorexit指令,一个是正常执行的路径,另一个是block发生异常的路径,保证无论是否有异常都会执行monitorexit。monitorenter会尝试获得锁,将锁计数加1,monitorexit将释放锁,锁计数减1,锁计数为0则释放锁。
另一方面,synchronized修饰方法时只是给方法的flags加了个ACC_SYNCHRONIZED标志位,由JVM根据这个标志位进行加锁。
atomic
Java.util.concurrent.atomic包提供了一些原子类,使用这些原子类操作数据可以保证原子性。以AtomicInteger为例,
1 | public class AtomicInteger extends Number implements java.io.Serializable { |
其内部是通过Unsafe类提供的一些native方法实现同步操作。类似的还有AtomicReference
Unsafe类提供类获取Unsafe单例的方法,但是因为限制了调用getUnsafe的类只能是使用BootstrapClassloader加载的类(BootstrapClassloader用于加载JDK核心类库),所以我们无法直接getUnsafe调用。
1 | public final class Unsafe { |
但是我们可以通过反射获取:
1 | private static Unsafe reflectGetUnsafe() { |
ReentrantLock
ReentrantLock和synchronized一样具备可重入性,但是区别在于:
- ReentrantLock支持构造函数传入boolean值选择公平锁(FIFO)还是非公平锁(锁被释放的时候如果正好有线程请求锁则直接允许,否则才唤醒等待队头的线程),默认false非公平锁。因为线程上下文切换开销不小,所以非公平锁性能要优于公平锁。
- ReentrantLock是可中断的,使用lockInterruptibly可以对中断做出响应,在获得锁之前停止休眠。还有一个tryLock作用类似,如果超时或者被中断可以在没获得锁的情况下停止休眠
CountDownLatch
CountDownLatch是能让一个或者多个线程在一系列操作完成之前一直等待的工具,初始化会设置一个计数,为1时相当于一个简单的开关,为N时只有当N个线程都执行完并调用countDown()后才会接着执行当前线程await后面的代码(每次调用countDown()计数器会减1)。计数器不能重置,如果需要重置可以考虑用CyclicBarrier(没用过)。CountDownLatch是通过AQS实现的。AQS内部通过一个dequeue和flag来维护等待队列。
Semaphore
Semaphore是计数的信号量,初始化设置一个值x,可以理解为维护大小为x的permits集合。acquire() 方法可以使用一个permit,如果无可用permit则会block。release() 将释放一个正在使用的permit。Semaphore可以保证任何时候任何情况下都只有最多x个permit在使用。
1 | /** |
Semaphore也是基于AQS的共享锁来实现的。
Condition
Condition本质上和Lock绑定在一起。我们通常使用Lock的newCondition()方法创建Condition实例(实际上就是new了一个ConditionObject,ConditionObject维护了一个等待队列),在获取到锁后调用await()可以suspend当前线程,让出锁,等其他线程用完锁后调用signal()再唤醒当前线程。ArrayBlockingQueue就是通过condition来实现的一个生产者消费者模式的数据结构。
1 | public class ArrayBlockingQueue<E> extends AbstractQueue<E> |
再谈ConcurrentModificationException和Collection线程安全
ConcurrentModificationException
我们都知道在forEach循环的时候不能直接对集合元素进行增删,否则会抛出ConcurrentModificationException。但是原因是为何呢?其实对于Iterable类型的forEach语法糖,本质上还是用的iterator,以下面一段简单的代码为例:
1 | List<Integer> list = new ArrayList<>(); |
我们将这段代码通过javac compile成class后再利用其他decompiler给decompile回Java就可以看到等同于:
1 | java.util.ArrayList localArrayList = new java.util.ArrayList(); |
这验证了我们说的forEach实际上就是iterator实现的。
这里多说一句:如果对数组进行forEach的话则不是iterator,而是for循环遍历下标。同时,我们推荐对forEach中的变量使用final修饰,例如for (final String s : list)
。当foreach变量和集合元素类型不一致时,规则如下:
1 | List<? extends Integer> l = ... |
将被翻译成:
1 | for (Iterator<Integer> #i = l.iterator(); #i.hasNext(); ) { |
我们再回到之前的话题,为什么forEach里不能增删这个arrayList里的元素呢?ArrayList中iterator的next方法实现如下:
1 | "unchecked") ( |
expectedModCount是iterator中的变量,在iterator创建的时候赋值为modCount。每一次iterator的操作都会同步一次expectedModCount = modCount;但是由于我们直接通过ArrayList去add或者remove,就导致modCount变化了,而没有同步给expectedModCount,在下一次iterator的next方法时候判断二者不等,就抛出异常ConcurrentModificationException。那么如何规避呢?方法很多,例如:
- 最容易想到的自然是将forEach换成iterator了
- 其次把forEach换成for循环也可以规避 // 当然这样会有别的问题,不推荐
- 直接将ArrayList换成CopyOnWriteArrayList也能解决
- 通过将集合中的元素拷贝一份,利用拷贝得到的元素进行forEach而不是原集合进行forEach。
这里详细说下第四种吧,这其实也是CopyOnWriteArrayList的原理。在Android源码中也有类似场景,activity生命周期回调执行的时候都会dispatch给Application里的LifecycleCallbacks,我们来看一下:
1 |
|
举例,Activity onStart的时候调了Application的dispatchActivityStarted
1 | /* package */ void dispatchActivityStarted(Activity activity) { |
可以看到这里是先将集合转成数组(这里对集合进行了加锁,确保多线程下的数组和集合一致),利用数组去iterate,同时如果有对集合元素的增删也将作用在集合上,这样就能确保不会出现ConcurrentModificationException(虽然这里源码并不是用的forEach,所以肯定不会出现ConcurrentModificationException,但是即使换成forEach一样不会出现ConcurrentModificationException)
CopyOnWriteArrayList
我们再来看下CopyOnWriteArrayList里是怎么实现的吧。首先同ArrayList一样,CopyOnWriteArrayList也是靠Object数组array存储元素的,不同的是加了volatile修饰,volatile保证里多线程下的可见性,即线程A对array进行写操作后,其他读array的线程也将立即更新各自的array缓存,保证读到的是最新的array值。
其次CopyOnWriteArrayList的iterator保存了一份array副本,使用副本遍历,所以无需校验modCount,同理也不支持set,remove,add操作。
1 | static final class COWIterator<E> implements ListIterator<E> { |
既然CopyOnWriteArrayList不支持iterator的add,那我们直接看一下CopyOnWriteArrayList自身的add:
1 | /** |
可知add之前使用了ReentrantLock加锁,以保证多线程下不会出现并发问题。
HashMap
再来看HashMap的同步问题:
和前面分析的一样,其iterator也是判断ModCount
和expectedModCount
不想等的时候就抛出ConcurrentModificationException。怎么让HashMap变得线程安全呢?
- 用迭代器进行迭代和增删
- 用迭代器迭代和不用迭代器增删前都需要先请求同一个锁
- 使用ConcurrentHashMap
- 使用Collections.synchronizedMap把HashMap变程同步的map
先看下Collections.synchronizedMap
,其实不光map,set,list都可以通过此法转成线程安全的集合。但是此法不推荐,因为其实现原理也是把自己作为锁,所有操作之前先请求锁来保证自身线程安全。但是,其iterator是非线程安全的。也就是说,用迭代器迭代和不用迭代器增删同时发生的时候依然会抛出ConcurrentModificationException。那么如何使用呢?看官网的示例:
ConcurrentHashMap在Java8中是通过CAS和红黑树实现的,其在大小小于等于8的时候使用链表存储。put的时候如果目标key的value是null,就使用CAS操作赋值,不为null就对该元素使用synchronized关键字申请锁,然后进行操作。如果该put操作使得当前链表长度超过8,则将该链表转换为树,从而提高寻址效率。put不允许传入的key或者value为null。对于get,由于数组使用volatile修饰,不需要担心可见性问题。Hash算法:取hashCode,与右移16位后的结果异或,保证最高位是0以确保正数。
1 | static final int spread(int h) { |
参考: