1、线程安全

当多个线程同时访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那就称这个对象是线程安全的。

根据线程安全程度对操作共享的数据可分为五类:

① 不可变

JDK1.5之后不可变的对象一定是安全的,如果共享数据是基础数据类型,那么声明为final之后就一定可以保证安全,如果是对象引用类型,那么需要在对象具体方法中去确保安全,比如String中的各个方法,需要保证不能修改原串;

② 绝对线程安全

绝对的安全代价非常大,在使用Java中线程安全的类时可能还需要额外的同步操作才能保证真正的线程安全,但这种绝对安全并不是必须要的;

③ 相对线程安全

Java 中宣称线程安全的类都是这种类型,也就是通常意义上的线程安全,它只保证对象的单次操作是安全的,比如 Vector、HashTable等;

④ 线程兼容

这种类型指对象本身不是线程安全,但是可以通过调用时使用的同步手段确保安全,比如ArrayList、HashMap等;

⑤ 线程独立

指的是无论使用什么手段,都无法在并发的环境下安全使用,要避免使用。

2、线程安全的实现方法

1)互斥同步

也就是使用互斥手段来达到同步的效果,常见的互斥手段为临界区、互斥量、信号量;

synchronized 是JVM提供的互斥同步手段,并且是可重入的,同一个锁持有对象可以多次加锁,除了持有锁的对象之外,其他对象无法干涉锁的释放,也即是说其他对象在竞争锁失败时会阻塞等待,直到锁持有对象自己释放锁,当然synchronized是无需异常处理释放锁的,因为jvm底层会保证同步代码块之后异常时跳转到释放锁的指令。

ReentrantLock 是在类库层面实现的互斥同步手段,相比synchronized有几点新功能:
① 等待可中断:在获取锁时,如果长时间获取不到可以中断执行其他代码,也就是tryLock(timeout) 来实现;
② 公平锁支持:所谓公平锁即是多个对象竞争锁时最先等待的会获取到锁,非公平是随机在等待对象中挑选一个,默认非公平,可通过构造方法参数传入构造公平锁;
③ 锁可绑定多个条件:在synchronized中如果执行wait notify只有一个隐含的实现条件,而ReentrantLock可以通过newCondition()获取到多个条件,从而对他们分别调用await、signal方法,实现指定对象的等待唤醒操作;

2)非阻塞同步

指的是基于冲突检测的乐观并发策略,也可以称为无锁,在Java中即是CAS操作,这里涉及到三个值对象,一个是内存中的共享数据值A,一个是进入操作前的旧值B,一个是即将查询赋的值C,在进行B—>C的操作中,CAS会先检测A是否和B相等,如果相等才去执行B—>C,不相等则循环判断,直到赋值成功。

这种方案的好处是避免加锁情况下的多对象等待,但会造成ABA问题,也即是另一个线程将A变成B,后另一个线程将B变成A,在CAS中是无变化的,但实际上已经做了两次赋值了。ABA问题可以使用记录变量变更版本信息来解决(原子引用类AtomicStampedReference),但性能上不如直接加同步锁来的优。

3)无同步方案

可重入代码:纯代码,指的是代码的结果性可预测,并且只要输入的是相同的数据就会返回相同的结果;
线程本地存储:代码只在一个线程中执行,不会有越界的行为,这种情况下自然不会有线程安全问题。

3、锁的优化

1)锁自旋

首先明确Java中的线程与OS上的线程是一一对应的,每一次线程的阻塞/就绪状态都会涉及到OS内核态到用户态的切换,这一块的开销主要体现在对象获取锁失败之后进入阻塞状态,而在程序中实际上有很多的锁的持有时间是很短的,这里切换的开销就有点大了。自旋锁解决的就是阻塞到就绪频繁切换的问题,如果获取不到锁不会马上进入阻塞等待,而是再循环多几次获取锁,如果刚好获取到了就可以避免切换的开销了。在JDK1.5之后有了自适应自旋,JVM 会自动判断是否需要自旋、自旋多少次,无需人工干涉。

2)锁消除

除了显示加上的synchronized等锁,程序中还存在一种我们在编码时看不到的锁,例如在进行StringBuffer的append操作时,实际上就会加锁,而如果我们可以确保对象不发生逃逸(不会被外部引用),那么就无需上锁了,因此这里JVM也是会进行精细的逃逸分析,如果判断对象没有被外部引用的危险,只是在线程内被使用,那么就会帮我们把锁消除,减少开销。

3)锁粗化

这里使用上面的append方法加锁来做说明,虽然可能已经发生锁消除了,但不影响描述概念。因为锁是加在append方法上的,因此如果调用十次,就是加了十次的锁,而锁粗化即是把锁的范围拉大,相当于将十次的append方法都包括在一个同步代码块内,这样只需要加一次锁,开销也小了。

4)轻量级锁

我们使用synchronized的时候其实都是假设在同一时刻有多个线程来竞争,这时候就不得不加上monitor锁,也称为重量级锁,因为加锁解锁再加锁这个过程开销是比较大的,而如果我们有几个线程,但他们执行同步代码块的时间是错开的,那么就没有必要频繁加monitor锁了,这时候轻量级锁就上场了。轻量级锁利用到了对象头的Mark Word部分,平常这部分是用来存放hashCode、分代年龄等信息的,锁标志位是01,一旦线程准备进入同步代码块时,就会在线程栈中创建一个Lock Record(锁记录信息),接着获取锁对象Mark Word中的信息,存入Lock Record中,并将自己的线程ID写入Mark Word,将锁标志位设置为00。

如果后续有本线程再次获取锁对象,检查到Mark Word的线程ID一致,那么相应的计数器会加一,后续待到计数器为0才会将锁释放掉。

如果有其他线程获取锁对象,检查了Mark Word发现线程ID不一致,就会获取一个Monitor对象,将Mark Word信息修改为Monitor对象地址,将锁标志位设置为10,自己进入Entry Set中等待获取锁,后续获取锁的对象也需要进入Entry Set中的等待。这也被称为锁膨胀。

5)偏向锁

偏向锁顾名思义就是只偏向一方(一个线程)的锁,在JDK1.6以后引入。如果我们的使用场景下只有一个线程会频繁进入同步代码块(频繁获取锁),那么这时可以直接使用偏向锁,在锁对象的Mark Word中记录下偏向的线程ID,将偏向标志位置为1,这时hashCode的位置就会被偏向进程ID所替代,此后如果获取锁的线程是同一个,那么无需做多余的操作,跟没加锁一样。如果很不幸有其他的线程来竞争,那么会先判断当前是否已被锁定,如果不是则先进行重偏向,将当前线程ID存入Mark Word,如果已被锁定或者后续有多次的重偏向,就会撤销偏向锁,转而加上轻量级锁。

这里需要注意,偏向锁状态下hashCode是没地方存储的,如果对象已经计算好hashCode存放在Mark Word了,那么后续将无法进入偏向锁的状态,如果在偏向锁的状态下对象的hashCode被请求获取了,那么偏向锁将马上膨胀为重量级锁。