java线程安全和锁

1. java线程安全

 按照线程安全的由强至弱的程度,可以把java语言中各种操作共享的数据分为五类:不可变、绝对线程安全、相对线程安全、线程兼容和线程对立。

java中的线程安全

不可变

 不可变对象一定是线程安全的,无需再进行任何安全措施保障。比如final修饰的变量等。比如java.lang.String,它的任何方法substring()、concat()等都是返回新的字符串,不会修改原字符串。

绝对线程安全

 不管运行时环境如何,调用者都不需要任何额外的同步措施。通常代价较大。比如java.util.Vector,它的方法add() get() remove() size()等都添加了synchronized,尽管效率不高,但是可以保证原子性、可见性和有序性。但是它不是绝对线程安全的,比如有多个线程同时多次添加、读取、删除Vector元素,这时候无法保证循环每个线程中for循环的i的原子性,可能会报越界,因此需要额外保证同步。

相对线程安全

 通常来说线程是安全的,但对于一些特定顺序的连续调用,就可能需要在调用端使用额外的同步手段来保证调用的正确性。比如Vector

线程兼容

 本身不是线程安全的,但是可以通过调用端使用同步来保证安全性。java类api中大多数类都是线程兼容的。

线程对立

 无论是否采用同步措施,都无法在并发中使用。比如Thread类的suspend()resume()方法。

线程安全的实现

互斥同步

 互斥是方法,同步是目的。临界区(Critical Section)、互斥量(Metex)和信号量(Semaphore)是常见的实现互斥的方式。

互斥量和信号量的区别

  • 互斥量用于线程的互斥,信号量用于线程的同步。
    • 互斥:是指某一资源同时只允许一个访问者对其进行访问,具有唯一性和排它性。但互斥无法限制访问者对资源的访问顺序,即访问是无序的。
    • 同步是指在互斥的基础上(大多数情况),通过其它机制实现访问者对资源的有序访问。
  • 互斥量值只能为0/1,信号量值可以为非负整数。
  • 互斥量的加锁和解锁必须由同一线程分别对应使用,信号量可以由一个线程释放,另一个线程得到。

synchronized是实现互斥同步的最基本的手段。synchronized编译后会在代码块前后产生monitorentermonitorexit字节码指令。monitorenter指令被执行时,会先获取对象的锁,如果当前线程已经持有了对象锁了,则会把锁计数器+1,monitorexit指令被执行时,锁计数器-1,当锁计数器为0的时候说明锁被释放了。如果获取锁失败,当前线程阻塞,直至获取成功为止。

 加锁是一个重量级的操作。因为java线程是映射到操作系统原生的线程之上的,如果需要阻塞或者唤醒线程,则需要操作系统的帮忙,需要切换用户态到内核态,这种状态转换需要消耗很多处理器时间。比如给简单的get/set方法加锁,切换耗时会比代码耗时更长,因此非必要不加锁。

 虚拟机对锁进行了一些优化,比如自旋锁等,下节会看到。

 除了 synchronized,jdk5起提供了java.util.concurrent包,其中的java.util.concurrent.locks.Lock接口成了Java的另一种互斥同步手段。重入锁(ReentrantLock)是Lock接口最常见的一种实现,也是可重入的,功能与 synchronized类似,多了以下高级功能:

  • 等待可中断:当持有锁的线程长期不释放锁的时候,正在等待的线程可以选择放弃等待,改为处理其他事情。
  • 可实现公平锁:多个线程等待同一锁时,必须按照申请时间来一次获得。默认非公平。
  • 绑定多个条件:一个ReentrantLock对象可以绑定多个Condition对象。

 性能上,理论上多线程环境下synchronized的吞吐量比不上ReentrantLock,但是由于虚拟机对锁的优化,实际上他们两差不多。在实际功能中,如果都满足条件,《深入理解java虚拟机》作者推荐使用synchronized。

