目录
什么是线程安全&线程不安全
线程不安全就是不提供数据访问保护,有可能出现多个线程先后更改数据造成所得到的数据就是脏数据。
线程安全 就是多线程访问时,采用加锁机制,当一个线程访问该类某个数据时,进行保护,其他线程不能进行访问直到该线程读取完毕,其他线程才能使用。不会出现数据不一致或者数据污染。
线程不安全例子
package com.synchronizedtest;
import java.util.concurrent.CountDownLatch;
/**
* @author 卷心菜
* @version 1.0
* @date 2021/10/22 15:36
* @Description synchronized
* @motto 路漫漫其修远兮
*/
public class SynchronizedTest {
//最后total是多少?
private static int total = 0;
private static Object object = new Object();
public static void main(String[] args) throws InterruptedException {
CountDownLatch countDownLatch = new CountDownLatch(1);
for (int i = 0; i < 10; i++) {
new Thread(() ->{
try {
countDownLatch.await();
for (int j = 0; j < 1000; j++) {
total++;
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}).start();
}
Thread.sleep(1000);
countDownLatch.countDown();
Thread.sleep(2000);
System.out.println(total);
}
}
如何解决线程并发安全问题?
实际上,所有的并发模式在解决线程安全问题时,采用的方案就是序列化访问临界资源。即在同一时刻,只能有一个线程访问临界资源,也称做同步互斥访问。Java中,提供了两种方式来实现同步互斥访问: synchronized
和 Lock
。
package com.synchronizedtest;
import java.util.concurrent.CountDownLatch;
/**
* @author 卷心菜
* @version 1.0
* @date 2021/10/22 20:36
* @Description synchronized
* @motto 路漫漫其修远兮
*/
public class SynchronizedTest {
private static int total = 0;
private static Object object = new Object();
public static void main(String[] args) throws InterruptedException {
CountDownLatch countDownLatch = new CountDownLatch(1);
for (int i = 0; i < 10; i++) {
new Thread(() ->{
try {
countDownLatch.await();
for (int j = 0; j < 1000; j++) {
//加上synchronized同步块解决
synchronized (object){
total++;
}
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}).start();
}
Thread.sleep(1000);
countDownLatch.countDown();
Thread.sleep(2000);
System.out.println(total);
}
}
接下面详细说下synchronized
synchronized实现原理与应用
在多线程并发编程中synchronized一直是元老级角色,很多人都会称呼它为重量级锁。但是,随着Java SE 1.6对synchronized进行了各种优化之后,有些情况下它就并不那么重了。详细介绍Java SE 1.6中为了减少获得锁和释放锁带来的性能消耗而引入的偏向锁和轻量级
锁,以及锁的存储结构和升级过程。
synchronized加锁方式
package com.synchronizedtest;
/**
* @author 卷心菜
* @version 1.0
* @date 2021/10/24 9:12
* @Description
* @motto 路漫漫其修远兮
*/
public class Synchronized {
private Integer synchronizedLock;
public static void main(String[] args) {
Synchronized st = new Synchronized();
Synchronized st2 = new Synchronized();
new Thread(() -> {
System.out.println(Thread.currentThread().getName() + " test 准备进入");
st.test1();
// st.test2();
// st.test3();
// st.test4();
}).start();
new Thread(() -> {
System.out.println(Thread.currentThread().getName() + " test 准备进入");
st2.test1();
// st2.test2();
// st2.test3();
// st2.test4();
}).start();
}
/**
*修饰普通方法,锁住的是当前实例对象
*同一个实例调用会阻塞
* 不同实例调用不会阻塞
*/
private synchronized void test1(){
try {
System.out.println(Thread.currentThread().getName() + " test1 进入了同步方法");
Thread.sleep(5000);
System.out.println(Thread.currentThread().getName() + " test1 休眠结束");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
/**
* 同步代码块传参this,锁住的是当前实例对象
*/
private void test2(){
synchronized (this){
try {
System.out.println(Thread.currentThread().getName() + " test2 进入了同步方法");
Thread.sleep(5000);
System.out.println(Thread.currentThread().getName() + " test2 休眠结束");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
/**
* 同步代码块传参变量对象,锁住的是变量对象
*/
private void test3(){
synchronized (synchronizedLock){
try {
System.out.println(Thread.currentThread().getName() + " test3 进入了同步方法");
Thread.sleep(5000);
System.out.println(Thread.currentThread().getName() + " test3 休眠结束");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
/**
* 同步代码块传参class对象,全局锁
*/
private void test4(){
synchronized (this){
try {
System.out.println(Thread.currentThread().getName() + " test4 进入了同步方法");
Thread.sleep(5000);
System.out.println(Thread.currentThread().getName() + " test4 休眠结束");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
Monitor监视器锁
synchronized是基于内置锁实现,通过内部对象Monitor(监视器锁)实现,基于进入与退出Monitor对象实现方法与同步代码块,监视器的实现依赖底层操作系统,Mutex Lock(互斥锁)实现。synchronized关键字被编译成字节码后会被翻译成monitorenter 和 monitorexit 两条指令分别在同步代码块逻辑的起始位置和结束位置。
每一个同步对象都有一个自己的Monitor(监视器锁),加锁过程如下图所示:
Monitor监视器锁,任何对象都有一个Monitor与之关联,当且一个Monitor被持有后,它将处于锁定状态。synchronized在JVM里的实现都是基于进入和退出Monitor对象来实现方法同步和代码块同步,虽然实现细节不一样,但都可以通过成对的MonitorEnter和MonitorExit指令来实现的。
-
monitorenter:
-
每个对象都是一个监视器锁(monitor)。当monitor被占用时就会处于锁定状态,线程执行monitor指令时尝试获取monitor的所有权,过程如 下:
- a 如果monitor的进入数为0,则该线程进入monitor,然后将该进入数设置为1,该线程即为monitor的所有者;
- b 如果线程已经占有monitor,只是重新进入,则进入monitor的进入数+1;
- c 如果其他线程已经占有monitor,则该线程进入阻塞状态,直到monitor的进入数为0,再次重新尝试获取monitor所有权;
-
monitorexit:
- 执行monitorexit的线程必须是objectref所对应的monitor的所有者,指令执行时,monitor的进入数-1,如果-1之后进入数为0,那么线程退出monitor,不再是这个monitor的所有者。其他被这个monitor阻塞的线程可以重新尝试获取这个monitor的所有权。
- 指令出现了两次,第一次位同步正常退出释放锁,第二次为放生异常退出释放锁
通过上面描述,应该能清楚的看出synchronized的实现原理,synchronized的语义底层是通过一个monitor对象来完成的,其实wait/notify等方法也依赖于monitor对象,这就是为什么只有在同步块或方法中才能调用wait/notify等方法,否则抛出java.lang.IllegalMonitorStateException的异常的原因。
看下同步方法反编译结果
/**
* @author 卷心菜
* @version 1.0
* @date 2021/10/24 10:46
* @Description
* @motto 路漫漫其修远兮
*/
public class test {
public synchronized void test1(){
System.out.println("同步方法");
}
}
反编译结果
从编译结果来看,方法的同步并没有通过monitorenter和monitorexit 开完成(理论上其实也可以通过这两条指令来实现)不过相对于普通的方法,其实常量池中多了ACC_SYNCHRONIZED 标识符。JVM就是根据这个标识符来实现的方法同步。
当方法调用时,调用指令将会检查方法的ACC_SYNCHRONIZED访问标志是否被设置,如果设置了,执行该线程,获取成功之后才会执行方法体,方法体执行完后在释放monitor。
两种同步方式本质上没有什么区别,只是方法的同步是一种隐式的方式来实现的,无需通过字节码来完成。两个指令的执行是JVM通过调用操作系用的互斥原语mutex来实现的,被阻塞的线程会被挂起,等待重新被调用,会导致,用户态和内核态 之间的来回切换,这样会对性能有较大影响。
我们知道了synchronized加锁是在对象上,对象是如何记录锁的状态?
锁的状态是被记录在每个对象的对象头
对象的内存布局
Hotspot虚拟机中,对象在内存中存储的布局可以分为三块区域:对象头、实例数据、对齐填充
- 对象头: 比如hash码,对象锁、锁的状态标识、偏向锁ID,偏向时间、数组长度(数组对象)、分代年龄等。Java对象头一般占有2个机器码(在32位虚拟机中,1个机器码等于4字节,也就是32bit,在64位虚拟机中,一个机器码是8字节,也就是64bit),但是如果对象是数组类型,则需要3个机器码,因为JVM虚拟机可以通过Java对象的元数据信息确定Java对象的大小,但是无法从数组的元数据来确认数组的大小,所以用一块记录数组的长度。
- 实例数据: 存放类的属性数据信息,包括父类的属性信息;
- 对齐填充: 由于虚拟机要求 对象起始地址必须是8字节的整数倍。填充数据不是必须存在的,仅仅为了字节对齐。
对象头
HospSpot虚拟机的对象头包括两部分信息:第一部分是”Mark Word“,用于存储对象自身的运行时数据,如哈希码、GC分代年龄、锁的状态标志、线程持有的锁、偏向线程ID、偏向时间戳等,它是实现偏向锁和轻量级锁的关键,这部分数据的长度在32位和64位的虚拟机(暂不考虑开启指针压缩的场景
)中分别为32和64个Bits,官方称为:“Mark Word”。对象需要存储的运行时数据很多,其实已经超出了32、64位Bitmap结构所能记录的限度,但是对象头信息是与对象自身定义的数据无关的额外存储成本,考虑到虚拟机空间的效率,Mark Word被设计成一个非固定的数据解结构以便在极小的空间内存存储尽量最多的信息。
Mark Word存储数据会随着程序的运行发生变化
变化状态如下:
32位虚拟机:
Mark Word的32个Bits空间中的25Bits用于存储对象哈希码(HashCode),4Bits用于存储对象分代年龄,2Bits用于存储锁标志位,1Bit固定为0,在其他状态(轻量级锁定、重量级锁定、GC标记、可偏向)下对象的存储内容如上表所示
锁的膨胀升级过程
Java1.6为了减少获取锁和释放锁带来的性能消耗,引入了“偏向锁”、“轻量级锁”,在Java1.6中,锁一共有4种状态,级别从低到高依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,**这几种状态会随着竞争情况逐渐升级。锁可以升级但不能降级,意味着偏向锁升级成轻量级锁后不能降级成偏向锁。**这种锁升级却不能降级的策略,目的为了避免锁的升级降级带来的性能消耗。
偏向锁
偏向锁是java 1.6之后加入的新锁,它是一种针对加锁操作的优化手段,HotSpot的作者经过研究发现,大多数情况下,锁不仅不存在多线程竞争,而且总是有同一个线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁。
偏向锁的核心思想是,如果一个线程获得了锁,那么锁就进入偏向模式,此时Mark Word的结构也会变为偏向锁结构,并会在对象头和栈帧中记录存储偏向锁的线程ID,以后该线程再进入和退出同步块时不需要进行CAS操作来加锁和解锁,只需要简单的测试下对象头的Mark Word是否存储着指向当前线程的偏向锁。如果测试成功,表示线程已经获得了锁。如果测试失败,则需要再测试一下Mark Word中偏向锁的标识是否设置成1(表示当前是偏向锁):如果没有设置,则使用CAS竞争锁;如果设置了,则尝试使用CAS将对象头的偏向锁指向当前线程。
偏向锁的撤销
偏向锁使用了一种等到竞争出现才释放锁的机制,所以当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有正在执行的字节码)。它会首先暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否还活着,如果线程不处于活动状态,则将对象头设置成无锁状态;如果线程仍然活着,拥有偏向锁的栈会被执行,遍历偏向锁对象的锁记录,栈中的锁记录和对象头的MarkWord要么重新偏向其他线程,要么恢复到无锁或者标记对象不适合作为偏向锁,最后唤醒暂停的线程。
轻量级锁
对于没有锁竞争的场合,偏向锁有很好的优化效果,毕竟极有可能连续多次是同一个线程申请相同的锁。但是对于锁竞争比较激烈的场合,偏向锁就失效了。因为这样的场合极有可能每次申请锁的线程都不是相同的,因此这种场合不应该使用偏向锁,需要注意的是,偏向锁失败后,并不会立即膨胀为重量级锁,而是先升级为轻量级锁。
倘若偏向锁失败,虚拟机不会立即升级为重量级锁。它还会尝试使用轻量级锁优化的手段,此时Mark Word的结构也变为轻量级锁的结构。轻量级锁能够提升程序性能的依据是“对大部分的锁,在整个同步周期内都不存在竞争”。需要了解的是,轻量级锁适用的场景是线程交替执行同步块的场合,如果存在同一时间访问同一锁的场合,就会导致轻量级锁膨胀为重量级锁。
轻量级锁加锁
线程在执行同步块之前,JVM会现在当前线程的栈帧中创建用于存储锁记录的空间,并将对象头的Mark Word复制到锁记录中,然后线程尝试使用CAS将对象头中的Mark Word替换为指向锁记录的指针。如果成功,但前线程获得锁,如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁。
轻量级锁解锁
轻量级解锁时,会使用原子的CAS操作Displaced Mark Word替换回到对象头,如果成功,则表示没有发生竞争。如果失败,表示当前锁存在竞争,锁就会膨胀成重量级锁。
锁自旋
轻量级锁失败后,虚拟机为了避免线程真实的在操作系统层面挂起,还会进行一项称为自旋的优化手段。这就是基于大多数情况下,线程持有锁的时间都不会太长,如果直接挂起操作系统层面的线程可能会得不偿失,毕竟操作系统实现线程之间的切换时需要从用户态转换到内核态,这个状态之间的转换需要相对较长的时间,时间成本相对较高,因此自旋锁会假设在不久将来,当前的线程可以获取到锁,因此虚拟机会让当前想要获取锁的线程做几个空循环,一般不会太久,可能时50个循环或100个循环,在经过若干次循环后,如果得到锁,就顺利进入临界区。如果还不能获取锁,那就将线程在操作系统层面挂起,这就是自旋锁的优化方式,这种方式确实也可以提升效率。最后没办法也就只能升级为重量级锁了。
锁的其他优化
锁的消除
消除锁是虚拟机另一种的优化,这种优化更彻底,Java虚拟机在JIT编译时(可以简单的理解为某段代码第一次被执行时进行编译,又称为即时编译),通过对运行上下文的扫描,去除不可能存在共享资源竞争的锁,通过这种方式消除没有必要的锁,可以节省毫无意义的请求锁时间。如以下代码,object1不可能存在共享资源竞争的情景,JVM会自动将锁消除掉。 锁消除的依据是逃逸分析的数据支持。
锁消除,前提是Java必须运行在server模式下(server会比client模式做更多的优化),同时开启逃逸分析。
public class test {
private void test2(){
Object object1 = new Object();
synchronized (object1){
System.out.println();
}
}
}
锁的粗化
如下代码:
public class test {
Object object = new Object();
private void test() {
synchronized (object) {
System.out.println("");
}
synchronized (object) {
System.out.println("");
}
synchronized (object) {
System.out.println("");
}
}
}
当一个线程来执行代码块时:
- 获取对象锁,打印一句话,再释放锁
- 在执行下边加锁,再是释放锁
- 在执行下边加锁,再是释放锁
而且加的都是同一个对象锁,JVM就会进行优化成以下代码
public class test {
Object object = new Object();
private void test() {
synchronized (object) {
System.out.println("");
System.out.println("");
System.out.println("");
}
/* synchronized (object) {
System.out.println("");
}
synchronized (object) {
System.out.println("");
}*/
}
}
逃逸分析
使用逃逸分析,编译器可以对代码做如优化:
- 同步省略,如果一个对象被发现只能从一个线程访问到,那么对于这个对象的操作可以不考虑同步。
- 将堆分配转化为栈分配。如果一个对象在子程序中被分配,要使指向对象的指针永远不会逃逸,对象可能是栈分配的候选,而不是堆分配。
- 分离对象或标量替换,有的对象可能不需要作为一个连续的内存结构存在,也可以被访问到,那么对象的部分(或全部)可以不存储在内存,而是存储在CPU寄存器中。
所以,所有的对象和数组 不一定 都会在堆内存分配空间
在Java代码运行时,通过JVM参数可指定是否开启逃逸分析, -XX:+DoEscapeAnalysis
: 表示开启逃逸分析 -XX:-DoEscapeAnalysis
: 表示关闭逃逸分析。从jdk 1.7开始已经默认开启逃逸分析,如需关闭,需要指定-XX:-DoEscapeAnalysis
如下逃逸分析例子:
package com.synchronizedtest;
/**
* @author 卷心菜
* @version 1.0
* @date 2021/10/24 16:36
* @Description
* @motto 路漫漫其修远兮
*/
public class T0_ObjectStackAlloc {
public static void main(String[] args) {
/**
* 进行两种测试
* 关闭逃逸分析,同时调大堆空间,避免堆内GC的发生,如果有GC信息将会被打印出来
* VM运行参数:-Xmx4G -Xms4G -XX:-DoEscapeAnalysis -XX:+PrintGCDetails -XX:+HeapDumpOnOutOfMemoryError
*
* 开启逃逸分析
* VM运行参数:-Xmx4G -Xms4G -XX:+DoEscapeAnalysis -XX:+PrintGCDetails -XX:+HeapDumpOnOutOfMemoryError
*
* 执行main方法后
* jps 查看进程
* jmap -histo 进程ID
*
*/
//开始时间
long start = System.currentTimeMillis();
for (int i = 0; i < 500000; i++) {
alloc();
}
long end = System.currentTimeMillis();
//执行时间
System.out.println("执行时间:"+(end - start)+ "ms");
try {
Thread.sleep(100000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
private static Student alloc(){
return new Student();
}
static class Student{
private String name;
private int age;
}
}
当关闭逃逸分析时,堆空间会创建500000对象
开启逃逸分析时,堆只创建了15万