在上一篇 java并发编程——内存模型中我们提到:并发编程中,我们需要处理两个关键问题:线程之间如何通信和线程之间如何同步。线程之间如何通信已经在上篇文章中讲述,本文主要来阐述线程之间如何同步。
1. 同步概念
同步是指程序用于控制不同线程之间操作发生相对顺序的机制。
- 在共享内存并发模型里,同步是显式进行的。程序员必须显式指定某个方法或某段代码需要在线程之间互斥执行。
- 在消息传递的并发模型里,由于消息的发送必须在消息的接收之前,因此同步是隐式进行的。
Java的并发采用的是共享内存模型,需要程序员显示的指定某个方法或某段代码需要线程之间互斥执行。
同步的目的:在多线程编程里面,一些敏感共享资源不允许被多个线程同时访问,此时就使用同步访问技术,保证数据在任何时刻,最多有一个线程访问,以保证数据的完整性。
2. java中同步方法
Java提供了很多同步操作,比如synchronized关键字、wait/notifyAll、ReentrantLock、Condition、一些并发包下的工具类、Semaphore,ThreadLocal、AbstractQueuedSynchronizer等,下面就其中常见操作进行详细说明
2.1 synchronized关键字
在java中,synchronized关键字主要有以下三个作用:
- 确保线程互斥的访问同步代码
- 保证共享变量的修改能够及时可见
- 有效解决重排序问题
后两个作用,内存模型文章中提到过,这里主要讲述synchronized如何确保线程同步互斥。
2.1.1 synchronized使用
synchronized提供了两种方式对线程进行同步,分别是同步代码块和同步方法。如下图给出了具体的示例:
使用synchronized效果几点说明(原因见下一节原理说明):
- 当两个并发线程访问同一个对象object中的这个synchronized(this)同步代码块时,一个时间内只能有一个线程得到执行。另一个线程必须等待当前线程执行完这个代码块以后才能执行该代码块。
- 当一个线程访问object的一个synchronized(this)同步代码块时,另一个线程仍然可以访问该object中的非synchronized(this)同步代码块。
- 当一个线程访问object的一个synchronized(this)同步代码块时,其他线程对object中所有其它synchronized(this)同步代码块的访问将被阻塞。
同步代码块和同步方法的选择原则,能缩小代码段的范围就尽量缩小,能在代码段上加同步就不要再整个方法上加同步。这叫减小锁的粒度,使代码更大程度的并发。
2.1.2 synchronized原理
首先我们反编译synchronized使用中给出的示例的代码如下图所示,其中标注出了monitorenter和monitorexit两个字节码指令,这两个指令都需要一个reference类型的参数来指明要锁定和解锁的对象。
java程序中如果synchronized明确指定了对象参数,那么就是这个对象的reference;如果没有明确指出,则根据synchronized修饰的实例方法还是类方法来确定,如果是类方法则取Class对象作为锁对象。总结下来,锁对象分为如下两类:
- synchronized(this)以及非static的synchronized方法,则锁定调用对象本身。
- static修饰的静态方法以及synchronized(xxx.class),则锁定类的Class对象,因为一个类的Class对象只有一个,所以该类的所有相关对象都共享一把锁。
根据jvm规范,在执行monitorenter指令,线程首先要尝试获取reference对应的对象锁。
- 如果该对象锁没有被锁定占有,或者改线程之前已经拥有了该对象锁,则把锁的计数器加1。
- 相应地,在执行monitorexit指令时会将该对象锁计数器减1,当计数器为0时,锁就被释放了。其他被这个对象锁阻塞的线程可以尝试去获取这个对象锁的所有权。
2.1.3 对象锁和类锁概念
每个java对象都可以用做一个实现同步的锁,这些锁称为内置锁。java内置锁是一个互斥锁,这就是意味着最多只有一个线程能够获得该锁,同时是一个可重入锁,锁对象有计数器记录相应线程进入次数只有清空为0才表示该线程不再持有该锁。
java的对象锁和类锁在锁的概念上基本上和内置锁是一致的,只是类锁是用于类的静态方法或者一个类的class对象上的而对象锁是用于对象实例方法。
类的对象实例可以有很多个,但是每个类只有一个class对象,所以不同对象实例的对象锁是互不干扰的,但是每个类只有一个类锁,所有对象共享一个类锁。
2.2 Lock接口
javaSE5之后,并法包新增了Lock接口用来实现锁功能,它提供了与synchronized类似的同步功能,只是使用的时候需要显示的获取和释放锁,同时这些接口也提供了synchronized不具备的特性如下表所示:
特性 | 描述 |
---|---|
尝试非阻塞获取锁 | 当前线程尝试获取锁,如果这一刻锁没有被其他线程获取到,则成功获取持有锁 ,不会阻塞等待锁释放 |
被中断的获取锁 | 与synchronized不同,获取到锁的线程能够响应中断,当获取到的锁的线程被中断时,中断异常将会被抛出,同时锁会被释放 |
超时获取锁 | 接口在指定的截止时间之前获取锁,如果截止时间到了依旧无法获取锁,则返回 |
下面就其中一些常用Lock接口实现类进行讲述。
2.2.1 ReentrantLock重入锁
ReentrantLock是Lock接口一种常见的实现,它是支持重进入的锁即表示在调用lock()方法时,已经获取锁的线程能够再次调用lock()方法而不被阻塞。同时,该锁还支持获取锁时的公平与非公平的选择。 最后,ReentrantLock是排他锁,该锁在同一时刻只允许一个线程来访问。
关于公平与非公平几点说明:
- 如果在绝对时间上,先对于锁进行获取的请求一定先被满足,那么这个锁就是公平的,反之就是非公平的。
- 公平的获取锁也就是等待时间最久的线程优先获取到锁。ReentrantLock的构造函数来控制是否为公平锁。
- 通常情况下,公平锁保证了获取锁按照FIFO原则,而代价就是大量的线程切换,导致性能下降。而非公平有可能导致部分线程饥饿,但是保证了更大的吞吐量。
2.2.2 读写锁
前面提到的ReentrantLock是排他锁,该锁在同一时刻只允许一个线程来访问,而读写锁在同一时刻允许可以有多个线程来访问,但在写线程访问时,所有的读线程和其他写线程被阻塞。
读写锁维护了一对锁,一个读锁和一个写锁,其中读锁是一个共享锁可以被多个线程同时获取,而写锁是一个支持冲进入的排它锁。读写锁实例ReentrantReadWriteLock有以下特性:
- 公平性选择:和ReentrantLock类似
- 重进入:可重入锁,特别注意写线程在获取写锁之后能够再次获取写锁,同时也可以获取读锁。
- 锁降级:遵循获取写锁,获取读锁再释放写锁的次序,写锁能够降级为读锁。
下面给出一个读写锁使用示例:
该示例中使用非线程安全的HashMap作为缓存实现,通过使用读写锁来保证线程的安全。分析代码,在读取操时需要获取读锁为共享锁支持多线程同时访问不被阻塞。在写操作时,首先获取写锁,当获取写锁后,其他线程对于读锁和写锁的获取均被阻塞,只有写锁被释放后,其他操作才可以继续,这样也保证了所有读操作都是最新数据。
2.3 闭锁
闭锁是一种同步工具类,可以延迟线程的进度直到其达到终止状态。闭锁的作用相当于一扇门:在闭锁达到技术状态之前,这扇门一直是关闭的,没有任何线程能通过,当达到结束状态时,这扇门会打开并允许所有线程通过。
闭锁常见使用场景:
- 确保某个计算在其需要的所有资源都被初始化之后才继续执行。
- 确保某个服务在其依赖所有服务都已经启动之后才启动。
CountDownLatch作为闭锁的实现类,拥有一个计数器初始化为一个正数,表示需要等待的事件数量。countDown方法递减计数器,表示有一个事件已经发生,而await会一直阻塞直到计数器为0,所有等待事件已经发生。下面给出一个使用示例:
分析示例代码,startGate作为启动门,由主线程控制所有的线程都准备就绪后打开启动门。而endGate作为结束门,每一个线程在执行结束后countDown减1,最后endGate.await()等待所有线程执行结束。
2.4 信号量
计数信号量(Counting Semaphore)用来控制同时访问某个特定资源的操作数量,或者同时执行某个指定操作的数量。Semaphore常见使用情景:
- 用于资源池,控制资源池大小
- 将任何一种容器变成有界阻塞容器
Semaphore管理着一组虚拟的许可(permit),许可的初始数量通过构造函数来指定,在执行操作时可以首先获取许可(有剩余许可),并在执行结束以后释放许可。如果没有许可剩余,则acquire将阻塞(或者中断或者超时)。下面给出一个有界阻塞容器的示例:
2.5 栅栏
栅栏(Barrier)类似于闭锁,它能阻塞一组线程直到某个事件发生。栅栏和闭锁的关键区别,所有线程必须同时到达栅栏位置,才能继续执行。闭锁用于等待事件,而栅栏用于等待其他线程。
CyclicBarrier作为栅栏实现类,可以使一定数量的参与方反复在栅栏位置汇集。当线程到达栅栏位置时会调用await方法,该方法会阻塞调用线程直到所有线程到达栅栏位置。当所有线程到达指定位置,那么栅栏会被打开,所有线程被释放,而栅栏将重置等待下次执行。下面给出一个使用示例:
2.6 ThreadLocal
对于多线程资源共享的问题,前面叙述的同步机制采用了“以时间换空间”的方式,而ThreadLocal采用了“以空间换时间”的方式。
ThreadLocal实现原理,查看Thread类源码可以看到其中有一个hash结构的ThreadLocal内部类ThreadLocalMap对象,该对象实例threadLocals即是用来保存具体值的对象,定义如下图所示
ThreadLocal类负责管理threadLocals中保存的变量,具体set代码见下图:
在set方法中以当前线程的实例为参数调用getMap获取当前线程的ThreadLocalMap对象,如果线程的threadLocals不为null,就将value保存到threadLocals中,反之,先创建一个ThreadLocalMap实例。
可以看出,隔离的value并不是保存到ThreadLocal中,而是在每个线程对象的内部来保存。因为是每个线程自己来保存value,所以做到了线程间相互隔离。
接下来看下ThreadLocalMap类作为ThreadLocal内部类的定义如下图所示:
其中,Entry继承与WeakReference,ThreadLocalMap中的键ThreadLocal对象通过软引用来保存,值则保存到一个Object的实例value中。因为key是一个软引用,所以每次gc之后就可能有key为null。
最后,一个需要注意的坑:
通过上述分析,可以看出ThreadLocal其实是与线程绑定的一个变量,这样出现一个问题:如果没有将ThreadLocal内的变量删除或替换,他的生命周期将会与线程共存。
只有线程真正注销的时候,ThreadLocal才会被回收,但是类似线程池管理对线程都是采用复用的过程,那么生命周期都是不可预测的。假设Threadlocal中存放了一个HashMap里面有很大的对象,这样对象就会在内存中一直无法释放。
通常使用ThreadLocal的set和remove有始有终,外部调用代码使用finally来remove数据,如果是第三方包引起的可以通过BTrace工具定位。
参考文章
《java并发编程实战》
http://langgufu.iteye.com/blog/2152608
http://www.cnblogs.com/paddix/p/5367116.html
https://www.ibm.com/developerworks/cn/java/j-threads/
http://fangjian0423.github.io/2016/04/18/java-synchronize-way/