非阻塞同步

 互斥同步实际上一种阻塞同步,属于一种悲观的并发策略。实际上不一定会发生互斥,因此也可以不管风险,直接操作,如果共享数据被征用了,再进行补偿措施,这种乐观的并发策略被称为非阻塞同步。这种需要硬件支持(否则手动实现需要互斥同步),需要操作和冲突检测这两个步骤具备原子性。这类指令有:

  • Test-and-Set

  • Fetch-and-Increment

  • Swap

  • Compare-and-Swap,CAS

  • Load-Linked/Store-Conditional,LL/SC

 以上最常用的是CAS指令,它有3个操作数内存地址V, 旧的预期值A,准备设置的新值B,执行CAS指令时,当V符合A时,才会更新V的值为B,否则不更新,这是一个原子操作。java.util.concurrent.atomic包中提供了一系列可原子操作的对象。比如

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
public class IntVolatile {

private static AtomicInteger a = new AtomicInteger(0);

public static void increase() {
// a++
a.incrementAndGet();
}

public static void main(String[] args) throws InterruptedException {
Thread[] threads = new Thread[20];
for (int i = 0; i < threads.length; i++) {
threads[i] = new Thread(new Runnable() {
@Override
public void run() {
for (int j = 0; j < 10000; j++) {
increase();
}
}
});
threads[i].start();
}

//等待所有线程结束
TimeUnit.SECONDS.sleep(5);
// while (Thread.activeCount() > 1)
// Thread.yield();

System.out.println(a);
}
}

//incrementAndGet()实现,实际就是 自旋锁 + CAS操作
public final int getAndAddInt(Object var1, long var2, int var4) {
int var5;
do {
var5 = this.getIntVolatile(var1, var2);
//CAS操作
} while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));

return var5;
}

无同步方案

 有一些代码天生就安全,无需额外同步。比如不使用全局变量的类方法,在栈上操作,线程私有。

2. 锁优化

自旋锁和自适应锁

 使用synchronize关键字的时候,需要使线程挂起和唤醒,然而挂起线程和唤醒线程都需要转入到核心态完成。实际上,程序不需要等待很久的时间就可以轮到下一个程序来执行了,那么这个时候就没有必要挂起线程,而是让等待的线程在获取到锁的时候,在原地循环等待,不断判断锁,是否可以被自己获取。如果有线程释放锁了,那就可以停止等待获取锁了。这种叫自旋锁

 但是获取锁的线程并没有执行什么任务,虽然都是在活跃的状态,我们称作busy-waiting。

自旋锁优点:避免线程切换的开销(即一直处在用户态);

自旋锁缺点:1.不公平锁,所以存在“线程饥饿”问题 2.如果等待时间过长,消耗cpu不做事情,cpu的使用率就下降

自旋锁实现

自适应锁:为了提高cpu使用率,自旋默认次数是10,但是可以修改参数。jdk1.6自适应不意味着时间不固定了,而是由前一次在同一个锁上的等待时间来决定,虚拟机可以做一些判断。

锁消除

 我们之前也讲过,比如Vector,他的内部源码基本所有都存在互斥同步的关键字synchronize,但是实际上有时候不存在数据竞争,那么java编译器就会在编译的时候消除这个锁。

1
2
3
public String concat(String s1, String s2, String s3){
return s1 + s2 + s3;
}

 以上代码,字符串不可变,连接操作时生成新的的String对象,javac对对此优化。在jdk5之前会转化为StringBuffer的append操作,之后会转为StringBuild操作。由于Stringbuffer是线程安全的,appen方法有加锁操作,因此以上代码实际上存在同步操作(JDK5之前)。但是虚拟机经过逃逸分析发现,它不会逃到concat方法之外,因此在JIT编译之后,这段代码会忽略所有的同步操作。这个就叫锁消除

锁粗化

 通常情况下,为了保证多线程间的有效并发,会要求每个线程持有锁的时间尽量短,即在使用完公共资源后,应该立即释放锁。只有这样,等待在这个锁上的其他线程才能尽早的获得资源执行任务。但是,凡事都有一个度,如果对同一个锁不停的进行请求、同步和释放,其本身也会消耗系统宝贵的资源,反而不利于性能的优化 。比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public void demoMethod(){  
synchronized(lock){
//do sth.
}
//...做其他不需要的同步的工作,但能很快执行完毕
synchronized(lock){
//do sth.
}
}


