一、JUC
JUC是java.util.conccurent包的简称,juc提供了很多关于线程安全的集合实现,以及各种锁的实现。
juc视频教程
二、多线程相关知识
1.并发
同一台处理器上 “同时” 处理多个任务,为什么同时需要打引号呢?因为实际上同一时刻实际上只有一个事件在发生。处理器是通过不断切换占用cpu时间片的任务来实现同时处理多个任务的。
2.并行
多台处理器同时处理多个任务,同一时刻,每个处理器都在处理不同的任务。
3.进程
是并发执行的程序在执行过程中系统分配和管理资源的基本单位
4.线程
包含在进程中,是cpu调用的最小单元,也称轻量级进程
5.管程
实际上指的是锁(monitor),管程只会允许一个线程进入去获取共享资源
6.进程VS线程
- 内存空间及系统资源
进程之间拥有独立的空间、系统资源,同一个进程中的线程则是共享进程的内存空间、系统资源 - 健壮性
一个进程崩溃之后不会影响其它进程,同一个进程中的线程崩溃后会导致进程中的其它线程受到影响。
7.用户线程
一般情况下不做特殊说明,线程默认都是用户线程
8.守护线程
守护线程是一个服务线程,他服务于用户线程,如果用户线程结束了,守护线程也就结束了。
在java中,jvm的gc垃圾回收线程就是守护线程,当主线城结束后,垃圾回收线程也会结束。
下面用一个例子来演示守护线程:
创建一个线程类,在其中执行打印操作1-100
/**
* @author Watching
* * @date 2023/4/29
* * Describe:
*/
public class MyThread extends Thread{
@Override
public void run() {
for(int i = 1;i<= 100;i++){
System.out.println(getName()+" helloWord " + i);
}
}
}
main方法,创建两个线程,一个女神线程,一个舔狗线程,女神先开始打印,1s后舔狗开始打印
/**
* @author Watching
* * @date 2023/4/29
* * Describe:
*/
public class ThreadDemo {
public static void main(String[] args) throws InterruptedException {
MyThread myThread1 = new MyThread();
MyThread myThread2 = new MyThread();
myThread1.setName("女神");
myThread2.setName("舔狗");
myThread2.setDaemon(true);
myThread1.start();//女神开始打印
Thread.sleep(1000);
myThread2.start();//舔狗开始打印
}
}
查看结果:发现舔狗没有打印完100次就结束了,因为他打印17次之后发现女神线程结束了,所以舔狗线程也就结束了。
三、CompletableFuture
可以查看我另外一篇文章
CompletableFuture
四、锁
1.悲观锁VS乐观锁
- 悲观锁
认为自己在使用数据的时候一定有别的线程来修改数据,因此在获取数据的时候会先加锁,确保数据不会被别的线程修改。synchronized和lock接口都是悲观锁,适合用于写多的操作。 - 乐观锁
认为自己在使用数据的时候不会有其它线程修改数据,所以不会添加锁,只有在更新数据的时候才会去判断,这个数据相比于自己查到的时候有没有被修改。
如果数据相比于自己查到的时候没有被修改,则将数据修改为自己线程的数据。
反之,可以选择放弃修改数据,或者自旋重新尝试修改数据。
通常会采用版本号机制和cas算法
适合用于读多的操作。
2.synchronized(1.6)
sychronized是java提供的一个关键字,在jdk1.6之前它是一把重量级锁。
第一个线程获取锁之后(在底层获取monitor这把锁),然后其他未获取到锁的线程会被添加到一个就绪队列中(其中还存在一个等待队列,线程处于阻塞状态时会被放入等待队列,等待被唤醒之后放入就绪队列),等待锁被释放后再次去竞争这把锁。
由于涉及到线程的状态切换,比较耗费系统资源,所以synchronized这把锁的代价很大。
2.0、管程(monitor)(待完善)
下面就来详细讲一下synchronized在底层是如何获取monitor这把锁的
2.1、synchronized作用在代码块时
它的底层是通过monitorenter、monitorexit指令来实现的。
monitorenter:
每个对象都是一个监视器锁(monitor),当monitor被占用时就会处于锁定状态,线程执行
monitorenter指令时尝试获取monitor的所有权,过程如下:
如果monitor的进入数为0,则该线程进入monitor,然后将进入数设置为1,该线程即为monitor
的所有者。如果线程已经占有该monitor,只是重新进入,则进入monitor的进入数加1。如果其
他线程已经占用了monitor,则该线程进入阻塞状态,直到monitor的进入数为0,再重新尝试获
取monitor的所有权。
monitorexit:
执行monitorexit的线程必须是objectref所对应的monitor持有者。
指令执行时,monitor的进入数减1,如果减1后进入数为0,那线程退出monitor,不再是这个monitor的所有者。其他被这个monitor阻塞的线程可以尝试去获取这个monitor的所有权。
monitorexit指令出现了两次,第1次为同步正常退出释放锁,第2次为发生异常退出释放锁。
2.2、方法的同步
并没有通过 monitorenter 和 monitorexit 指令来完成,不过相对于普通方法,其常量
池中多了 ACC_SYNCHRONIZED 标示符。JVM就是根据该标示符来实现方法的同步的:
当方法调用时,调用指令将会检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先获取monitor,获取成功之后才能执行方法体,方法执行完后再释放monitor。在方法执行期间,其他任何线程都无法再获得同一个monitor对象。
2.3、总结
两种同步方式本质上没有区别,只是方法的同步是一种隐式的方式来实现,无需通过字节码来完成。两个指令的执行是JVM通过调用操作系统的互斥原语mutex来实现,被阻塞的线程会被挂起、等待重新调度,会导致“用户态和内核态”两个态之间来回切换,对性能有较大影响。
2.4、javap -v
为了更直观的看见monitorenter、monitorexit、ACC_SYNCHRONIZED这些指令和标识,我们可以使用java提供的javap -v指令将字节码文件反编译。
这里是包含同步代码块、同步方法、静态同步方法的类:
//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by FernFlower decompiler)
//
package com.bilibili.juc.locks;
public class LockSyncDemo {
Object object = new Object();
public LockSyncDemo() {
}
//同步代码块
public void m1() {
synchronized(this.object){}
try {
System.out.println("----hello synchronized code block");
throw new RuntimeException("-----exp");
} finally {
;
}
}
//同步方法
public synchronized void m2() {
System.out.println("----hello synchronized m2");
}
//静态同步类
public static synchronized void m3() {
System.out.println("----hello static synchronized m3");
}
public static void main(String[] args) {
}
}
将其字解码反编译:
m1
m2、m3
3.公平锁Vs非公平锁
3.1公平锁
公平锁是指几个线程共同竞争锁,如果竞争到了锁,则占有,没有竞争到锁的线程会被依次存放在一个FIFO的双向链表中,等占有锁的线程执行完任务释放锁之后,排在双向链表头结点后一个的线程被唤醒再来获取锁。如此反复。
双向链表图:
head处存的是站有锁的线程的信息
3.2非公平锁
非公平锁是指几个线程共同竞争锁,如果锁已经被一个线程占用了,后续的线程不会像公平锁一样直接依次存放在FIFO的双向链表中,而是每次来一个线程都要尝试获取锁,如果没获取到则依次存入双向链表,如果获取到了那就直接占有锁,尽管双向链表中还有线程在等待,可以理解为插队了。
在java锁的实现如synchronized和ReentrantLock中,synchronized是非公平锁,而ReentrantLock默认是非公平锁。
为什么都要优先使用非公平锁呢?
因为这涉及到一个效率问题,公平锁中如果锁当前被占用,后续的线程会在双向队列中阻塞等待,锁被释放后,再从双向队列中唤醒下一个线程,这就涉及到了上下文切换,核心态到用户态的切换,效率更低。
而非公平锁,在当前锁被占用的情况下,后续线程来了会直接尝试获取锁,如果恰好锁被释放了,后续线程就可以直接获取锁执行操作,避免了上下文切换。
4.重入锁
重入锁是指线程已经获取了锁,这个线程再次获取这把锁不会阻塞。
4.1synchronized (隐式重入
java中的sychronized和Lock都是重入锁实现。
示例:
在方法reEntryM1()中使用同一个线程嵌套使用Synchronized,结果正常执行
package com.bilibili.juc.locks;
/**
* @auther zzyy
* @create 2022-01-18 18:09
*/
public class ReEntryLockDemo {
public static void main(String[] args) {
reEntryM1();
}
private static void reEntryM1() {
final Object object = new Object();
new Thread(() -> {
synchronized (object) {
System.out.println(Thread.currentThread().getName() + "\t ----外层调用");
synchronized (object) {
System.out.println(Thread.currentThread().getName() + "\t ----中层调用");
synchronized (object) {
System.out.println(Thread.currentThread().getName() + "\t ----内层调用");
}
}
}
}, "t1").start();
}
}
4.2 实现原理
在前面讲synchronized关键字的时候我们提到了monitor这个底层的锁,其实在底层的ObjectMonitor.hpp(锁对象monitor)中有很多属性,其中有几个关键属性列在下面表格中。
注意看到有一个属性叫 _recursions,当同一个线程获取已经被该线程获取到的锁时,该锁对象会调用monitorenter并且_recursions+1,当释放锁时,调用monitorexit并且_recursions-1。如果recursions=0了,则当前锁已经完全被释放了。
注意:
但是在判断当前锁是否已经被线程占用了不是用_recursions字段,而是判断_count字段是否尾0,monitorenter,_count++; monitorexit,_count–;
现在我们按照这个思路来解释一下上面的代码:
4.3 Lock (显示重入
lock接口需要手动释放锁,为了避免死锁,每次lock()之后最好在finally中unlock()
static Lock lock = new ReentrantLock();
public static void main(String[] args) {
ReEntryLockDemo reEntryLockDemo = new ReEntryLockDemo();
new Thread(() -> {
lock.lock();
try {
System.out.println(Thread.currentThread().getName() + "\t ----come in外层调用");
lock.lock();
try {
System.out.println(Thread.currentThread().getName() + "\t ----come in内层调用");
} finally {
lock.unlock();
}
} finally {
// 由于加锁次数和释放次数不一样,第二个线程始终无法获取到锁,导致一直在等待。
lock.unlock();// 正常情况,加锁几次就要解锁几次
}
}, "t1").start();
}
加锁和解锁需要一一对应
4.4 死锁产生的原因以及排查手法
死锁产生的原因可以用一句话概括:我占用着你需要的资源,你占用着我需要的资源,并且彼此都需要两个资源才能完成任务。
代码演示:
线程A此时需要锁对象objectB,但是锁对象objectB被B线程占用着。
线程B此时需要锁对象objectA,但是锁对象objectA被A线程占用着。
public class DeadLockDemo
{
public static void main(String[] args)
{
final Object objectA = new Object();
final Object objectB = new Object();
new Thread(() -> {
synchronized (objectA){
System.out.println(Thread.currentThread().getName()+"\t 自己持有A锁,希望获得B锁");
try { TimeUnit.SECONDS.sleep(1); } catch (InterruptedException e) { e.printStackTrace(); }
synchronized (objectB){
System.out.println(Thread.currentThread().getName()+"\t 成功获得B锁");
}
}
},"A").start();
new Thread(() -> {
synchronized (objectB){
System.out.println(Thread.currentThread().getName()+"\t 自己持有B锁,希望获得A锁");
try { TimeUnit.SECONDS.sleep(1); } catch (InterruptedException e) { e.printStackTrace(); }
synchronized (objectA){
System.out.println(Thread.currentThread().getName()+"\t 成功获得A锁");
}
}
},"B").start();
}
}
运行结果:
死锁了。
那么在开发中遇到死锁我们该如何排查呢?
方式一:命令终端
①jps -l
找到当前运行的进程编号 15108
②jstack 15108
找到对应信息
在信息的最下面还会告诉存在几个死锁
方式二:图形化
①jconsole打开java监管图形化界面
②找到进程
找到以下模块,可以看见死锁的线程。
5.synchronized加锁解锁流程图
解锁步骤recursions应该是- -,写错了