深入理解JVM(③)再谈线程安全
前言
我们在编写程序的时候,一般是有个顺序的,就是先实现再优化,并不是所有的牛P程序都是一次就写出来的,肯定都是不断的优化完善来持续实现的。因此我们在考虑实现高并发程序的时候,要先保证并发的正确性,然后在此基础上来实现高效。所以线程安全是高并发程序首先需要保证的。
线程安全定义
对于线程安全的定义可以理解为:当多个线程同时访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那就称这个对象是线程安全的。
这个定义是很严谨且有可操作性,它要求线程安全的代码都必须具备一个共同特征:代码本身封装了所有必要的正确性保障手段(互斥、同步等),令调用者无须关心多线程下的调用问题,更无须自己实现任何措施来保证多线程环境下的正确调用。
Java中的线程安全
要讨论Java中的线程安全,我们要以多个线程之间存在共享数据访问为前提。我们可以不把线程安全当作一个非真即假的二元排他选项来看待,而是按照线程安全的“安全程度”由强至弱来排序,将Java中各操作共享的数据分为以下五类:不可变、绝对线程安全、相对相对安全、线程兼容和线程对立。
不可变
Java内存模型中,不可变的对象一定是线程安全的,无论对象的方法实现还是方法的调用者,都不需要再进行任何线程安全保障措施。在学习Java内存模型这一篇文章中我们在介绍Java内存模型的三个特性的可见性的时候说到,被final修饰的字段在构造器中一旦被初始化完成,并且构造器没有吧“this”的引用传递出去,那么在其他线程中就能看见final字段的值。并且外部可见状态永远都不会改变,永远都不会看到它在多个线程之中处于不一致的状态。“不可变”带来的安全性是最直接、最纯粹的。
在Java中如果共享数据是一个基本类型,那么在定义时使用final修饰它就可以保证它是不可变的。如果共享数据是一个对象,那就需要对象自行保证其行为不会对其状态产生任何影响才行。例如java.lang.String
类的对象实例,它的substring()、replace()、concat()这些方法都不会影响它原来的值,只会返回一个新构造的字符串对象。
保证对象行为不影响自己状态的途径有很多种,最简单的一种就是把对象里面带有状态的变量都声明为final,这样在构造函数结束后,他就是不可变的。
例如java.lang.Integer
构造函数。
/**
* The value of the {@code Integer}.
*
* @serial
*/
private final int value;
/**
* Constructs a newly allocated {@code Integer} object that
* represents the specified {@code int} value.
*
* @param value the value to be represented by the
* {@code Integer} object.
*/
public Integer(int value) {
this.value = value;
}
除了String之外,还有枚举类型以及java.lang.Number
的部分子类,如Long
和Double
等数值包装类型、BigInteger
和BigDecimal
等大数据类型。
绝对线程安全
绝对线程安全是能够完全满足上面的线程安全的定义,这个绝对线程安全的定义是很严格的:“不管运行时环境如何,调用者都不需要任何额外的同步措施”。Java的API中标注自己是线程安全的类,大多数都不是绝对的线程安全。
例如java.util.Vector
是一个线程安全的容器,相信所有的Java程序员对此都不会有异议,因为它的add()、get()、和size()等方法都被synhronized
修饰。但是这样并不意味着调用它的时候,就永远不再需要同步手段了。
public class VectorTest {
private static Vector<Integer> vector = new Vector<Integer>();
public static void main(String[] args){
while (true){
for (int i=0;i<10;i++){
vector.add(i);
}
Thread removeThread = new Thread(new Runnable() {
@Override
public void run() {
for (int i=0;i<vector.size();i++){
vector.remove(i);
}
}
});
Thread printThread = new Thread(new Runnable() {
@Override
public void run() {
for(int i=0;i<vector.size();i++){
System.out.println(vector.get(i));
}
}
});
removeThread.start();
printThread.start();
while (Thread.activeCount() > 20);
}
}
}
运行结果:
Exception in thread "Thread-653" java.lang.ArrayIndexOutOfBoundsException: Array index out of range: 18
at java.util.Vector.get(Vector.java:748)
at com.eurekaclient2.test.jvm3.VectorTest$2.run(VectorTest.java:33)
at java.lang.Thread.run(Thread.java:748)
通过上述代码的例子,就可以看出来,尽管Vector的get()、remove()和size()方法都是同步的,但是在多线程的环境中,如果调用端不做额外的同步措施,使用这段代码仍然是不安全的。因为在并发运行中,如果提前删除了一个元素,而后面还要去打印它,就会抛出数组越界的异常。
如果非要这段代码正确执行下去,就必须把removeThread
和printThread
进行加锁操作。
Thread removeThread = new Thread(new Runnable() {
@Override
public void run() {
synchronized (vector){
for (int i=0;i<vector.size();i++){
vector.remove(i);
}
}
}
});
Thread printThread = new Thread(new Runnable() {
@Override
public void run() {
synchronized (vector){
for(int i=0;i<vector.size();i++){
System.out.println(vector.get(i));
}
}
}
});
相对线程安全
相对线程安全就是我们通常意义上所讲的线程安全,它需要保证对这个对象单词的操作时线程安全的,我们在调用的时候不需要进行额外的保证措施,但是对于一些特定顺序的连续调用,就可能需要在调用端使用额外的同步手段来保证调用的正确性。上面的代码例子就是相对线程安全的案例。
线程兼容
线程兼容是指对象本身并不线程安全的,但是可以通过在调用端正确地使用同步手段来保证对象在并发环境中可以安全地使用。Java类库API中大部分的类都是线程兼容的,如ArrayList
、HashMap
等。
线程对立
线程对立是指不管调用端是否采用了同步措施,都无法在多线程环境中并发是使用代码。由于Java语言天生就支持多线程的特性,此案从对立这种排斥多线程的代码时很少出现的,而且通常都是有害的,应当尽量避免。
线程安全的实现方法
Java虚拟机为实现线程安全,提供了同步和锁机制,在了解了Java虚拟机线程安全措施的原理与运作过程,再去用代码实现线程安全就不是一件困难的事情了。
互斥同步
互斥同步(Mutual Exclusion & Synchronization)是一种常见也是最主要的并发正确性保障手段。
同步是指多个线程并发访问共享数据时,保证共享数据在同一时刻只被一条线程使用。
互斥是指实现同步的一种手段,临界区(Critical Section)、互斥量(Mutex)和信号量(Semaphore)都是常见的互斥实现方式。
在Java里,最基本的互斥同步手段就是synchronized
关键字,这是一种块结构的同步语法。在Java代码里如果synchronized
明确指定了对象参数,那就以这个对象的引用作为reference
;如果没有明确指定,那将根据synchronized
修饰的方法类型(如实例方法或类方法),来决定是取代码所在的对象实例还是取类型对应的Class对象来作为线程要持有的锁。
在使用sychronized
时需要特别注意的两点:
- 被
synchronized
修饰的同步块对同一条线程来说是可重入的。这意味着同一线程反复进入同步块也不会出现自己把自己锁死的情况。 - 被
synchronized
修饰的同步块在持有锁的线程执行完毕并释放锁之前,会无条件地阻塞后面其他线程的进入。这意味着无法像处理某些数据库中的锁那样,强制已获取锁的线程释放锁;也无法强制正在等待锁的线程中断等待或超时退出。
除了synchronized
关键字以外,自JDK5起,Java类库中新提供了java.util.concurrent
包(J.U.C包),其中java.util.concurrent.locks.Lock
接口便成了Java的另一种全新的互斥同步手段。
重入锁(ReentrantLock)是Lock接口最常见的一种实现,它与synchronized
一样是可重入的。在基本用法是,ReentrantLock
与synchronized
很相似,只是代码写法上稍有区别而已。
但是ReentrantLock
与synchronized
相比增加了一些高级特性,主要有以下三项:
- 等待可中断:是指当持有锁的线程长期不释放的时候,正在等待的线程可以选择放弃等待,改为处理其他事情。可中断特性对处理执行时间非常长的同步很有帮助。
- 公平锁:是指多个线程在等待同一个锁时,必须按照申请锁的时间顺序来依次获得锁;而非公平锁则不保证这一点,在锁被释放时,任何一个等待锁的线程都有机会获得锁。
synchronized
是非公平锁,ReentrantLock在默认情况系也是非公平锁,但可以通过构造函数的参数设置成公平锁,不过一旦设置了公平锁,ReentrantLock性能急剧下降,会明显影响性能。 - 锁绑定多个条件:是指一个ReentrantLock对象可以同时绑定多个Condition对象。在
synchronized
中,锁对象的wait()跟它的notify()或者notifyAll()方法配合可以实现一个隐含条件,如果要和多于一个的条件关联的时候,就不得不额外添加一个锁;而ReentrantLock则无须这样做,多次调用newCondition()方法即可。
虽然说ReentrantLock比synchronized
增加了一些高级特性,但是从JDK6对synchronized
做了很多的优化后,他俩的性能其实几乎相差无几了。并且在以下的几种情况下虽然synchronized
和ReentrantLock都可以满足需求时,建议优先使用synchronized
。
synchronized
是在Java语法层面的同步,清晰简单。并且被广泛熟知,但J.U.C中的Lock接口并非如此。因此在只需要基础的同步功能时,更推荐synchronized
。- Lock应该确保在finally块中释放锁,否则一旦受同步保护的代码块中抛出异常,则有可能永远不释放持有的锁。
- 尽管在JDK5时代ReentrantLock曾经在性能上领先过
synchronized
,但这已经是十多年之前的胜利。从长远看,Java虚拟机更容易针对synchronized
来进行优化,因为Java虚拟机可以在线程和对象的元数据中记录synchronized
中锁的相关信息。
非同步阻塞
互斥同步面临的主要问题时进行线程阻塞和唤醒所带来的性能开销,因此这种同步也被称为阻塞同步(Blocking Synchronized)。从解决问题的角度来看,互斥同步是一种悲观的并发策略,无论共享的数据是否真的会出现竞争,都会进行加锁。
随着硬件指令集的发展,出现了另一种选择,基于冲突检测的乐观并发策略,通俗地说就是不管风险,先进行操作,发生了冲突,在进行补偿,最常用的补偿就是不断重试,直到出现没有竞争的数据为止。使用这种乐观并发策略不再需要线程阻塞挂起,因此这种同步操作被称为非阻塞同步(Non-Blocking Synchronized)。
在进行操作和冲突检测时这个步骤要保证原子性,硬件可以只通过一条处理器指令就能完成,这类指令常用的有:
- 测试并设置(Test and Set);
- 获取并增加(Fetch and Increment);
- 交换(Swap);
- 比较并交换(Compare adn Swap,简称CAS);
- 加载链接/条件存储(Load-Linked/Store-Conditional,简称LL/SC)。
Java类库从JDK5之后才开始使用CAS操作,并且该操作有sun.misc.Unsafe类里面的compareAndSwapInt()和compareAndSwapLong()等几个方法包装提供。但是Unsafe的限制了不提供给用户调用,因此在JDK9之前只有Java类库可以使用CAS,譬如J.U.C包里面的整数原子类,其中的compareAndSet()和getAndIncrement()等方法都使用了Unsafe类的CAS操作来实现。直到JDK9,Java类库才在VarHandle类里开放了面向用户程序使用的CAS操作。
下面来看一个例子:
这是之前的一个例子在验证volatile变量不一定完全具备原子性的时候的代码。20个线程自增10000次的操作最终的结果一直不会得到200000。如果按之前的理解就会把race++操作或increase()方法用同步块包起来。
但是如果改成下面的代码,效率将会提高许多。
public class AtomicTest {
public static AtomicInteger race = new AtomicInteger(0);
public static void increase(){
race.incrementAndGet();
}
private static final int THREADS_COUNT = 20;
public static void main(String[] args) throws Exception{
Thread[] threads = new Thread[THREADS_COUNT];
for (int i = 0;i<THREADS_COUNT;i++){
threads[i] = new Thread(() -> {
for(int i1 = 0; i1 <10000; i1++){
increase();
}
});
threads[i].start();
}
while (Thread.activeCount() > 2){
Thread.yield();
}
System.out.println(race);
}
}
运行效果:
200000
使用哦AtomicInteger代替int后,得到了正确结果,主要归功于incrementAndGet()方法的原子性,incrementAndGet()使用的就是CAS,在此方法内部有一个无限循环中,不断尝试讲一个比当前值大一的新值赋值给自己。如果失败了,那说明在执行CAS操作的时候,旧值已经发生改变,于是再次循环进行下一次操作,直到设置成功为止。
无同步方案
要保证线程安全,也不一定非要用同步,线程安全与同步没有必然关系,如果能让一个方法本来就不涉及共享数据,那它自然就不需要任何同步措施去保证正确性,因此有一些代码天生就是线程安全的,主要有这两类:
可重入代码:是指可以在代码执行的任何时刻中断它,然后去执行另外一段代码,而控制权返回后,原来的程序不会出现任何错误,也不会对结果有所影响。
可重入代码有一些共同特征:
不依赖全局变量、存储在堆上的数据和公用的系统资源,用到的状态量都由参数传入,不调用非可重入的方法等。
简单来说就是一个原则:如果一个方法的返回结果是可以预测的,只要输入了相同的数据,就能返回相同的结果,那它就满足可重入性的要求,当然也就是线程安全的。
线程本地存储(Thread Local Storage):如果一段代码中所需的数据必须与其他代码共享,那就看看这些共享数据的代码是否能保证在同一个线程中执行。如果能,就可以把共享数据的可见范围限制在同一个线程内,这样无须同步也能保证线程之间不出现数据争用的问题。
如大部分使用消费队列的架构模式,都会将产品的消费过程限制在一个线程中消费完,最经典一个实例就是Web交互模式中的“一个请求对应一个服务器线程”的处理方式,这种处理方式的广泛应用使得很多Web服务端应用都可以使用线程本地存储来解决线程安全问题。
.
作者:纪莫
欢迎任何形式的转载,但请务必注明出处。
限于本人水平,如果文章和代码有表述不当之处,还请不吝赐教。
欢迎扫描二维码关注公众号:Jimoer
文章会同步到公众号上面,大家一起成长,共同提升技术能力。
声援博主:如果您觉得文章对您有帮助,可以点击文章右下角【推荐】一下。
您的鼓励是博主的最大动力!
![微信公众号](https://img2018.cnblogs.com/blog/772743/201909/772743-20190904004009398-659676330.png)