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
编译后会在代码块前后产生monitorenter
和monitorexit
字节码指令。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. 锁优化
自旋锁和自适应锁
使用synchronize关键字的时候,需要使线程挂起和唤醒,然而挂起线程和唤醒线程都需要转入到核心态完成。实际上,程序不需要等待很久的时间就可以轮到下一个程序来执行了,那么这个时候就没有必要挂起线程,而是让等待的线程在获取到锁的时候,在原地循环等待,不断判断锁,是否可以被自己获取。如果有线程释放锁了,那就可以停止等待获取锁了。这种叫自旋锁。
但是获取锁的线程并没有执行什么任务,虽然都是在活跃的状态,我们称作busy-waiting。
自旋锁优点:避免线程切换的开销(即一直处在用户态);
自旋锁缺点:1.不公平锁,所以存在“线程饥饿”问题 2.如果等待时间过长,消耗cpu不做事情,cpu的使用率就下降
自适应锁:为了提高cpu使用率,自旋默认次数是10,但是可以修改参数。jdk1.6自适应不意味着时间不固定了,而是由前一次在同一个锁上的等待时间来决定,虚拟机可以做一些判断。
锁消除
我们之前也讲过,比如Vector,他的内部源码基本所有都存在互斥同步的关键字synchronize,但是实际上有时候不存在数据竞争,那么java编译器就会在编译的时候消除这个锁。
1 |
|
以上代码,字符串不可变,连接操作时生成新的的String对象,javac对对此优化。在jdk5之前会转化为StringBuffer的append操作,之后会转为StringBuild操作。由于Stringbuffer是线程安全的,appen方法有加锁操作,因此以上代码实际上存在同步操作(JDK5之前)。但是虚拟机经过逃逸分析发现,它不会逃到concat方法之外,因此在JIT编译之后,这段代码会忽略所有的同步操作。这个就叫锁消除。
锁粗化
通常情况下,为了保证多线程间的有效并发,会要求每个线程持有锁的时间尽量短,即在使用完公共资源后,应该立即释放锁。只有这样,等待在这个锁上的其他线程才能尽早的获得资源执行任务。但是,凡事都有一个度,如果对同一个锁不停的进行请求、同步和释放,其本身也会消耗系统宝贵的资源,反而不利于性能的优化 。比如:
1 |
|
轻量级锁
重量级锁:synchronize
轻量级锁:通过CSA操作来进行加锁操作。
对于绝大部分的锁,整个周期内都是不存在竞争关系的,那么轻量级锁就可以使用CSA操作来避开使用互斥同步的开销,但是如果存在互斥量的开销外还额外的产生CSA操作,那么轻量级锁就会比重量级锁还要慢。
轻量级锁所适应的场景:线程交替执行同步块的情况,如果存在同一时间访问同一锁的情况,就会导致轻量级锁膨胀为重量级锁。
我们看一下轻量级锁的加锁和解锁过程。下图是对象的对象头Mark Word,它一共32位,前25位记录对象的哈希码,中间4位记录分代年龄,最后2位记录锁的状态。
加锁过程
1. 在代码进入同步块,若是同步对象没有被锁定(01),虚拟机就将当前线程的栈帧中建立一个锁记录空间,用来存储Mark Word的拷贝(Displaced Mark Word)
2. 虚拟机使用CSA操作尝试将对象的Mark Word更新为指向锁记录指针,若是成功,则拥有这个对象的锁了,并且Mark Word的锁标志位为00,表示处于轻量级锁状态。如下图所示。
3. 若是操作失败,则先检查对象的Mark Word是否指向当前线程的栈帧,若当前线程有这个锁了,那就进入同步块继续执行,若是没有,这说明这个锁被其他线程抢占了,这个时候Mark Word变成了重量级互斥指针(10)。
解锁过程
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 协议 ,转载请注明出处!