线程安全与锁优化
文章目录
一、线程安全概念
为了更好的理解线程安全,我们不把线程安全看做是一个二元对立的选项来看,而是按照线程安全的”安全程度“由强至弱来排序。
Java线程各种操作共享的数据分为五类:
- 不可变
- 绝对线程安全
- 相对线程安全
- 线程兼容
- 线程对立
不可变
不可变的对象一定是线程安全的,无论是对象的方法实现还是方法的调用者,都不需要进行任何的线程安全保障措施。被final修饰的对象只要被正确构建出来,就永远是不可变的,也就是线程安全的。
- 例1-1:
String类型就是不可变类型,我们调用它的substring()、replace()方法都不会影响原来的值,只会返回一个新构造的字符串对象。
绝对线程安全
不管运行环境如何,调用者也不需要任何的同步措施,就可以达到线程安全。这种状态通常不可能达到或要付出非常大的代价。
- 例1-2:
Vector是一个线程安全的容器,但是仍然会出错。
//线程一执行:
for(int i=0;i<vector.size();i++){
vector.remove(i);
}
//线程二执行
for(int i=0;i<vector.size();i++){
vector.get(i);
}
该情况下,可能在线程二执行了i++之后执行get(i)之前被阻塞调用了线程一,之后i位置的元素被移除了,此时线程二执行get(i)就会出错
相对线程安全
普遍意义上的线程安全,它保证对象单独的操作时线程安全的,但是对于一些特定顺序的连续调用就可能需要在调用端使用额外的同步手段来保证调用正确性。例1-2就是一个很好的例子。
绝大部分线程安全类都属于这种类型。如Vector、HashTable、Collections的synchronizedCollection()方法包装的集合
线程兼容
线程兼容是指对象本身并不是线程安全的,但是可以通过调用端正确地使用同步手段保证对象可以在并发环境中使用。
如ArrayList和HashMap就是线程兼容的。可以自行添加锁来保证安全
线程对立
无论是否采取同步措施,都无法在多线程环境下保证并发。
如采取同步措施后出现的死锁问题
二、线程安全的实现方法
实现线程安全的方法有三种
- 互斥同步
- 非阻塞同步
- 无同步
互斥同步
互斥同步是最常见的一种并发正确性保障手段。
- 同步是指多个线程并发访问共享数据时,保证共享数据在同一时刻只被一条(或一些,使用信号量的时候)线程使用。
- 互斥是实现同步的一种手段,临界区、互斥量、信号量都是主要实现方式
互斥是因,同步是果。互斥是方法,同步是目的。
最基本互斥手段就是synchronized
synchronized关键字经过编译之后会在同步代码块的前后分别形成monitorenter和monitorexit这两个字节码指令。这两个字节码都需要一个reference类型的参数来指明要锁定和解锁的对象
目前简易执行流程,与实际执行有出入
缺点:
-
由于Java的线程是映射到操作系统的原生线程之上,如果阻塞和唤醒一条线程就需要从用户态转换到核心态,需要耗费处理器较多时间。如对于简单的同步块进行内核状态转换消耗的时间可能比业务代码执行时间还要长,使系统的性能较低。是一个重量级操作。
后期会优化在阻塞前加入一段自旋等待的过程,避免频繁切换状态。
另一种手段JUC下的ReentrantLock
在基本用法上ReentrantLock与synchronized很相似。都具备可重入性。
区别:
- ReentrantLock是表现在API层面的互斥锁,synchronized是表现在原生语法层面的互斥锁
- ReentrantLock获取锁的等待可以被中断,synchronized不可以被中断
- ReentrantLock可以实现公平锁(默认为非公平锁),synchronized则只能是非公平锁
- ReentrantLock可以精确进行线程唤醒,synchronized只能粗略的进行线程唤醒
在JDK1.5时ReentrantLock的性能比synchronized性能稳定且高的多。synchronized仍有很大的优化空间
阻塞同步的问题:
互斥同步的主要问题就是进行线程阻塞和唤醒带来的性能问题。因此这种同步方式被称为阻塞同步,属于悲观的并发策略。无论共享数据是否出现竞争都会进行加锁。**很出现很多不必要的状态切换、维护锁计数器和检查是否有被阻塞的线程需要唤醒等操作。**影响性能
非阻塞同步
随着硬件指令集的发展,我们可以使用**基于冲突检测的乐观并发策略。**通俗的说先进先操作,如果没有其他线程争用共享数据,那么操作就成功了,如果有争用产生了冲突,那就进行其他的补偿措施(一般为循环尝试)。这种操作不需要把线程挂起,因此被称为非阻塞同步。
实现方式CAS操作:
CAS指令由三个操作数:CAS(V,A,B)
- V:共享变量的内存地址
- A:旧的预期值
- B:新的预期值
在硬件层面保证原子性
问题:CAS操作的经典问题ABA问题
线程A读取了数据A,线程B将A修改为B后又修改为A,之后线程A再次访问数据A。这样就存在漏洞。大多数情况下ABA不会影响程序并发的争取性。可以为数据添加一个版本号时间戳来标记数据是否被修改过。
无同步方案
要保证线程安全不一定要同步。同步只是保障数据争用时的正确性的手段。如果一个方法不涉及共享数据,则无需任何同步措施保证正确性。
可重入代码:
可以在代码执行的任何时刻中断它,转而执行另一段代码,而控制权返回后,原来的程序不会出现任何错误。
线程本地存储:
如果一段代码中需要的数据必须与其他代码共享,可以试试将这些共享数据的代码放入同一个线程执行。如果可以,则无须同步也能保证线程之间不出现数据争用。
三、锁优化
自旋锁与自适应锁
自旋锁
由于互斥同步的阻塞对性能影响较大。并且在很多情况下对于共享数据的锁定状态只会持续很短一段时间,为了这段时间去挂起和恢复线程并不值得。所以当物理机可以让两个线程同时执行时,我们可以让请求锁的线程稍等一会并不将其挂起。为了让线程等待,我们只需要让线程执行一个忙循环(自旋),这就是自旋锁。
自适应锁
自适应锁就是对自旋锁的自旋次数进行适应性调整。
如:一个锁对象曾经自旋成功,那么虚拟机认为这次仍然可能成功,进而允许他自旋等待更长的时间。如果一个锁对象自旋很少成功,那么以后获取这个锁时就减少自旋或不自旋。
锁消除
锁消除是指在虚拟机即时编译器在运行时发现一些代码上要求同步,但是实际上不可能出现对于共享数据的竞争,就会进行把同步操作进行省略。
例:
public String concatstring(String s1,String s2,String s3){
return s1+s2+s3;
}
表面上没有锁的存在,实际上jdk会对String连接进行优化,于是以上代码就会变成new StringBuffer().append(s1).append(s2).append(s3).toString();,然而StringBuffer是线程安全的,在其内部就存在锁,但是此时根本无需锁的存在,这里就使用了JVM的锁消除。
锁粗化
在编写代码时,推荐将同步块的作用范围限制得尽量小。但是也有例外情况,如果一系列连续的操作都对同一个对象反复加锁和解锁比如在循环体内加锁。那么就需要不停的加锁和释放锁,及时没有线程竞争也会导致性能消耗。这时只需要将锁的作用域扩大到整个循环。只需要加一个锁就可以了。这就是锁粗化。
轻量级锁
传统的互斥锁属于“重量级”锁。相对应的就是“轻量级”锁。是对“重量级”锁的补充。要理解轻量级锁必须先了解虚拟机的对象的内存布局。HotSpot虚拟机的对象头分为两部分,一部分存储对象自身的运行时数据,一部分存储指向方法区对象类型数据的指针,如果是数组对象的话,还有一部分用于存储数组长度。
Mark Word区域是实现轻量级锁与偏向锁的关键(随着锁标记位的不同,Mark Word的内容不断变化)。
代码进入同步代码块的时候,如果此同步对象没有被锁定,虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间。用于存储当前Mark Word的拷贝,之后尝试CAS操作,成功就修改Mark Word为指向Lock Record的指针并修改锁标记位为00。此时Mark Word内容参考上图。加锁流程图如下:
解锁过程流程图如下:
轻量级锁提升程序性能的依据是“对于绝大部分的锁,在整个同步周期内都不存在竞争”。
偏向锁
如果说轻量级锁是在无竞争的情况下使用CAS操作去消除同步使用的互斥量,那么偏向锁就是无竞争的情况下降整个同步都消除掉。
偏向锁的“偏”是偏心的偏。他的意思是这个锁会偏向于第一个获得他的线程,在接下来的情况如果没有其他的线程要获取他,则持有偏向锁的线程永远不需要同步。而如果有其他的线程要获取这个锁时,偏向模式宣告结束,根据对象是否被锁定恢复到未锁定或轻量级锁定的状态。
偏向锁优缺点:
偏向锁可以提高带有同步但是无竞争的程序性能。但是一个共享数据总是被不同的线程访问,那么偏向模式就是多余的。