面试题: 谈谈你对多线程的理解:(从以下几个方面)
- 1、使用场景:提高效率(多个任务且任务量巨大、多个任务且会阻塞)
- 2、线程状态:谈 Java 的线程状态
- 3、线程安全:
- 4、高阶 api 的使用和对 原理 的理解
(线程池、ConcurrentHashMap、juc包下的(ThreadLocal、lock、AQS、CAS、CountDownLatch等)) - 5、多线程提高效率 - - 保证安全的前提下,尽可能的提高效率
方式 : 加锁细粒度化()
一、认识线程:
概念:
- 进程:系统 分配资源 的最小单位
- 线程:系统 调度 的最小单位
(1)线程和进程的区别:(面试)
- 1、进程是系统进行资源分配和调度的一个独立单位,线程是程序执行的最小单位
- 2、进程有自己的内存地址空间,线程只独享指令流执行的必要资源,如寄存器和栈
- 3、由于同一进程的各线程间共享内存和文件资源,可以不通过内核进行直接通信
- 4、线程的创建、切换及终止效率更高(相对进程)
一个进程内的线程之间是可以共享资源的。每个进程至少有一个线程的存在(即主线程)
(2)线程创建:
- ① 继承 Thread:创建线程,重写 run 方法表示定义任务
- ② 实现 Runnable 接口:定义任务
- ③ 实现 Callable 接口:定义一个带返回值的任务
结合 Future 接口,可以获取返回值;线程池中也可以使用:submit()
//方式1:
Runnable r = new Runnable() {
@Override
//线程运行态时,执行
public void run() {
}
};
Thread t = new Thread(r,"子线程A");
t.start();
//合并代码(方式2)
new Thread(new Runnable() {
@Override
public void run() {
}
},"子线程B").start();
//方式3:
//runnable只有一个接口方法,可以直接用lambda表达式
new Thread(()-> {
System.out.println();//和在run()方法写代码一样的效果
},"子线程C").start();
线程 应用场景:
- 1、工作量大,执行时间比较长的任务
- 2、让阻塞代码不影响后续代码的执行(阻塞代码、后续代码在多个线程执行)
- 3、多线程一般需要考虑:性能、安全、提高程序性能和效率
线程的 特性:
- 线程的创建:申请系统创建一个线程,会比较耗时 - - - > new Thread()
- 线程的用户态和内核态切换 - - - > 系统接口调用(如线程中的 io 操作)
- 线程的时间片轮转调度 - - - > 系统调度
- 线程的阻塞:synchronized 关键字影响的代码块
竞争失败的线程不停的在阻塞态和被唤醒态(被JVM唤醒再次竞争对象锁)之间切换
结果:如果竞争失败的线程数量很多,对系统性能影响很大 - 如何确定线程数:
(1)计算公式:CPU核数 / (1-阻塞系数)
某线程执行任务的总时间 = 执行任务总的阻塞时间 + 真正执行任务的时间(非阻塞)
阻塞系数 = 真正执行任务的时间 / 总时间
(2)计算密集型的任务:CPU核数 / CPU核数+1 作为线程数(阻塞系数 = 0)
(3)IO密集型的任务:使用计算公式
二、Thread类及常见方法:
Thread 类是JVM用来管理线程的一个类,换句话说,每个线程都有一个唯一的 Thread 对象与之关联
(1)Thread 类常用 API:
① 静态方法:作用在当前代码行所在的线程 作用为 当前线程
类型 | 获取方法 | 作用 |
---|---|---|
static int | activeCount() | 获取当前线程组中,还存活的线程数量 |
static Thread | currentThread() | 获取代码行所在的当前线程 |
static boolean | interrupted() | 中断线程 |
static void | sleep(long millis) | 让当前线程休眠,给定时间(毫秒) |
static void | yield() | 让当前线程让步:从运行态转变为就绪态 |
② 实例方法: 作用在调度的 线程对象 上
属性 | 获取方法 | 作用 |
---|---|---|
ID | getId() | 线程的唯一标识(不同线程不会重复) |
名称(String) | getName() | 获取线程名称 |
优先级(int) | getPriority() | 获取线程优先级,0-10的数值 |
void | start() | 启动线程:申请系统调度运行线程 |
void | run() | 定义线程任务 |
Thread.State | getState() | 线程中断 |
是否存活(boolean) | isAlive() | run方法是否执行结束了 |
void | join() | 无条件等待:当前线程阻塞并等待,一直等到调用线程执行完毕 |
void | join(long millis) | 限时等待:当前线程阻塞并等待,直到调用线程执行完,或时间到了,再往下执行 |
状态 | getState() | 线程当前所处情况 |
是否后台线程 | isDaemon() | JVM会在一个进程的所有非后台线程结束后,才会结束运行 |
是否被打断 | isInterrupted() | 线程的中断 |
面试:run方法 和 start方法的区别:
- 覆写 run 方法是提供给线程要做的事情的指令清单
- 线程对象可以认为是把 张三、李四叫过来
- 而调用 start() 方法,就是喊一声:“行动起来”,主线程猜真正独立去执行
线程的阻塞:
- Thread.sleep(long) :当前线程休眠
- t.join() / t.join(long) :t 线程加入到当前线程,当前线程等待 long 或 t 线程执行完
- synchronized:竞争对象锁失败的线程,进入阻塞态
③ Thread 的常见构造方法:
方法 | 说明 |
---|---|
Thread() | 创建线程对象 |
Thread(Runnable target) | 使用Runnable对象创建线程对象 |
Thread(String name) | 创建线程对象,并命名 |
Thread(Runnable target,String name) | 使用Runnable对象创建线程对象,并命名 |
Thread(ThreadGroup group,Runnable target) | 线程可以被用来分组管理,分好的组即线程组 |
Thread t1 = new Thread();
Thread t2 = new Thread(new MyRunnable());
Thread t3 = new Thread("这是我的名字");
Thread t4 = new Thread(new MyRunnable(), "这是我的名字");
(2)中断一个进程:
线程的中断:让某个线程中断,不是直接停止线程,而是一个“建议”,是否中断,由该线程代码决定
- 原理:设置线程中断标志位
- 注意事项:如果中断时,线程处于阻塞态(线程 api 抛 InterruptedException),抛异常并重置标志位
方法 | 说明 |
---|---|
public void interrupt() | 设置调用线程的中断标志位为 true,至于是否中断,由线程自行决定,如果线程处于阻塞api调用代码,会中断出现抛出InterruptedException,并重置中断标志位 |
public static boolean interrupted() | 获取当前线程的中断标志位,并重置 |
public boolean isInterrupted() | 获取线程的中断标志位 |
举个栗子:
t 进程执行了 3 秒,还没有结束,要中断 t 进程:
public class InterruptTest {
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(new Runnable() {
// 1、中断以后停止程序:
/*@Override
public void run() {
try {
for (int i = 0; i < 10000 && !Thread.currentThread().isInterrupted(); i++) {
System.out.println(i);
//模拟中断过程
Thread.sleep(1000);
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});*/
// 2、中断以后继续执行:
@Override
public void run() {
for (int i = 0; i < 10000 && !Thread.currentThread().isInterrupted(); i++) {
System.out.println(i);
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
});
t.start();
System.out.println("t start");
//模拟,t执行了3秒,还没由结束,要中断,停止t进程
Thread.sleep(5000);
//告诉t进程,要中断(设置t进程的中断标志位为true),由t的代码自行决定是否中断
//如果t线程处于阻塞状态,会抛出InterruptedException,并且重置t线程的中断标志位
t.interrupt();
System.out.println("t stop");
}
}
三、线程的状态:
(1)线程的所有状态:
线程的状态是一个枚举类型 Thread.State
public class ThreadState {
public static void main(String[] args) {
for (Thread.State state : Thread.State.values()) {
System.out.println(state);
}
}
}
NEW
RUNNABLE
BLOCKED
WAITING
TIMED_WAITING
TERMINATED
(2)线程状态和状态转移:
四、线程运行时私有和共享的数据区域:
运行时数据区域;
1、程序计数器(线程 私有)
- 程序计数器是一块比较小的内存空间,可以看作是当前线程所执行的字节码的信号指示器
2、JAVA虚拟机栈 (线程 私有)
3、本地方法栈 (线程 私有)
- 本地方法栈与虚拟机栈所发挥的作用是非常相似的,其区别知识虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到本地方法服务
4、Java堆(线程 共享)
- 所有的对象实例以及数组都应当在堆上分配
5、方法区(线程 共享)
6、运行时常量池(线程 共享)
- 运行时常量池是方法区的一部分。Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息时常量池表,用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。
7、直接内存(本地内存中)
五、线程不安全问题:
1、线程安全的概念:
- 如果多线程环境下代码运行的结果是符合我们预期的,即在单线程环境应该的结果,则说明这个程序是线程安全的。
2、线程不安全原因:
(1)原子性:
① 什么是原子性:
在一次或者多次操作时,要么所有操作都被执行,要么所有操作都不执行
- 我们把一段代码想象成一个房间,每个线程就是要进入这个房间的人。我们没有任何机制保证,A进入房间之后,还没有出来;B是不是也可以进入房间,打断A在房间里的隐私。这个就是不具备原子性的。
- 那我们也应该如何解决这个问题呢?是不是只要给房间加一把锁,A进去就把门锁上,其他人是不是就进不来了。这样就保证了这段代码的原子性了。
- 有时也把这个现象叫做同步互斥,表示操作是互相排斥的。
② 不能切分的最小单位(多行指令)
多行指令(java某些代码,看着是一行,其实会分解为多条指令执行)。如果指令前后有依赖关系,不能插入其他影响我执行结果的指令;如果能插入就是没有原子性,不能插入就是有原子性。
③ 一条Java语句不一定是原子的,也不一定只是一条指令:
比如 n++,其实是由三部操作组成的:
- 1、从内存把数据读到 CPU
- 2、进行数据更新
- 3、把数据写回到 CPU
看着是一行,其实多个执行:
- methodA(new 对象()).methodB() - - -> methodA的返回对象调用 methodB
- n++,n- -,++n,- -n,分解为3条指令
- new对象:分解为三条指令
④ 不保证原子性会给多线程带来什么问题:
- 如果一个多线程正在对一个变量操作,中途其他线程插入进来了,如果这个操作被打断了,结果就可能使错误的。
⑤ 原子性怎么实现:
- 使用synchronized或Lock加锁实现,保证任一时刻只有一个线程访问该代码块
- 使用原子操作
(2)可见性:
当一个线程修改了某个共享变量的值,其他线程是否能够马上得知这个修改的值
(1)为什么会有可见性问题:
- 对于多线程程序而言。由于线程对共享变量的操作都是线程拷贝到各自的工作内存进行操作后才写回到主内存中的,这就可能存在一个线程A修改了共享变量x的值,还未写回主内存时,另外一个线程B又对主内存中同一个共享变量x进行操作,但此时A线程工作内存中共享变量x对线程B来说并不可见,这种工作内存与主内存同步延迟现象就造成了可见性问题。
(2)如何保证可见性:
- 加 volatile 关键字保证可见性。当一个共享变量被volatile修饰时,它会保证修改的值立即被其他的线程看到,即修改的值立即更新到主存中,当其他线程需要读取时,它会去内存中读取新值
- 使用 synchronized 和 Lock 保证可见性。因为它们可以保证任一时刻只有一个线程能访问共享资源,并在其释放锁之前将修改的变量刷新到内存中
(3)volatile 修饰的共享变量,可以保证可见性,部分保证顺序性,禁止指令重排序:(不保证原子性)
(当一个变量被volatile关键字修饰时,其他线程对该变量进行了修改后,会导致当前线程在工作内存中的变量副本失效,必须从主内存中再次获取,当前线程修改工作内存中的变量后,同时也会立刻将其修改刷新到主内存中)
class ThraedDemo {
private volatile int n;
}
常见使用场景:
一般读写分离的操作,提供性能:
- 写操作不依赖共享变量,赋值时一个常量(依赖共享变量赋值不是原子性操作)
- 作用在读,写依赖其他手段(加锁)
- 为了提高效率,JVM在执行过程中,会尽可能的将数据在工作内存中执行,但这样会造成一个问题,共享变量在都多线程之间不能及时看到改变,这个就是可见性问题。
(3)有序性:
程序执行的顺序按照代码的先后顺序执行
① 什么是 代码重排序:
在Java中,为了提高程序的运行效率,可能在编译期和运行期会对代码指令进行一定的优化,不会百分之百的保证代码的执行顺序严格按照编写代码中的顺序执行,但也不是随意进行重排序,它会保证程序的最终运算结果是编码时所期望的(使机器指令能更符合CPU的执行特性,最大限度的发挥机器性能)
一段代码是这样的:
- 1、去前台取下U盘
- 2、去K歌
- 3、去前台取下快递
如果是在单线程情况下,JVM、CPU指令集会对其进行优化,比如:
1 -> 3 -> 2 的方式执行,也是没问题,可以少跑一次前台。这种叫做指令重排序
- 单个线程站在自己的视角看代码,总是有序的
- 其他线程的视角看,总是无序的(指令重排序优化)
② 代码重排序会给多线程带来什么问题:
在上述栗子中,单线程情况是没问题的,优化是正确的,但是在多线程场景下就有问题了,什么问题呢。可能快递是在你K歌的时间内被另一个线程放过来的,或者被人变过了,如果指令重排序了代码就是会错误的。
(4)volatile:
① 什么是volatile:
- volatile是一种同步机制,比synchronized或者Lock相关类更轻量级,因为使用volacile并不会发生上下文切换等开销很大的行为
- volatile是无锁的,并且只能修饰单个属性
② 什么时候适合用vilatile:
- 一个共享变量始终只被各个线程赋值,没有其他操作
- 作为刷新的触发器,引用刷新之后使修改内容对其他线程可见(如CopyOnRightArrayList底层动态数组通过volatile修饰,保证修改完成后通过引用变化触发volatile刷新,使其他线程可见)
③ volatile的作用:
- 可见性保障:修改一个volatile修饰变量之后,会立即将修改同步到主内存,使用一个volatile修饰的变量之前,会立即从主内存中刷新数据。保证读取的数据都是最新的,之前的修改都是可见的。
- 有序性保障(禁止指令重排序优化):有volatile修饰的变量,赋值后多了一个“内存屏障“( 指令重排序时不能把后面的指令重排序到内存屏障之前的位置)
④ volatile 的性能:
- volatile 的读性能消耗与普通变量几乎相同,但是写操作稍慢,因为它需要在本地代码中插入许多内存屏障指令来保证处理器不发生乱序执行。
3、解决线程不安全问题:加锁(synchronized)、Lock体系
我的另一篇文章带你深入理解:
https://blog.youkuaiyun.com/qq_45658339/article/details/116082296
六、死锁:
1、出现的原因:
至少两个线程,互相持有对方需要的资源没有释放,再次申请对方已持有的资源
2、如何检测死锁:
使用 jdk 工具:jconsole(查看线程) - - - > jstack
3、怎么解决死锁:
- (1)资源 一次性分配(破坏请求与保持条件)
- (2)在线程满足条件时,释放已占有的资源
- (3)资源 有序分配:系统未每类资源赋予一个编号,每个线程按照编号递请求资源,释放则相反
七、多线程案例:
1、单例模式:(都使用同一个对象)
(1)饿汉模式:
class Singleton {
private static Singleton instance = new Singleton();
private Singleton() {}
public static Singleton getInstance() {
return instance;
}
}
(2.1)懒汉模式(单线程)
class Singleton {
private static Singleton instance = null;
private Singleton() {}
public static Singleton getInstance() {
//同时多个线程进入
if (instance == null) {
//每个线程new出不同的对象,不满足单例要求
instance = new Singleton();
}
return instance;
}
}
(2.2)懒汉模式(多线程)(性能低)
第一次初始化操作,涉及到多线程 new 不同对象,需要加锁。后续的获取同一个对象操作,运行并发并行
class Singleton {
private static Singleton instance = null;
private Singleton() {}
public synchronized static Singleton getInstance() {
//第一次实例化对象之后,应该允许多线程并发并行获取同一个对象(效率低)
if (instance == null) {
instance = new Singleton();
}
return instance;
}
}
(2.3)🎨 懒汉模式(多线程)(二次判断)(性能高)(双重校验锁)(面试)
① 如果只是一个判断语句:
我们会发现: 当线程A和线程B同时进行到第2步的时候,两者会竞争对象锁,假设此时是线程A获得了锁,未进行new操作;等到下个时间片过来的时候,线程B获得了锁,线程A开始new操作,然后释放锁,线程B也new了对象,此时线程A和线程B都new了新对象,不满足我们的单例模式(使用同一个对象),所以这种方法行不通
② 所以我们在上述代码中的第2后面新加了一个判断语句:
如果 instance 这个值是 null的话,即我们还没有进行 new对象操作,我们才去new对象,这就有效的避免了线程A和线程B new出两个对象
class Singleton {
private static volatile Singleton instance = null;
private Singleton() {}
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) { //竞争对象锁
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}
- volatile:保证可见性、volatile修饰的这行禁止指令重排序
instance = new Singleton();
这行代码看似一行,其实有三步:
- 1、创建初始化内存空间
2、new对象
3、赋值给共享变量
我们知道代码重排序,JVM会对指令进行优化,可能会打乱指令的顺序,这就给我们的多线程带来了很大的麻烦;instance 是由 volatile修饰的,所以在赋值变量这步操作不会进行重排序。
2、线程间的通信:
- 多个线程间协作完成任务
- 线程等待(线程自己释放锁阻塞等待),线程通知(线程自己执行完释放锁以后,通知之前 wait 等待的线程,可以再次竞争锁)
线程间的通信方法:
(wait,notify,notifyAll 必须使用在 synchronized 同步方法或者代码块内)
for/while循环 {
synchronized(object) {
while(不满足执行条件时) {
object.wait()
}
//满足执行条件,执行业务
//不推荐写 notify() 极端情况下,可能会导致所有线程 wait 阻塞等待
object.notifyAll();
}
}
方法 | 说明 |
---|---|
wait() | 让当前线程进入等待时间,同时释放当前线程的锁(使线程停止运行) |
notify() | 唤醒当前对象上的线程(单个线程) |
notifyAll() | 唤醒当前对象上的线程(所有的线程) |
wait 和 sleep 的对比(面试)
- 1、wait 之前需要请求锁,而 wait 执行时会先释放锁,等被唤醒时再重新请求锁。这个锁是 wait 对象上的 monitorlock
- 2、sleep 是无视锁的存在,即之前请求的锁不会释放,没有锁也不会请求
- 3、wait 是 Object 的方法
- 4、sleep 是 Thread 的静态方法
3、阻塞式队列:
(1)生产者-消费者模型(重要)
public class BreadShop {
//定义面包库存数
private static int COUNT;
//消费者:
public static class Consumer implements Runnable {
private String name;
public Consumer(String name) {
this.name = name;
}
@Override
public void run() {
try {
//一直消费:
while (true) {
synchronized (BreadShop.class) {
//库存到达下线,不能继续消费,需要阻塞等待
if (COUNT == 0) {
BreadShop.class.wait();//释放对象锁
} else {
//库存 > 0,允许消费
COUNT--;
System.out.printf("消费者 %s 消费了一个面包,库存 %s\n",name,COUNT);
//通知代码进入阻塞的进程
BreadShop.class.notifyAll();
//模拟消费的耗时
Thread.sleep(200);
}
}
Thread.sleep(100);
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
//生产者:
public static class Producer implements Runnable {
private String name;
public Producer(String name) {
this.name = name;
}
@Override
public void run() {
try {
//一直生产
while (true) {
synchronized (BreadShop.class) {
//库存达到上限,不能继续生产,需要阻塞队列
if (COUNT + 3 > 100) {
BreadShop.class.wait();
} else {
COUNT+=3;
System.out.printf("生产者 %s 生产了三个面包,库存%s\n",name,COUNT);
BreadShop.class.notifyAll();
Thread.sleep(200);
}
}
Thread.sleep(100);
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
public static void main(String[] args) {
//同时启动20个消费者线程
Thread[] consumers = new Thread[20];
for (int i = 0; i < 20; i++) {
consumers[i] = new Thread(new Consumer(String.valueOf(i)));
}
//同时启动10个产生着线程
Thread[] produces = new Thread[20];
for (int i = 0; i < 10; i++) {
produces[i] = new Thread(new Producer(String.valueOf(i)));
}
//同时启动
for (int i = 0; i < 20; i++) {
consumers[i].start();
}
for (int i = 0; i < 10; i++) {
produces[i].start();
}
}
}
(2)阻塞队列:
- 队列的特点:先进先出(FIFO)
- 队列的底层数据结构:循环队列、链表
- JDK队列的实现:可以看 Queue 接口的实现类(idea中,点击 Queue 接口名,navigate - > type hierarchy)
类和方法分析:方法上,navigate - > call hierarchy
- 阻塞队列:BlockingQueue接口,实现类,就是该接口下的实现类
(3)线程池:(面试)
1、为什么需要线程池呢?
- 我们知道 new Thread() 操作非常耗时
- 线程池最大的好处就是减少每次启动、销毁线程的损耗(达到线程的重用 / 复用)
2、创建的方式:
线程池有4个快捷创建方式(实际工作不使用,作为面试要了解)
(实际工作需要使用 ThreadPoolExecutor,构造参数自己指定,下面方法参数都是默认的)
- 1、单线程池:
ExecutorService pool2 = Executors.newSingleThreadExecutor();
- 2、缓存的线程池
ExecutorService pool3 = Executors.newCachedThreadPool();
- 3、计划任务线程池
ScheduledExecutorService pool4 = Executors.newScheduledThreadPool(4);
- 4、固定大小线程池
ExecutorService pool5 = Executors.newFixedThreadPool(4);
阿里编程规范建议:永远不要用 Excutors 下的预置线程池,而是用 ThreadPoolExcutor 自行构造
原因: Excutors 下的线程池,基本都是队列长度无限长的,如果任务处理的很慢,会造成任务都堆积在队列中;队列是内存的,所以最终很容易内存被消耗完了
下面有个案例:
public class ThreadPoolExecutorTest {
public static void main(String[] args) {
//创建线程池:
ThreadPoolExecutor pool = new ThreadPoolExecutor(
5,//(corePoolSize)核心线程数 ---> 正式员工
10,//(maximumPoolSize)最大线程数 ---> 正式员工+临时工
60,//(keepAliveTime)
TimeUnit.SECONDS,//idle线程的空闲时间:临时工最大的存活时间
new LinkedBlockingQueue<>(),//阻塞队列:任务存放的地方(快递仓库)
new ThreadFactory() {//创建线程的工厂类:线程池创建线程时,调用该工厂的方法创建线程--->招聘员工的标准
@Override
public Thread newThread(Runnable r) {//线程中定义的任务类r
return new Thread(new Runnable() {
@Override
public void run() {
System.out.println(Thread.currentThread().getName()+"开始执行了");
}
});
}
},
/**
* 拒绝策略:达到最大线程数且阻塞队列已满,采取的拒绝策略
* 1、AbortPolicy:直接抛 RejectedExecutionException(不提供 handler 时的默认策略)
* 2、CallerRunsPolicy:谁(某个线程)交给我(线程池)任务,我拒绝执行,由谁自己执行
* 3、DiscardPolicy:交给我的任务,直接丢弃掉
* 4、DiscardOldestPolicy:丢弃阻塞队列中最旧的任务
*/
new ThreadPoolExecutor.AbortPolicy()
);
//线程池创建以后,只要有任务就自动执行
for (int i = 0; i < 200; i++) {
final int j = i;
//线程池执行任务:execute\submit --->提交执行一个任务
pool.execute(new Runnable() {
@Override
public void run() {
System.out.println(j);
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
}
}
}
3、执行流程:
八、解决多线程代码中遇到的问题:
多线程的调试方法:
-
1、写打印语句
-
2、jconsole:(先运行程序)
① 在 idea左下角点击这个 Terminal,并输入 jconsole 敲回车:
如果你的 idea 左下角没有,可以这样操作:
② 然后在弹出的窗口中双击你所要调试的 类:
点击不安全连接:
③ 先选择线程,然后再左下角选择你要调试的具体线程
-
(3)debug 在有些场景不一定适用