//锁粗化之后
public void demoMethod(){
//整合成一次锁请求
synchronized(lock){
//do sth.
//...做其他不需要的同步的工作,但能很快执行完毕
}
}

轻量级锁

 重量级锁:synchronize

 轻量级锁:通过CSA操作来进行加锁操作。

 对于绝大部分的锁,整个周期内都是不存在竞争关系的,那么轻量级锁就可以使用CSA操作来避开使用互斥同步的开销,但是如果存在互斥量的开销外还额外的产生CSA操作,那么轻量级锁就会比重量级锁还要慢。

轻量级锁所适应的场景:线程交替执行同步块的情况,如果存在同一时间访问同一锁的情况,就会导致轻量级锁膨胀为重量级锁。

 我们看一下轻量级锁的加锁和解锁过程。下图是对象的对象头Mark Word,它一共32位,前25位记录对象的哈希码,中间4位记录分代年龄,最后2位记录锁的状态。

HotSpot虚拟机对象头Mark Word

加锁过程

轻量级锁CAS之前堆栈与对象状态

 1. 在代码进入同步块,若是同步对象没有被锁定(01),虚拟机就将当前线程的栈帧中建立一个锁记录空间,用来存储Mark Word的拷贝(Displaced Mark Word)

 2. 虚拟机使用CSA操作尝试将对象的Mark Word更新为指向锁记录指针,若是成功,则拥有这个对象的锁了,并且Mark Word的锁标志位为00,表示处于轻量级锁状态。如下图所示。

 3. 若是操作失败,则先检查对象的Mark Word是否指向当前线程的栈帧,若当前线程有这个锁了,那就进入同步块继续执行,若是没有,这说明这个锁被其他线程抢占了,这个时候Mark Word变成了重量级互斥指针(10)。

轻量级锁CAS之后堆栈与对象状态

解锁过程

 1. 若是对象的Mark Word仍指向线程的锁记录,那就用CSA操作把对象当前的Mark Word和线程赋值的Displaced Mark Word替换回来;

 2. 替换成功,同步完成,失败说明有其他锁试图获取该锁,那就唤醒被挂起的线程。

 如果使用轻量级同步锁提升性能,必须满足:对于绝大部分锁,在同步周期内不存在竞争。否则的话,存在锁竞争,出去互斥量开销,还额外发生了CAS操作开销。

偏向锁

偏向锁:顾名思义,偏袒,在无竞争的情况下把整个同步都消除掉,偏向于第一个获得它的线程。若是该锁接下来没有被其他线程获取,持有偏向锁的线程就不需要同步。

 轻量级锁是在无竞争的情况下使用CAS操作进行消除同步使用的互斥量;但是偏向锁是消除所有同步,包括CAS。

 假设当前虚拟机开启了偏向锁-XX: UseBiased Locking, 默认开启,当锁第一次被线程获取时,虚拟机会把Mark Word中的标志位设置为01,偏向模式设置为1,同时CSA操作把获取锁的线程的ID记录在对象的Mark Word里。如果cas操作成功,以后持有该锁的线程进入同步块都不会做任何同步操作。

 一旦出现另一个线程尝试去获取偏向锁的时候,偏向模式结束,撤销偏向后恢复到未锁定01或者轻量级锁定00。

锁状态转换

注意:从HotSpot虚拟机对象头Mark Word的结构我们可以看到,当对象进入偏向模式的时候,原先存哈希码的空间被用来存储持有锁的线程id了,那么原先的哈希码怎么办呢?

 哈希码对于对象来确定唯一性很重要,java中绝大多数api的hashCode()都继承自Object::hashCode(),调用hashCode函数后,哈希码会被记录在对象头中,如果一个对象已经记录了哈希码,那么它是无法进行偏向模式的;如果正持有偏向锁,又收到需要计算哈希码的请求,那么偏向锁会膨胀为重量级锁。


本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!