文章目录
- JUC并发
- synchronized
- 锁对象
- 底层原理
- synchronized锁升级
- 无锁
- 偏向锁
- 轻量级锁
- 重量级锁
- JIT对锁的优化:锁消除和锁粗化
- reentrantlock
- 公平锁和非公平锁
- 可重入锁 / 递归锁
- 死锁
- 死锁产生条件
- 如何排查死锁?如果解决死锁?
- LockSupport与中断机制
- 中断机制
- 中断相关的三大API
- 如何中断运行中的线程?
- LockSupport
- LockSupport原理与优势
- JMM
- happens-before(先行发生原则)
- CAS
- Unsafe
- ThreadLocal
- 基本使用
- 使用规范
- Thread、ThreadLocal、ThreadLocalMap三者关系?
- ThreadLocalMap的Entry的key为啥使用threadlocal的弱引用?弱引用被GC了会怎么样?
- 对象内存布局和对象头
- 对象头
- 类元信息
- AQS
- state与CLH队列
- AQS自身属性与Node节点
- ReentrantLock实现原理
- 公平与非公平是咋实现的?
JUC并发
synchronized
锁对象
synchronized 有三种应用场景:
- 作用于非静态实例方法:对当前实例加锁,进入同步方法前,需获得当前实例的锁,此时它是一个
对象锁
。 - 作用于静态实例方法:对实例所属类加锁,进入静态同步方法前,需要获得当前类对象的锁,此时它是一个
类锁
。 - 作用于代码块:对括号中配置的对象加锁。
底层原理
- 1、管程(英语:
monitors
,也称为监视器)是一种程序结构,结构内的多个子程序(对象或模块〉形成的多个工作线程互斥访问共享资源。这些共享资源一般是硬件设备或一群变量。对共享变量能够进行的所有操作集中在一个模块中。(把信号量及其操作原语“封装”在一个对象内部) 管程实现了在一个时间点,最多只有一个线程在执行管程的某个子程序。管程提供了一种机制,管程可以看做一个软件模块,它是将共享的变量和对于这些共享变量的操作封装起来,形成一个具有一定接口的功能模块,进程可以调用管程来实现进程级别的并发控制。 - 2、Java中每个对象都继承自
Object
,而每个Object 的对象都关联一个monitor
,该monitor在C语言底层中对应一个ObjectMonitor
结构体,有_owner
属性记录持有该对象的线程,有_count
属性,记录该线程获取monitor锁的次数,同一时间只有一个线程能够持有对象的monitor
,这是Java中所有对象都可以成为锁对象,且可以重复获取锁的基础。
- 3、字节码标识:
- synchronized同步代码块实现使用的是
monitorenter
和monitorexit
指令来标识获取到对象的monitor
和释放对象的monitor
,每次monitorenter
进入,锁重入次数加1,每次monitorexit
退出,锁重入次数减1。 - synchronized同步方法底层使用
ACC_SYNCHRONIZED
访问标志,标识同步方法,并使用ACC_STATIC
区分该方法是否静态同步方法,即有ACC_SYNCHRONIZED
和ACC_STATIC
标识的是静态同步方法,只有ACC_SYNCHRONIZED
标识的是普通同步方法。静态同步方法会持有类对象的monitor
,而普通同步方法会持有实例对象的monitor
.
- synchronized同步代码块实现使用的是
synchronized锁升级
锁会带来并发时的性能下降,Java8对synchd锁关键字做了锁升级的优化。
对象锁升级过程:
- 无锁(锁标志01,且是否偏向锁标志位为0)
- 偏向锁(锁标志01,且是否偏向锁标志位为1),前54位指向当前线程id
- 轻量级锁(锁标志位为00),前62位指向线程栈中Lock Record的指针
- 重量级(自旋)锁(锁标志位为10),前62位指向堆中的monitor对象的指针。
- GC标志(锁标志位为11)
无锁
前25位unused,全0
后31位,存放哈希码,仅当有调用的时候才生成
1位unused
4位分代年龄,因此分代年龄最大只能是15,因为对象头中只有4个bit记录。
偏向锁标志位,无锁为0
锁标志位01.
偏向锁
当线程A第一次竞争到锁,通过操作修改MarkWord中的偏向锁线程ID、偏向锁标志位,使其从无锁状态升级为偏向锁。
如果不存在其他线程竞争,持有偏向锁的线程将永远不需要进行同步。
偏向锁的出现是为了解决在一个线程执行同步时,提高性能,线程的id被记录到对象头,如果id符合,则不需要从用户态切换到内核态去进行加锁解锁的过程。
- 当非偏向线程id的线程尝试获取锁:
- 如果没有发生竞争,即偏向线程已经退出来同步代码块,非偏向线程才进入,那么会将对象头设置为无锁状态,并撤销偏向锁,重新偏向为当前线程。
- 如果发生了竞争,即偏向线程还未退出同步代码块,那么出现锁升级为轻量级锁,正在竞争的线程会进入自旋状态,等待获得该轻量级锁。
- 如果对象处在已经偏向状态,且又调用了hashcode()方法,它的偏向状态会被撤销,并膨胀为重量级锁,对象头的前62位指向重量级锁的位置,其中存储有原来的哈希码。
- 如果在偏向锁状态调用了wait()方法,那么偏向锁也会直接升级为重量级锁,因为wait()方法也是重量级锁独有的
由于偏向锁的维护成本比较高,Java15废除了偏向锁。最终就是,JDK 15 之前,偏向锁默认是 enabled,从 15 开始,默认就是 disabled,除非显示的通过 UseBiasedLocking 开启
轻量级锁
轻量级锁:多线程竞争,但是任意时刻最多只有一个线程竞争,即不存在锁竞争太过激烈的情况,也就没有线程阻寒,轻量级锁本质是自旋锁。
自旋锁自旋的有最大的自旋次数,如果自旋达到一定次数,会将自旋锁升级为重量级锁,自旋次数取决于自适应自旋锁机制。自适应自旋锁的大致原理:
- 线程如果自旋成功了,那下次自旋的最大次数会增加,因为JVM认为既然上次成功了,那么这一次也很大概率会成功。
- 反之如果很少会自旋成功,那么下次会减少自旋的次数甚至不自旋,避免CPU空转。
重量级锁
重量级锁,对象头的前62位指向互斥量。
JIT对锁的优化:锁消除和锁粗化
JIT即时编译器(JIT compiler,just-in-time compiler)
下边的代码,每次new一个对象,对其加锁,实际上加锁完全无用,JIT会对它优化,发生锁消除。
锁粗化就是加大锁的锁定范围,如下边的代码,多个同步代码块相连,锁对象相同,JIT编译器会执行锁粗化,用一个更大范围的synchronized,代替多个同步代码块,避免多次加锁、释放锁。
reentrantlock
公平锁和非公平锁
reentrantlock默认是非公平锁,主要有以下两点考虑:
- 恢复挂起的线程到真正锁的获取还是有时间差的,从开发人员来看这个时间微乎其微,但是从CPU的角度来看,这个时间差存在的还是很明显的。所以非公平锁能让真正运行的线程先获取到锁,更充分的利用CPU的时间片,尽量减少CPU空闲状态时间。
- 使用多线程很重要的考量点是线程切换的开销,当采用非公平锁时,当1个线程请求锁获取同步状态,然后释放同步状态,所以刚释放锁的线程在此刻再次获取同步状态的概率就变得非常大,所以就减少了线程的开销。
如果为了更高的吞吐量,很显然非公平锁是比较合适的,因为节省很多线程切换时间,吞吐量自然就上去了。否则那就用公平锁,大家公平使用。
可重入锁 / 递归锁
可重入锁的优点在于,可以在一定程度上避免死锁,一个加了锁的方法,如果递归调用自身,此时使用不可重入锁,就会自己把自己锁住。
死锁
死锁产生条件
- 存在互斥访问的锁
- 已经获取锁不可被强行剥离
- 没有完全获取到所需要的锁时,会持有锁并等待。
- 存在循环等待锁释放
举一个MySQL中,由于两个事务的间隙锁互相兼容,而插入意向锁与间隙互斥导致死锁的案例:
- time1阶段:事务A加间隙锁,范围(20, 30)
- time2阶段:事务B加间隙锁,范围(20, 30)
- time3阶段:事务A尝试给id为25的位置加插入意向锁,但是发现事务B在(20,30)间设置了间隙锁,加锁失败,阻塞,等待事务B释放间隙锁。
- time4阶段:事务B尝试给id为26的位置加插入意向锁,但是发现事务A在(20,30)间设置了间隙锁,加锁失败,阻塞,等待事务A释放间隙锁。
事务A和事务B相互等待对方释放锁,满足了死锁的四个条件:互斥、占有且等待、不可强占用、循环等待,因此发生了死锁。
如何排查死锁?如果解决死锁?
命令行版:
- jps -l
列出所有Java进程号,找到发生死锁的进程,假如是1123 - jstack 1123
查看进程栈信息,就可以定位到死锁的情况
图形工具版:
使用jconsole工具,也可以定位正在运行的Java进程的死锁情况。
LockSupport与中断机制
中断机制
- 一个线程不应该由其他线程来强制中断或停止,而是应该山线程自己自行停止,自己来决定自己的命运。
- 在Java中没有办法立即停止一条线程,然而停止线程却非常重要,如取消一个耗时操作。因此,Java提供了一种用于停止线程的协商机制——中断,也即中断标识协商机制。
- 中断只是一种协作协商机制,Java没有给中断增加任何语法,中断的过程完全需要程序员自己实现。若要中断一个线程,你需要手动调用该线程的interrupt方法,该方法也仅仅是将线程对象的中断标识设成true;接着你需要自己写代码不断地检测当前线程的标识位,如果为true,表示别的线程请求这条线程中断,此时究竟该做什么需要你自己写代码实现。
- 每个线程对象中都有一个中断标识位,用于表示线程是否被中断;该标识位为true表示中断,为false表示未中断;通过调用线程对象的interrupt方法将该线程的标识位设为true;可以在别的线程中调用,也可以在自己的线程中调用。
中断相关的三大API
- interrupt():
- 当一个线程调用interrupt()时,如果线程处于正常活动状态,那么会将该线程的中断标志设置为true,仅此而已,被设置中断标志的线程将继续正常运行,不受影响。
即interrupt()仅是设置中断标识,并不实际中断线程,需要被调用的线程进行配合。
就像你让一个人闭嘴,但最终那人是否闭嘴,需要它的配合。 - 如果线程处于
被阻塞的状态
(例如sleep、wait、join等状态),在别的线程中调用当前线程对象的interrupt()方法,那么线程将立即退出被阻塞状态,中断状态标志位将被清除,并抛出一个InterruptedException异常。
- 对于不活动的线程,调用interrupt()没有任何影响。
- 当一个线程调用interrupt()时,如果线程处于正常活动状态,那么会将该线程的中断标志设置为true,仅此而已,被设置中断标志的线程将继续正常运行,不受影响。
- public boolean isInterrupted():检测当前实例的中断标志位是否被设置为中断。
- public static boolean interrupted():静态方法,会调用当前线程的isInterrupted(),并传入参数,清除中断标志位。
从源码中可以看到,实例方法默认是不清除中断状态的,而静态方法则会。
如何中断运行中的线程?
- 通过volatile关键字,进行线程间通信,线程每次都会读取volatile关键字最新的值。
public class demo1 {
private static volatile boolean isStop;
public static void main(String[] args) {
new Thread( () -> {
while(!isStop) {}
System.out.println(Thread.currentThread().getName() + "收到停止!");
},"A").start();
try {
TimeUnit.SECONDS.sleep(2);
} catch (Exception e) {
e.printStackTrace();
}
new Thread(() -> {
System.out.println(Thread.currentThread().getName() + "发送停止信号!");
isStop = true;
}, "B").start();
}
}
- 通过AtomicBoolean 原子布尔型,
public class demo1 {
private static volatile boolean isStop;
private static AtomicBoolean flag = new AtomicBoolean(false);
public static void main(String[] args) {
new Thread( () -> {
// while(!isStop) {}
while(!flag.get()) {}
System.out.println(Thread.currentThread().getName() + "收到停止!");
},"A").start();
try {
TimeUnit.SECONDS.sleep(2);
} catch (Exception e) {
e.printStackTrace();
}
new Thread(() -> {
System.out.println(Thread.currentThread().getName() + "发送停止信号!");
// isStop = true;
flag.set(true);
}, "B").start(
);
}
}
- 通过线程自带的中断API,线程2中使用线程1的interrupt()方法,而线程1中使用isInterrupted()方法来监听。
public class demo1 {
private static volatile boolean isStop;
private static AtomicBoolean flag = new AtomicBoolean(false);
public static void main(String[] args) {
Thread t1 = new Thread( () -> {
// while(!isStop) {}
// while(!flag.get()) {}
while (!Thread.currentThread().isInterrupted()) {}
System.out.println(Thread.currentThread().getName() + "收到停止!");
},"A");
t1.start();
try {
TimeUnit.SECONDS.sleep(2);
} catch (Exception e) {
e.printStackTrace();
}
new Thread(() -> {
System.out.println(Thread.currentThread().getName() + "发送停止信号!");
// isStop = true;
// flag.set(true);
t1.interrupt();
}, "B").start(
);
}
}
LockSupport
synchronized和reentrantlock的线程等待和线程唤醒有如下局限性:
- 对象的wait()和notify()方法必须要在同步代码块或者同步方法里边运行,且成对出现必须先wait后notify才ok。
- reentrantlock构建的condition的await()方法和signal()方法必须先获取到reentrantlock锁,才可以使用,且成对出现,必须先await()后signal();
基于synchronized和reentrantlock实现三个线程交替打印A、B、C:
package Interrupt;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class demo1 {
public static void main(String[] args) {
Data d = new Data();
for (int i = 0; i < 3; ++i) {
final int temp = i;
new Thread( () -> {
for (int j = 0; j < 10; ++j) {
d.print2(temp, (char) ('A' + temp));
}
}).start();
}
}
}
class Data {
private int flag = 0;
private final Object lock = new Object();
private final Lock lk = new ReentrantLock();
private final Condition cd = lk.newCondition();
void print(int i, char letter) {
synchronized (lock) {
try {
while (i != flag) lock.wait();
System.out.println(letter);
flag = (flag + 1) % 3;
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.notifyAll();
}
}
}
void print2(int i, char letter) {
lk.lock();
new Object();
try {
while (i != flag) cd.await();
System.out.println(letter);
flag = (flag + 1) % 3;
cd.signalAll();
} catch (Exception e) {
e.printStackTrace();
} finally {
lk.unlock();
}
}
}
LockSupport原理与优势
- LockSupport是concurrent包中一个工具类,不支持构造,提供了一堆static方法,比如park(),unpark()等。
- LockSupport提供park()和unpark()方法实现阻塞线程和解除线程阻塞的过程LockSupport和每个使用它的线程都有一个许可(permit)关联。每个线程都有一个相关的permit, permit最多只有一个,重复调用unpark也不会积累凭证。
- 当调用park():
- 如果有凭证,直接消耗掉这个凭证,然后正常退出。
- 如果无凭证,就必须阻塞等待凭证可用。
- 当调用unpark():
- 给指定线程增加一个凭证,但凭证最多只能有一个,不可累加。
LockSupport优势在于灵活性:
- LockSupport的park()和unpark()方法不需要在同步代码块内,或者持有锁。
- unpark()可以先于park()方法调用,不用担心线程间的执行的先后顺序,因为如果先执行unpark(),为指定线程提前赋予了凭证,那么该线程在调用park()时,直接低效已有的凭证。
下边是一个主线程等待子线程任务执行完毕,再唤醒主线程的例子:
注意在使用时,LockSupport的unpark()方法需要指定线程,因此第一步,我们需要获取到主线程md
public class demo1 {
public static void main(String[] args) {
Thread mt = Thread.currentThread();
new Thread(() -> {
try {
TimeUnit.SECONDS.sleep(2);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
System.out.println("子线程执行完毕。去唤醒主线程");
LockSupport.unpark(mt);
}
}).start();
System.out.println("等待子线程执行完毕");
LockSupport.park();
System.out.println("主线程被唤醒");
}
}
JMM
JMM(Java内存模型Java Memory Model,简称JMM)本身是一种抽象的概念并不真实存在它仅仅描述的是一组约定或规范,通过这组规范定义了程序中(尤其是多线程)各个变量的读写访问方式并决定一个线程对共享变量的写入何时以及如何变成对另一个线程可见,关键技术点都是围绕多线程的原子性、可见性和有序性
展开的。
happens-before(先行发生原则)
CAS
CAS是一条CPU的原子指令(cmpxchg指令),不会造成所谓的数据不一致问题,Unsafe提供的CAS方法(如compareAndSwapXXX)底层实现即为CPU指令cmpxchg
。
执行cmpxchg指令的时候,会判断当前系统是否为多核系统,如果是就对总线加锁,只有一个线程能加速成功,加锁成功之后会执行cas操作,也就是说CAS的原子性实际上是CPU实现独占的,比起用synchronized重量级锁,这里的排他时间要短很多,所以在多线程情况下性能会比较好。
Unsafe
Unsafe是CAS核心类,由于Java无法直接访问底层系统,需要通过本地(native)方法来访问,Unsafe类相当于一个后门,其内部方法都是native修饰的,可以像C的指针一样直接操作内存。
Unsafe类的compareAndSwapObject,相对于Java中调用的函数,多个两个与内存操作相关的属性,var1和var2,var1表示要操作的对象,而var2表示操作对象属性地址的偏移量。
以unsafe类实现的原子类Int为例:
当compareAndSwap()执行失败时,会陷入循环,即自旋,仅当执行成功时,跳出循环。
ThreadLocal
基本使用
ThreadLocal提供线程局部变量。这些变量与正常的变量不同,因为每一个线程在访问ThreadLocal实例的时候(通过其get或set方法)都有自己的、独立初始化的变量副本。ThreadLocal实例通常是类中的私有静态字段,使用它的目的是希望将状态(例如,用户ID或事务ID)与线程关联起来。
常用方法:
设置初始化方法:
protected T initialValue()
每个线程在第一次访问这个值的get()方法,会返回这个初始化方法的结果。
JDK8,可以用withInitial() 简化上边的写法:
案例:多线程模拟买票统计
public class demo1 {
public static void main(String[] args) {
SaleData sd = new SaleData();
for (int i = 0; i < 5; ++i) {
new Thread(() -> {
int j = Math.abs(new Random().nextInt()) % 20;
while (j-- != 0) {
sd.sale();
}
System.out.println(Thread.currentThread().getName() + "卖出:" + sd.saleNum.get());
}).start();
}
}
}
class SaleData {
ThreadLocal<Integer> saleNum = ThreadLocal.withInitial(() -> 0);
public void sale() {
saleNum.set(saleNum.get() + 1);
}
}
使用规范
每个线程都有自己的线程栈,栈空间大小是有效的,因此必须回收自定义的ThreadLocal变量,尤其在线程池场景下,线程经常会被复用,如果不清理自定义的ThreadLocal变量,造成ThreadLocal变量累积,可能会影响后续业务逻辑和造成内存泄露问题。
尽量在try - finally 块中,try中使用,finally中进行remove清除。
public class demo1 {
public static void main(String[] args) {
ExecutorService threadPool = Executors.newFixedThreadPool(10);
SaleData sd = new SaleData();
try {
for (int i = 0; i < 10; i++) {
threadPool.submit(() -> {
int r = Math.abs(new Random().nextInt() % 15);
for (int j = 0; j < r; ++j) {
sd.sale();
}
System.out.println(Thread.currentThread().getName() + "卖出:" + sd.saleNum.get());
});
}
} finally {
// 必须在用完后remove
sd.saleNum.remove();
threadPool.shutdown();
}
}
}
class SaleData {
ThreadLocal<Integer> saleNum = ThreadLocal.withInitial(() -> 0);
public void sale() {
saleNum.set(saleNum.get() + 1);
}
}
Thread、ThreadLocal、ThreadLocalMap三者关系?
Thread中有一个ThreadLocalMap的成员,
ThreadLocalMap的每个Entry以ThreadLocal实例的弱引用为key,任务对象为value。
ThreadLocalMap的Entry的key为啥使用threadlocal的弱引用?弱引用被GC了会怎么样?
假设我们在一个方法中,设置一个threadlocal变量t1存入到Thread上,该变量是一个强引用,而在存放到ThreadlocalMap上的是一个弱引用。
public void func1() {
ThreadLocal<String> t1 = new ThreadLocal<>();
t1.set("1111");
}
为什么使用弱引用?
当func1执行完毕,栈帧销毁,那么强引用t1也就没有了。但是此时线程的ThreadLocalMap里某个entry的key引用还指向这个对象,若这个key引用就是弱引用,就会导致key指向的threadlocal对象及指向的对象不能被gc回收,导致内存泄露。
而使用弱引用,每次gc时都会找到该弱引用对象,就不会发生内存泄露。
当gc发生后,存在ThreadlocalMap中的Entry的key会变为null,那么其对应的value就无法被访问,为解决该问题。threadlocal的get()、set()、remove方法,都会寻找key为null的脏Entry,然后对它进行删除。
对象内存布局和对象头
对象在堆内存中的存储布局可以划分为三个部分:对象头(Header)、实例数据( Instance Data)和对齐填充(Padding) 。
对齐填充的目的是为了保证8个字节的倍数。
数组对象比Java对象在对象头会多个记录数组长度的字段。
对象头
对象头分为两部分:
- 对象标记(Mark Word)
- 哈希码
- GC标记
- GC次数
- 同步锁标记
- 偏向锁持有者
- 类型指针( Class Pointer):指向实例在方法区的类元信息。
在64位系统中,对象头中的对象标记(Mark Word)占用8个字节,类型指针占了8个字节,一共是16个字节。
Mark Word 默认存储对象的哈希code、分代年龄和锁标志位等信息,这些信息都是与对象自身定义无关的数据,所有MarkWord 被设计成一个非固定的数据结构,以便在极小的空间内存储尽量多的数据,它会根据对象的状态复用自己的存储空间,也就是说再运行期间MarkWord里的存储数据会随着的锁的标志位的变化而变化
对象分代年龄使用4个bit位,最大是15
类元信息
类元信息在类加载的时候创建在元空间。(元空间在Java8以后的概念,用于存储类元信息、静态变量、常量池等)
每个实例对象都有指向它的类元信息的指针class pointer,虚拟机通过这个指针来确定这个对象是哪个类的实例。
AQS
AQS(AbstractQueuedSynchronizer,抽象队列同步器)整体就是一个抽象的FIFO队列,来完成资源获取线程的排队工作,并通过一个int类型的state表示持有锁的状态。
每次尝试持有锁,检查state是否为0,如果为0,则将state修改为1,表示持有了锁。
AQS是JUC的实现基石,JUC中的ReentrantLock、CountDownLatch、ReentrantReadWriteLock、Semaphore都是基于AQS实现的。
锁面向是锁的使用者,而AQS(抽象队列同步器)面向的是锁的设计者。
state与CLH队列
state变量是一个volatile修饰的int变量,如果为0,表示空闲,如果大于等于1,则表明锁被占用。
CLH队列:CLH是三个大牛名字缩写,队列本质是一种双端队列。
AQS自身属性与Node节点
ReentrantLock实现原理
公平与非公平是咋实现的?
ReentrantLock中维护了Sync,FairSync,NonFairSync3个内部类。
调用Reentrant的lock方法时,实际上是调用sync的lock方法,sync进而调用fairSync或者NonFairSync的lock方法。
这里可以发现公平锁与非公平锁的第一个区别:
非公平锁: 调用lock时,首先就会执行一次CAS,如果失败,才会进入acquire方法 公平锁: 不会进行CAS抢占锁,直接进入acquire方法
具体tryAcquire方法交给子类来实现:
公平锁的tryAcquire()方法,在获取同步状态时多了一个限制条件:hasQueuedPredecessors(),判断等待队列中是否存在有效前驱节点,只有队列为空或者当前线程节点是AQS中的第一个节点,则返回false,代表后续可以去获取锁;否则其他情况都表示,已经有前驱节点,返回true代表后续不能获取锁!!