引言
带着这些问题找答案:
- 怎么定义线程安全?
- 线程安全的实现方法有哪些?
- 多线程环境会带来什么问题?
- 解决这些问题的办法有哪些?
- synchronized关键字解决这些问题的原理是什么?
- synchronized关键字怎么使用?
- JVM的锁优化思路。
- 使用锁有哪些优化思路?
线程安全
怎么定义线程安全
《Java Concurrency In Practice》有一个比较恰当的定义:
当多个线程访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那这个对象时线程安全的。
这个定义比较严谨,它要求线程安全的代码都必须具备一个特性:
代码本身封装了所有必要的正确性保障手段(如互斥同步等),令调用者无须关心多线程的问题,更无须自己采取任何措施来保证多线程的正确调用。
但是从实际出发,这个定义——不管运行时环境如何,调用者都不需要任何额外的同步措施——其实是很严格的,一个类要达到这个定义通常需要付出很大的代价,甚至有时候是不切实际的代价。在JAVA API中标注自己是线程安全的类,大多数都不是绝对的线程安全。
对 java.util.Vector 线程安全的测试:
private static Vector<Integer> vector = new Vector<>();
public static void main(String[] args) {
while (true) {
for (int i = 0; i < 10; i++) {
vector.add(i);
}
Thread removeThread = new Thread(new Runnable() {
@Override
public void run() {
for (int i = 0; i < vector.size(); i++) {
vector.remove(i);
}
}
});
Thread printThread = new Thread(new Runnable() {
@Override
public void run() {
for (int i = 0; i < vector.size(); i++) {
System.out.println(vector.get(i));
}
}
});
removeThread.start();
printThread.start();
// 不要同时产生过多的线程,否则会导致操作系统假死
while (Thread.activeCount() > 20) {
;
}
}
}
运行结果如下:
Exception in thread "Thread-43791" java.lang.ArrayIndexOutOfBoundsException: Array index out of range: 17
at java.base/java.util.Vector.get(Vector.java:780)
ArrayIndexOutOfBoundsException抛出的原因:如果另一个线程恰好在错误的时间里删除了一个元素,导致序号 i 已经不再可用的话,再用 i 访问数组就会抛出这个异常。
怎么定义相对线程安全
相对线程安全就是我们通常意义上所讲的线程安全。
相对线程安全需要保证对这个对象单独的操作是线程安全的,我们在调用的时候不需要做额外的保障措施,但是对于一些特定顺序的连续调用,就可能需要在调用端使用额外的同步手段来保证调用的正确性。
对比线程绝对安全的定义:
当多个线程访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那这个对象时线程安全的。
我们把“调用这个对象的行为“限定为“单次调用“,这个定义的其他描述也能够成立的话,我们就称它是线程安全的。 即我们对相对线程安全的定义为:
当多个线程访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,单次调用对象的行为可以获得正确的结果,那这个对象时线程安全的。
线程安全的实现方法
- 互斥同步(Mutual Exclusion & Synchronization)
- 非阻塞同步(Non-Blocking Synchronization)
- LOCK FREE(最提倡的方式)
互斥同步(Mutual Exclusion & Synchronization)
同步是指在多个线程并发访问共享数据时,保证共享数据在同一个时刻只被一个(或者是一些,使用信号量的时候)线程使用。
互斥是实现同步的一种手段,临界区(Critical Section)、互斥量(Mutex)和信号量(Semaphore)都是主要的互斥实现方式。
- 在Java中,最基本的互斥同步手段就是 synchronized 关键字。
- 还可以使用 java,util.concurrent(下文称J.U.C)包中的重入锁(ReentrantLock)来实现同步。
synchronized 关键字
synchronized 关键字怎么使用?
- synchronized关键字可以修饰实例方法和类方法。
synchronized关键字经过编译之后,会在同步块的入口位置和退出位置分别插入monitorenter和monitorexit字节码指令,这两个字节码都需要一个reference类型的参数来指明要锁定和解锁的对象:
- 如果synchronized明确指定了对象参数,拿就是这个对象的reference;
- 如果没有明确指定,那就根据synchronized修饰的是实例方法还是类方法来取。如果synchronized修饰的是实例方法,那么锁对象为对应的对象实例;如果synchronized修饰的是类方法,那么锁对象为对应的Class对象。
synchronized 关键字实现原理
在Java虚拟机的specification中,有关于monitorenter和monitorexit字节码指令的详细描述:
monitorenter
The objectref must be of type reference. Each object is associated with a monitor. A monitor is locked if and only if it has an owner. The thread that executes monitorenter attempts to gain ownership of the monitor associated with objectref, as follows:
* If the entry count of the monitor associated with objectref is zero, the thread enters the monitor and sets its entry count to one. The thread is then the owner of the monitor.
* If the thread already owns the monitor associated with objectref, it reenters the monitor, incrementing its entry count.
* If another thread already owns the monitor associated with objectref, the thread blocks until the monitor’s entry count is zero, then tries again to gain ownership.
每个对象都有一个锁,也就是监视器(monitor)。当monitor被占有时就表示它被锁定。线程执行monitorenter指令时尝试获取对象所对应的monitor的所有权,过程如下:
- 如果monitor的进入数为0,则该线程进入monitor,然后将进入数设置为1,该线程即为monitor的所有者;
- 如果线程已经拥有了该monitor,只是重新进入,则进入monitor的进入数加1;
- 如果其他线程已经占用了monitor,则该线程进入阻塞状态,直到monitor的进入数为0,再重新尝试获取monitor的所有权。
monitorexit
The objectref must be of type reference. The thread that executes monitorexit must be the owner of the monitor associated with the instance referenced by objectref. The thread decrements the entry count of the monitor associated with objectref. If as a result the value of the entry count is zero, the thread exits the monitor and is no longer its owner. Other threads that are blocking to enter the monitor are allowed to attempt to do so.
执行monitorexit的线程必须是相应的monitor的所有者。 指令执行时,monitor的进入数减1,如果减1后进入数为0,那线程退出monitor,不再是这个monitor的所有者。其他被这个monitor阻塞的线程可以尝试去获取这个monitor的所有权。
关于monitorenter和monitorexit字节码指令的描述,需要注意两个点:
- synchronized同步块对同一条线程来说是可重入的,不会出现自己把自己锁死的问题。
- 同步块在已进行的线程执行完之前,会阻塞后面其他线程的进入。
在JDK1.6及其之前的版本中monitorenter和monitorexit字节码依赖于底层的操作系统的Mutex Lock来实现的,但是由于使用Mutex Lock需要将当前线程挂起并从用户态切换到内核态来执行,这种切换的代价是非常昂贵的。然而在现实中的大部分情况下,同步方法是运行在单线程环境(无锁竞争环境)。如果每次都调用Mutex Lock将严重的影响程序的性能。因此在JDK 1.6之后的版本中对锁的实现做了大量的优化,这些优化在很大程度上减少或避免了Mutex Lock的使用。
synchronized 关键字需要注意问题
- Java的线程是映射到操作系统的原生线程之上的,如果要阻塞或唤醒一个线程,都需要操作系统来帮忙完成,这就需要从用户态转换到核心态中,因此状态转换需要耗费很多的处理器时间。后面讲的自旋等待就是为了避免频繁地切入到核心态之中。
ReentrantLock
- ReentrantLock 如何使用
public static ReentrantLock lock = new ReentrantLock();
public static void main(String[] args) {
/**
* {@code true} if the lock was free and was acquired by the
* current thread, or the lock was already held by the current
* thread; and {@code false} otherwise
*/
if (lock.tryLock()) {
try {
//do something
} finally {
lock.unlock();
}
}
}
- ReentrantLock对比synchronized的一些高级功能
· 等待可中断是指当持有锁的线程长期不释放锁的时候,正在等待的线程可以选择放弃等待,改为处理其他事情,可中断特性对处理执行时间非常长的同步块很有帮助。
· 公平锁是指多个线程在等待同一个锁时,必须按照申请锁的时间顺序来依次获得锁;而非公平锁则不保证这一点,在锁被释放时,任何一个等待锁的线程都有机会获得锁。synchronized中的锁是非公平的,ReentrantLock默认情况下也是非公平的,但可以通过带布尔值的构造函数要求使用公平锁。
· 锁绑定多个条件是指一个ReentrantLock对象可以同时绑定多个Condition对象,而在synchronized中,锁对象的wait()和notify() 或 notifyAll() 方法可以实现一个隐含的条件,如果要和多于一个的条件关联的时候,就不得不额外地添加一个锁,而ReentrantLock则无须这样做,只需要多次调用newCondition方法即可。
锁可以绑定多个条件示例代码如下:
/**
* @author jungle
* @create 2022/2/13 10:27
* @description 让线程A和线程B交替进行打印10次
*/
public class LockWithConditionTest {
private ReentrantLock lock = new ReentrantLock();
private int number = 1;
private Condition conditionA = lock.newCondition();
private Condition conditionB = lock.newCondition();
private void doA() {
// 获取锁资源
lock.lock();
try {
// 判断是否可以执行业务
while (1 != number) {
conditionA.await();
}
// 执行业务
System.out.println("A " + number);
// 通知其他线程
number = 2;
conditionB.signal();
} catch (Exception e) {
// TODO 异常日志打印
e.printStackTrace();
} finally {
lock.unlock();
}
}
private void doB() {
lock.lock();
try {
while (2 != number) {
conditionB.await();
}
System.out.println("B " + number);
number = 1;
conditionA.signal();
} catch (Exception e) {
e.printStackTrace();
} finally {
lock.unlock();
}
}
public static void main(String[] args) {
LockWithConditionTest test = new LockWithConditionTest();
new Thread(() -> {
for (int i = 0; i < 10; i++) {
test.doA();
}
}).start();
new Thread(() -> {
for (int i = 0; i < 10; i++) {
test.doB();
}
}).start();
}
}
synchronized 关键字和ReentrantLock对比(重要)
- synchronized关键字为原生语法层面的互斥锁,是JVM层面锁;ReentrantLock为API层面的互斥锁,是JDK层面锁。
- 相比synchronized,ReentrantLock增加了一些高级功能:
· 等待可中断
· 可实现公平锁
· 锁可以绑定多个条件 - 关于synchronized和ReentrantLock性能对比:
synchronized优化以前,性能比ReentrantLock差很多。JDK1.6 中加入了很多针对锁的优化措施(偏向锁,轻量级锁、自旋锁)后,synchronized与ReentrantLock的性能基本上是完全持平了。虚拟机在未来的性能改进中肯定也会更加偏向于原生的synchronized,所以还是提倡在synchronized能实现需求的情况下,优先考虑使用synchronized来进行同步。 - 互斥同步属于一种悲观的并发策略,所以synchronized和ReentrantLock都属于悲观锁。
悲观的并发策略,总是认为只要不去做正确的同步措施(例如加锁),那就肯定会出现问题,无论共享数据是否真的会出现竞争,它都要进行加锁(这里讨论的是概念模型,实际上虚拟机会优化掉很大一部分不必要的加锁)、用户态核心态转换、维护锁计数器和检查是否有被阻塞的线程需要唤醒等操作。
非阻塞同步(Non-Blocking Synchronization)
随着硬件指令集的发展,我们有了另外一个选择:基于冲突检测的乐观并发策略,通俗地说,就是先进行操作,如果没有其他线程争用共享数据,那操作就成功了;如果共享数据有争用,产生了冲突,那就再采取其他的补偿措施(最常见的补偿措施就是不断地重试,直到成功为止),这种乐观的并发策略的许多实现都不需要把线程挂起,因此这种同步操作称为非阻塞同步(Non-Blocking Synchronization)。
为什么非阻塞同步需要随着硬件指令集的发展才能进行呢?
因为上述的描述中,冲突检测和操作都需要具有原子性。
硬件保证一个从语义上看起来需要多次操作的行为只通过一条处理器指令就能完成,这类指令常用的有:
- 测试并设置(Test-ans-Set)
- 获取并增加(Fetch-and-Increment)
- 交换(Swap)
- 比较并交换(Compare-and-Swap,下文称CAS)
- 加载链接/条件存储(Load-Linked/Store-Conditional,下文称LL/SC)
其中,前面的三条是20世纪就已经存在于大多数指令集之中的处理器指令,后面的两条是现代处理器新增的,而且这两条指令的目的和功能是类似的。
CAS指令需要有3个操作数,分别是内存位置(在Java中可以简单理解为变量的内存地址,用V表示)、旧的预期值(用A表示)和新值(用B表示)。CAS指令执行时,当且仅当V符合旧预期值A时,处理器用新值B更新V的值,否则它就不执行更新。
在JDK1.5之后,Java程序才可以使用CAS操作,该操作由 sun.misc.Unsafe 类里面的 compareAndSwapInt() 和 compareAndSwapLong() 等几个方法包装提供。我们可以通过Java API 来间接使用它,如J.U.C 包里面的整数原子类(AtomicInteger等),其中的 compareAndSet() 和 getAndIncrement() 等方法都使用了Unsafe类的CAS操作。
无同步方案
同步只是保证共享数据争用时的正确性的手段,如果一个方法本来就不涉及共享数据,那它自然就无须任何同步措施去保证正确性。例如
- 可重入代码(Reentrant Code,也叫做纯代码Pure Code):可以在代码执行的任何时刻中断它,转而去执行另外一段代码(包括递归调用它本身),而在控制权返回后,原来的程序不会出现任何错误。
- 线程本地存储(Thread Local Storage):把共享数据的可见范围限制在同一个线程之内。
多线程环境解决方案及原理
《Thinking in Java》书中写到:
基本上所有的并发模式在解决线程冲突问题的时候,都是采用序列化访问共享资源的方案。
多线程环境会带来什么问题?
多线程环境需要重点关注和解决的典型问题:
互斥性问题。
(这里有一个问题,如果是分布式环境,又会遇到什么其他问题呢???幂等性问题和互斥性问题)
互斥性问题用通俗的话来讲,就是对共享资源的抢占问题。如果不同的线程对同一个或者同一组资源读取并修改时,无法保证按序执行,无法保证一个操作的原子性,那么就很有可能会出现预期外的情况。因此操作的互斥性问题,也可以理解为一个需要保证时序性、原子性的问题。
举个例子:
例1: 内存中记录关键数据X,当前值为100。A线程需要将X增加200;同时,B线程需要将X减100。 在理想的情况下,A先读取到X=100,然后X增加200,最后写入X=300。B线程接着从读取X=300,减少100,最后写入X=200。 然而在真实情况下,如果不做任何处理,则可能会出现:A和B同时读取到X=100;A写入之前B读取到X;B比A先写入等等情况。
解决这些问题的办法有哪些?
Java JDK中提供了两种互斥锁Lock和synchronized。不同的线程之间对同一资源进行抢占,该资源通常表现为某个类的普通成员变量。因此,利用ReentrantLock或者synchronized将共享的变量及其操作锁住,即可基本解决资源抢占的问题。
锁优化
HotSpot在JDK1.6 中实现的各种锁优化技术
自旋锁与自适应自旋
- 互斥同步对性能最大的影响是阻塞的实现,挂起线程和恢复线程的操作都需要转入内核态中完成,这些操作给系统的并发性能带来了很大的压力。
- 虚拟机的开发团队也注意到在许多应用上,共享数据的锁定状态只会持续很短的一段时间,为了这段时间去挂起和恢复线程并不值得。
为了让线程等待,我们只需让线程执行一个忙循环(自旋),这项技术就是所谓的自旋锁。自旋等待不能代替阻塞,且先不说对处理器数量的要求,自旋等待本身虽然避免了线程切换的开销,但它是要占用处理器时间的。如果锁被占用的时间很短,自旋等待的效果就会非常好;反之,如果锁被占用的时间很长,那么自旋线程只会白白消耗处理器资源,而不会做任何有用的工作,反而会带来性能上的浪费。
- 自旋锁在JDK1.4.2 中就已经引入,只不过默认是关闭的,可以使用 -XX:+UseSpinning参数来开启。
- 自旋等待的时间必须要有一定的限度,如果自旋超过了限定的次数仍然没有成功获得锁,就应当使用传统的方式去挂起线程。自旋次数的默认值是10次,可以使用 -XX:+PreBlockSpin 参数来更改。
- 自旋锁在JDK1.6中已经改为默认开启,同时引入了自适应的自旋锁。自适应意味着自旋的时间不再固定了,而是由前一次在同一锁上的自旋时间及锁的拥有者的状态来决定。
- 如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋很有可能再次成功,进而它将允许自旋等待持续相对更长的时间;如果对于某个锁,自旋很少成功获得过,那在以后要获取这个锁时将可能省略掉自旋过程。
锁消除
锁消除是指虚拟机即时编译器在运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除。
例子:
public String concatString(String s1,String s2,String s3){
return s1+s2+s3;
}
在JDK1.5 之前,对字符串的连接操作会转化为StringBuffer对象的连续append()操作,在JDK1.5 之后,会转化为StringBuilder对象的连续append()操作。每个StringBuffer.append()方法中都有一个同步块,锁的对象就是StringBuffer对象本身。因为StringBuffer对象本身的所有引用永远不会“逃逸”到concatString()方法之外,其他线程无法访问到它,因此,虽然这里有锁,但是可以被安全地消除掉,在即时编译之后,这段代码就会忽略掉所有的同步而直接执行了。
锁粗化
如果虚拟机探测到有这样一串零碎的操作都对同一个对象加锁,将会把加锁同步的范围扩展(粗化)到整个操作序列的外部。
接下来按照锁膨胀顺序来开始讲:
偏向锁
- 偏向锁的“偏”就是偏心的“偏”、偏袒的“偏”。 它的意思是这个锁会偏向于第一个获得它的线程,如果在接下里的执行过程中,该锁没有被其他的线程获取,则持有偏向锁的线程将永远不需要再进行同步。
- 假设当前虚拟机启用了偏向锁(启用参数-XX:+UseBiasedLocking,这是JDK1.6的默认值):
- 当锁对象第一次被线程获取的时候,虚拟机将会把对象头中的标志位设为“01”,即偏向模式;同时使用CAS操作把获取到这个锁的线程的ID记录在对象的Mark Word之中:
- 如果CAS操作成功,持有偏向锁的线程以后每次进入这个锁相关的同步块时,虚拟机都可以不再进行任何同步操作(例如Locking、Unlocking以及对Mark Word的Update等)。
- 如果CAS操作不成功,则说明有另外一个线程去尝试获取这个锁,偏向模式就宣告结束:
- 如果锁对象目前没有处于被锁定的状态,则撤销偏向后恢复到未锁定(标志位为“01”)状态。
- 如果锁对象目前处于被锁定的状态,则撤销偏向后恢复到轻量级锁定(标志位为“00”)状态。
- 当锁对象第一次被线程获取的时候,虚拟机将会把对象头中的标志位设为“01”,即偏向模式;同时使用CAS操作把获取到这个锁的线程的ID记录在对象的Mark Word之中:
偏向锁、轻量级锁的状态转化及对象Mark Word的关系如下图所示:
轻量级锁
- 轻量级锁是JDK1.6 之中加入的新型锁机制。“轻量级”是相对于使用操作系统互斥量来实现的传统锁而言的,因此传统的锁机制就称为“重量级”锁。
- 轻量级锁能提升程序同步性能的依据是:对于绝大部分的锁,在整个同步周期内都是不存在竞争的“。这是一个经验数据。
- 在代码进入同步块的时候,如果此同步对象没有被锁定(锁标志位为“01”),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝(官方把这份拷贝加了一个Displaced前缀,即Displaced Mark Word),然后虚拟机将使用CAS操作尝试将对象的Mark Word更新微指向Lock Record的指针。
- 如果CAS操作成功了,那么这个线程就拥有了该对象的锁,并且对象Mark Word的锁标志位将转变为“00”,即表示此对象处于轻量级锁定状态。
- 如果CAS操作失败了,虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧:
- 如果是,则说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行。
- 如果不是,则说明这个锁对象已经被其他线程抢占了,如果两条以上的线程争用同一个锁,那轻量级锁不再有效,要膨胀为重量级锁,锁标志的状态值变为“10”,Mark Word中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也要进入阻塞状态。
重量级锁
底层的操作系统的Mutex Lock实现,会阻塞等待锁的线程;要阻塞或唤醒一个线程,都需要操作系统来帮忙完成,这就需要从用户态转换到核心态中,频繁切换十分耗费处理器资源。
使用锁的优化思路
- 减少锁的持有时间。
- 减少锁粒度。
- 锁分离。
- 锁粗化。
- 锁消除。