问题前景
了解线程安全的之前先来了解一下 Java 的内存模型,先搞清楚线程是怎么工作的。
Java 内存模型 - JMM
什么是 JMM
JMM(Java Memory Model),是一种基于计算机内存模型(定义了共享内存系统中多线程程序读写操作行为的规范)
,屏蔽了各种硬件和操作系统的访问差异的,保证了Java程序在各种平台下对内存的访问都能保证效果一致的机制及规范。保证共享内存的原子性、可见性、有序性。 那么问题来了,线程工作内存怎么知道什么时候又是怎样将数据同步到主内存呢? 这里就轮到 JMM 出场了。 JMM 规定了何时以及如何做线程工作内存与主内存之间的数据同步。 对 JMM 有了初步的了解,简单总结一下原子性、可见性、有序性。 原子性:对共享内存的操作必须是要么全部执行直到执行结束,且中间过程不能被任何外部因素打断,要么就不执行。 可见性:多线程操作共享内存时,执行结果能够及时的同步到共享内存,确保其他线程对此结果及时可见。 有序性:程序的执行顺序按照代码顺序执行,在单线程环境下,程序的执行都是有序的,但是在多线程环境下,JMM 为了性能优化,编译器和处理器会对指令进行重排,程序的执行会变成无序。
什么是线程安全?
用《java concurrency in practice 》中的一句话来表述:当多个线程访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其它的协调操作,调用这个对象的行为都可以获得正确的结果,那这个对象就是线程安全的。 从这句话中我们可以知道几层意思:
- 线程安全是和对象密切绑定的
- 线程的安全性是由于线程调度和交替执行造成的
- 线程安全的目的是实现正确的结果
按照java共享对象的安全性,将线程安全分为五个等级:不可变、绝对线程安全、相对线程安全、线程兼容、线程对立;
安全性
不可变
在java中Immutable(不可变)对象一定是线程安全的,这是因为线程的调度和交替执行不会对对象造成任何改变。同样不可变的还有自定义常量(final)及常池中的对象同样都是不可变的。
绝对线程安全
即这些对象无论在任何环境下进行线程调度或交替执行都不会影响数据的正确性。实际上,要实现对象的绝对安全要付出的代价,它要应对各种环境和的不确定性;甚至这种绝对性是不可能实现的,因此,在实际应用当中,javaApi 中不存在绝对线程安全的对象。
相对线程安全
在java api中通常所说的线程安全都是相对的线程安全。
public class ThreadSafeTest {
public static Vector
public static void main (String\[\] args) {
while (true) {
for (int i = 0; i < 100; i++) {
num.add(i);
}
// 从后往前remove
Thread t1 = new Thread(new Runnable() {
@Override
public void run() {
for (int i = 0; i < num.size(); i++) {
num.remove(i);
}
}
});
Thread t2 = new Thread(new Runnable() {
@Override
public void run() {
for (int i = 0; i < num.size(); i++) {
System.out.println(num.get(i));
}
}
});
t1.start();
t2.start();
}
}
}
vector 是java线程安全的数组,t1 和 t2 都有可能报出数组溢出的异常。 可能大家也知道原因:在进行一次for循环的过程中,如果发生中断,则num.size()的大小是会变化的,当尝试去get时,可能正好被 remove掉了。也就是说单独去get或者是remove时,vector 是线程安全的,但组合使用却不是线程安全的,所以vector的线程安全是有条件的,也就是相对线程安全。当然对于java 的concurrent包也是如此。
线程兼容
对象本身并不是线程安全的,但在多线程环境中,客户端可以通过正确地使用同步手段来达到线程安全。在java使用的大部分非线程安全的api都属于这种,例如hashmap ,arraylist等。
线程对立
线程对立指在多并发环境中,无论采取任何同步措施都不应该同时出现的代码。比如Thread suspend(中断)和resume(恢复),如果前一个线程进行了中断,另外一个线程尝试resume时,则获取不到该对象,则会产生死锁。
同步
影响对象线程安全是在多并发情况下,多个线程之间的调度和交替执行,影响对象的状态,那如何实现多线程之间的数据同步将是解决线程安全的关键所在。
互斥同步
互斥同步即当一个线程在对对象进行操作时,其它线程无法对该对象做任何操作。实现互斥同步有多种方法,如临界区、互斥量、信号量都是实现互斥同步的方式。 java实现同步的主要手段是使用synchronized关键字,它经过编译之后,会在同步块前后分别形成monitorenter 和 monitorexit 两个字 节码,这两个字节码都需要一个reference类型的对参数,来说明需要加锁和解锁的对象。如synchronized 没有指定锁定的对象,则指向该类对应的对象或者类本身(如果是静态类)。当执行monitorenter时,锁计数器就会加1,相反当 执行monitorexit时,计数器减1,当计数器为0时,就会释放该对象。当然, synchronized 对同一条线程是可重入的,以避免自已被自己锁死的情况。 除synchronized 外,java.util.concurrent 包还提供了重入锁(ReentrantLock)来实现同步, 重入锁提供了更加丰富的功能:
- 等待可中断:指当持有锁的线程长期不释放锁时,则放弃等待转而做其它事情
- 公平锁 :可以按照时间顺序获取锁(但默认实现仍然是非公平锁)
- 锁绑定多个条件
互斥同步属于较重的同步,它的性能也相对比较低。因为,对两个线程对同一个对象的访问,无论是否会存在数据同步的问题,都要进行锁竞争,它属于一种悲观锁。线程等待锁释放的阻塞过程严重降低了代码的运行效率。
非阻塞同步
为了解决互斥同步的线程阻塞问题,产生了非阻塞同步。非阻塞同步属于乐观锁:基于冲突检测的乐观并发策略,通谷地说,它是先进行操作,如果没有其它线程争用,则操作成功;如果有其它线程争用,产生了冲突,则进行冲突补偿。乐观锁的出现是在硬件发展的基础之上产生的,为什么这么说呢,这是因为需要保证对数据的操作和冲突检测具有原子性,这需要硬件的单一指令来实现。最常见的指令是(compare-and-swap)非阻塞同步并不是要替代互斥同步,只是互斥同步的一个补充,原因在于它的高性能。但由于其在设计上的缺陷(ABA问题),并不能成为互斥同步的一个替代品。
无同步
并不是所有的数据都需要同步,如果不存在数据争用就不需要进行同步。 另外,针对部分情境(共享数据的代码在一条线程内执行)也可以使用线程本地存储(Thread Local storage),它特别适用于消费队列的架构模式。
锁
大部分的数据同步的基础仍然是互斥同步,从上面可以知道互斥同步在进行锁竞争时,线程会被阻塞,并在挂起和唤醒中不断切换,这种线程切换对性能的影响是比较大的。由于以上原因,锁优化就成了JAVA的重要工作。
自旋锁与自适应自旋
在大部分情况下,当一个线程未竞争到锁时,它通常只需要等待很短的时候,就能重新获得锁,所以java 就采用循环忙等待的方式来等待锁的释放,而不是挂起和恢复线程。但是忙等待也不是无损失的,它需要占用cpu资源,如果等待了很长时间,锁也得不到释放,也是很可怕的资源浪费。 所以在1.4时,可以通过参数控制自旋次数,超过次数线程就会被挂起。在1.6时,java则采用了自适应的方式来控制自旋次数,而不需要通过人工进行设置。
锁消除
锁消除是指对于JVM 即时编译时,具有代码同步要求,但实际上共享数据并不存在竞争情况的锁进行消除。共享数据是否存在竞争,依赖JVM的逃逸分析技术。
锁粗化
虽然很多时候我们都希望对锁的粒度进行细化,以减少锁竞争的代码范围;但当,一个代码块需要用多个锁进行同步时,则可以考虑使用一个锁对整个代码块进行锁定,以减少锁的竞争开销。
轻量级锁
轻量级锁是相对于重量级锁而言的,它假设数据不存在竞争的情况下,来提升锁的性能。提升锁性能的方式,就是摒弃重量级锁利用系统互斥量来对对象加锁,而是采用对象头部的标志位和cas操作来进行锁竞争检测。
偏向锁
偏向锁类似于轻量级锁 是对无竞争情况下的优化。由于轻量级锁仍然需要对markword进行同步,而偏向锁则是消除这种同步,进一步地优化性能。偏向锁通过在markword中写入threadId和标志位,来对对象锁定,如果在对对象的操作过程中,其markword部分未被更改,则表示不存在竞争,也就无需同步。如果被更改,则说明存在竞争,会上升到轻量级锁或重量级锁进行同步操作。