JUC (3): Common methods of Java threads

common method

method Function illustrate
public void start() Start a new thread; the Java virtual machine calls the run method of this thread The start method just makes the thread ready, and the code inside does not necessarily run immediately (the CPU time slice has not been allocated to it yet). The start method of each thread object can only be called once, if it is called multiple times, an IllegalThreadStateException will occur
public void run() This method is called after the thread starts If the Runnable parameter is passed when constructing the Thread object, the run method in the Runnable will be called after the thread starts, otherwise no operation will be performed by default. But you can create a subclass object of Thread to override the default behavior
public voidsetName(String name) Give the current thread a name
public void getName() Get the name of the current thread. Threads have default names: child threads are Thread-index, main thread is main
public static Thread currentThread() Get the current thread object, in which thread the code is executed
public static void sleep(long time) How many milliseconds to let the current thread sleep before continuing. Thread.sleep(0) : Let the operating system restart the CPU competition immediately
public static native void yield() Prompt the thread scheduler to let the current thread use the CPU Mainly for testing and debugging
public final int getPriority() Returns the priority of this thread
public final void setPriority(int priority) Change the priority of this thread, usually 1 5 10 Java specifies that the thread priority is an integer from 1 to 10, and a higher priority can increase the probability of the thread being scheduled by the CPU
public void interrupt() Interrupt this thread, exception handling mechanism
public static boolean interrupted() Determine whether the current thread is interrupted, clear the interrupt flag
public boolean isInterrupted() Determine whether the current thread is interrupted without clearing the interrupt flag
public final void join() wait for this thread to end
public final void join(long millis) Wait for this thread to die in millis milliseconds, 0 means wait forever
public final native boolean isAlive() Whether the thread is alive (has not finished running yet)
public final void setDaemon(boolean on) Mark this thread as daemon thread or user thread
public long getId() Get the id of the thread long integer id unique
public state getState() get thread state The thread state in Java is represented by 6 enums, namely: NEW, RUNNABLE, BLOCKED, WAITING, TIMED_WAITING, TERMINATED
public boolean isInterrupted() Determine whether to be interrupted does not clear break markers

1. start and run

call run

public static void main(String[] args) {
    
    
    Thread t1 = new Thread("t1") {
    
    
        @Override
        public void run() {
    
    
            log.debug(Thread.currentThread().getName());
            FileReader.read(Constants.MP4_FULL_PATH);
        }
    };
    t1.run();
    log.debug("do other things ...");
}

output

19:39:14 [main] c.TestStart - main
19:39:14 [main] c.FileReader - read [1.mp4] start ...
19:39:18 [main] c.FileReader - read [1.mp4] end ... cost: 4227 ms
19:39:18 [main] c.TestStart - do other things ...

The program is still running in the main thread, and the FileReader.read() method call is still synchronous

call start

Change t1.run() of the above code to

t1.start();

output

19:41:30 [main] c.TestStart - do other things ...
19:41:30 [t1] c.TestStart - t1
19:41:30 [t1] c.FileReader - read [1.mp4] start ...
19:41:35 [t1] c.FileReader - read [1.mp4] end ... cost: 4542 ms

The program runs on the t1 thread, and the FileReader.read() method call is asynchronous

summary

  • Calling run directly is to execute run in the main thread without starting a new thread

  • Using start is to start a new thread, and indirectly execute the code in run through the new thread

    public static void main(String[] args) {
          
          
        Thread t1 = new Thread("t1") {
          
          
            @Override
            public void run() {
          
          
                log.debug("running...");
            }
        };
        System.out.println(t1.getState());
        t1.start();
        System.out.println(t1.getState());
    }
    

    As you can see, the start method creates a new thread and switches the thread from ready to Runnable

    NEW
    RUNNABLE
    03:45:12.255 c.Test5 [t1] - running...
    

A little interview related

  1. Why does the run() method execute when we call the start() method, why can't we call the run() method directly?

    • new A Thread, the thread enters the new state. Calling the start() method will start a thread and make the thread enter the ready state, and it can start running after the time slice is allocated. start() 会执行线程的相应准备工作, and then automatically execute the content of the run() method, which is a real multi-threaded work.
    • The direct execution of the run() method will execute the run method as an ordinary method under the main thread, and will not execute it in a certain thread, so this is not multi-threaded work.

    Summary: Calling the start method can start the thread and make the thread enter the ready state, while the run method is just an ordinary method call of thread, and it is still executed in the main thread.

  2. What is the difference between run() and start() of a thread?

  • Each thread completes its operation through the method run() corresponding to a specific Thread object, and the run() method is called the thread body. Start a thread by calling the start() method of the Thread class.

  • start() 方法用于启动线程,run() 方法用于执行线程的运行时代码。run() 可以重复调用,而 start()只能调用一次。

  • start()方法来启动一个线程,真正实现了多线程运行。调用start()方法无需等待run方法体代码执行完毕,可以直接继续执行其他的代码; 此时线程是处于就绪状态,并没有运行。 然后通过此Thread类调用方法run()来完成其运行状态, run()方法运行结束, 此线程终止。然后CPU再调度其它线程。

  • run()方法是在本线程里的,只是线程里的一个函数,而不是多线程的。 如果直接调用run(),其实就相当于是调用了一个普通函数而已,直接待用run()方法必须等待run()方法执行完毕才能执行下面的代码,所以执行路径还是只有一条,根本就没有线程的特征,所以在多线程执行时要使用start()方法而不是run()方法。

2. sleep 与 yield

sleep

  1. 调用 sleep 会让当前线程从 Running 进入 Timed Waiting 状态(阻塞)

  2. 其它线程可以使用 interrupt 方法打断正在睡眠的线程,这时 sleep 方法会抛出 InterruptedException

public static void main(String[] args) throws InterruptedException {
    
    
    Thread t1 = new Thread("t1") {
    
    
        @Override
        public void run() {
    
    
            log.debug("enter sleep...");
            try {
    
    
                Thread.sleep(2000);
            } catch (InterruptedException e) {
    
    
                log.debug("wake up...");
                e.printStackTrace();
            }
        }
    };
    t1.start();

    Thread.sleep(1000);
    log.debug("interrupt...");
    t1.interrupt();
}

输出结果:

03:47:18.141 c.Test7 [t1] - enter sleep...
03:47:19.132 c.Test7 [main] - interrupt...
03:47:19.132 c.Test7 [t1] - wake up...
java.lang.InterruptedException: sleep interrupted
	at java.lang.Thread.sleep(Native Method)
	at cn.xiaozheng.test.Test7$1.run(Test7.java:14)
  1. 睡眠结束后的线程未必会立刻得到执行

  2. 建议用 TimeUnit 的 sleep 代替 Thread 的 sleep 来获得更好的可读性 。其底层还是sleep方法。

@Slf4j(topic = "c.Test8")
public class Test8 {
    
    

    public static void main(String[] args) throws InterruptedException {
    
    
        log.debug("enter");
        TimeUnit.SECONDS.sleep(1);
        log.debug("end");
//        Thread.sleep(1000);
    }
}
  1. 在循环访问锁的过程中,可以加入sleep让线程阻塞时间,防止大量占用cpu资源。

yield

  1. 调用 yield 会让当前线程从 Running 进入 Runnable 就绪状态,然后调度执行其它线程
  2. 具体的实现依赖于操作系统的任务调度器
  3. 它是一个静态方法而且只保证当前线程放弃 CPU 占用而不能保证使其它线程一定能占用 CPU,执行yield()的线程有可能在进入到暂停状态后马上又被执行。

线程优先级

  • 线程优先级会提示(hint)调度器优先调度该线程,但它仅仅是一个提示,调度器可以忽略它
  • 如果 cpu 比较忙,那么优先级高的线程会获得更多的时间片,但 cpu 闲时,优先级几乎没作用
/**
 * 线程可以拥有的最低优先级。
 */
public static final int MIN_PRIORITY = 1;

/**
 * 分配给线程的默认优先级。
 */
public static final int NORM_PRIORITY = 5;

/**
 * 线程可以拥有的最大优先级。
 */
public static final int MAX_PRIORITY = 10;

/**
 * 更改此线程的优先级。
 * <p>
 * 首先调用该线程的{@code checkAccess}方法,不带任何参数。这可能会导致抛出{@code SecurityException} 。
 * <p>
 * 否则,此线程的优先级设置为指定的{@code newPriority}和线程的线程组的最大允许优先级中的较小者。
 *
 * @param newPriority 将此线程设置为的优先级
 * @throws     IllegalArgumentException  If the priority is not in the
 *               range {@code MIN_PRIORITY} to
 *               {@code MAX_PRIORITY}.
 * @throws     SecurityException  if the current thread cannot modify
 *               this thread.
 * @see        #getPriority
 * @see        #checkAccess()
 * @see        #getThreadGroup()
 * @see        #MAX_PRIORITY
 * @see        #MIN_PRIORITY
 * @see        ThreadGroup#getMaxPriority()
 */
public final void setPriority(int newPriority) {
    
    
    ThreadGroup g;
    checkAccess();
    if (newPriority > MAX_PRIORITY || newPriority < MIN_PRIORITY) {
    
    
        throw new IllegalArgumentException();
    }
    if((g = getThreadGroup()) != null) {
    
    
        if (newPriority > g.getMaxPriority()) {
    
    
            newPriority = g.getMaxPriority();
        }
        setPriority0(priority = newPriority);
    }
}

测试优先级和yield

@Slf4j(topic = "c.TestYield")
public class TestYield {
    
    
    public static void main(String[] args) {
    
    
        // 没有yield
        Runnable task1 = () -> {
    
    
            int count = 0;
            for (;;) {
    
    
                System.out.println("---->1 " + count++);
            }
        };
        // 有yield
        Runnable task2 = () -> {
    
    
            int count = 0;
            for (;;) {
    
    
				// Thread.yield();
                System.out.println("              ---->2 " + count++);
            }
        };
        Thread t1 = new Thread(task1, "t1");
        Thread t2 = new Thread(task2, "t2");
        t1.setPriority(Thread.MIN_PRIORITY);
        t2.setPriority(Thread.MAX_PRIORITY);
        t1.start();
        t2.start();
    }
}

测试结果:

#优先级
---->1 283500
---->2 374389
#yield
---->1 119199
---->2 101074

结论:可以看出,线程优先级和yield会对线程获取cpu时间片产生一定影响,但不会影响太大。

一点点面试相关
  1. Thread 类中的 yield 方法有什么作用?

    ​ yield()应该做的是让当前运行线程回到可运行状态,以允许具有相同优先级的其他线程获得运行机会。因此,使用yield()的目的是让相同优先级的线程之间能适当的轮转执行。但是,实际中无法保证yield()达到让步目的,因为让步的线程还有可能被线程调度程序再次选中。

    结论:yield()从未导致线程转到等待/睡眠/阻塞状态。在大多数情况下,yield()将导致线程从运行状态转到可运行状态,但有可能没有效果。

  2. Thread.sleep(0)的作用是什么?

    由于 Java 采用抢占式的线程调度算法,因此可能会出现某条线程常常获取到 CPU 控制权的情况,为了让某些优先级比较低的线程也能获取到 CPU 控制权,可以使用 Thread.sleep(0)手动触发一次操作系统分配时间片的操作,这也是平衡 CPU 控制权的一种操作

∗ 应用之限制(案例 1 ) \textcolor{green}{* 应用之限制(案例1)} 应用之限制(案例1

sleep 实现

在没有利用 cpu 来计算时,不要让 while(true) 空转浪费 cpu,这时可以使用 yield 或 sleep 来让出 cpu 的使用权 给其他程序

while(true) {
    
    
    try {
    
    
        Thread.sleep(50);
    } catch (InterruptedException e) {
    
    
        e.printStackTrace();
    }
}
  • 可以用 wait 或 条件变量达到类似的效果
  • 不同的是,后两种都需要加锁,并且需要相应的唤醒操作,一般适用于要进行同步的场景
  • sleep 适用于无需锁同步的场景

wait 实现

synchronized(锁对象) {
    
    
    while(条件不满足) {
    
     
        try {
    
    
            锁对象.wait();
        } catch(InterruptedException e) {
    
    
            e.printStackTrace();
        }
    }
    // do sth...
}

条件变量实现

lock.lock();
try {
    
    
    while(条件不满足) {
    
    
        try {
    
    
            条件变量.await();
        } catch (InterruptedException e) {
    
    
            e.printStackTrace();
        }
    }
    // do sth...
} finally {
    
    
    lock.unlock();
}

3. join 方法详解

为什么需要 join

下面的代码执行,打印 r 是什么?

static int r = 0;
public static void main(String[] args) throws InterruptedException {
    
    
    test1();
}
private static void test1() throws InterruptedException {
    
    
    log.debug("开始");
    Thread t1 = new Thread(() -> {
    
    
        log.debug("开始");
        sleep(1);
        log.debug("结束");
        r = 10;
    });
    t1.start();
    log.debug("结果为:{}", r);
    log.debug("结束");
}

分析

  • 因为主线程和线程 t1 是并行执行的,t1 线程需要 1 秒之后才能算出 r=10
  • 而主线程一开始就要打印 r 的结果,所以只能打印出 r=0

解决方法

  • 用 sleep 行不行?为什么?
  • 用 join,加在 t1.start() 之后即可

∗ 应用之同步(案例 1 ) \textcolor{green}{* 应用之同步(案例1)} 应用之同步(案例1

以调用方角度来讲,如果

  • 需要等待结果返回,才能继续运行就是同步
  • 不需要等待结果返回,就能继续运行就是异步
1s 后
t1 终止
main
t1.start
t1.join
r=10

等待多个结果

问,下面代码 cost 大约多少秒?

static int r1 = 0;
static int r2 = 0;
public static void main(String[] args) throws InterruptedException {
    
    
    test2();
}
private static void test2() throws InterruptedException {
    
    
    Thread t1 = new Thread(() -> {
    
    
        sleep(1);
        r1 = 10;
    });
    Thread t2 = new Thread(() -> {
    
    
        sleep(2);
        r2 = 20;
    });
    long start = System.currentTimeMillis();
    t1.start();
    t2.start();
    t1.join();
    t2.join();
    long end = System.currentTimeMillis();
    log.debug("r1: {} r2: {} cost: {}", r1, r2, end - start);
}  

分析如下

  • 第一个 join:等待 t1 时, t2 并没有停止, 而在运行
  • 第二个 join:1s 后, 执行到此, t2 也运行了 1s, 因此也只需再等待 1s

如果颠倒两个 join 呢?

最终都是输出

20:45:43.239 [main] c.TestJoin - r1: 10 r2: 20 cost: 2005
1s 后
t1 终止
2s 后
t2 终止
1s 后
t1 终止
2s 后
t2 终止
main
t1.start
t1.join
r1=10
t2.join - 仅需等待1s
t2.start
r2=20
main
t1.start
r1=10
t1.join - 无需等待
tt2.join
t2.start
r2=20

有时效的join

当线程执行时间没有超过join设定时间

static int r1 = 0;
static int r2 = 0;
public static void main(String[] args) throws InterruptedException {
    
    
    test3();
}
public static void test3() throws InterruptedException {
    
    
    Thread t1 = new Thread(() -> {
    
    
        sleep(1);
        r1 = 10;
    });
    long start = System.currentTimeMillis();
    t1.start();
    // 线程执行结束会导致 join 结束
    t1.join(1500);
    long end = System.currentTimeMillis();
    log.debug("r1: {} r2: {} cost: {}", r1, r2, end - start);
}

输出

20:48:01.320 [main] c.TestJoin - r1: 10 r2: 0 cost: 1010

当执行时间超时

static int r1 = 0;
static int r2 = 0;
public static void main(String[] args) throws InterruptedException {
    
    
    test3();
}
public static void test3() throws InterruptedException {
    
    
    Thread t1 = new Thread(() -> {
    
    
        sleep(2);
        r1 = 10;
    });
    long start = System.currentTimeMillis();
    t1.start();
    // 线程执行结束会导致 join 结束
    t1.join(1500);
    long end = System.currentTimeMillis();
    log.debug("r1: {} r2: {} cost: {}", r1, r2, end - start);
}

输出

20:52:15.623 [main] c.TestJoin - r1: 0 r2: 0 cost: 1502

4. interrupt方法详解

Interrupt说明

interrupt的本质是将线程的打断标记设为true,并调用线程的三个parker对象(C++实现级别)unpark该线程。

基于以上本质,有如下说明:

  • 打断线程不等于中断线程,有以下两种情况:
    • 打断正在运行中的线程并不会影响线程的运行,但如果线程监测到了打断标记为true,可以自行决定后续处理。
    • 打断阻塞中的线程会让此线程产生一个InterruptedException异常,结束线程的运行。但如果该异常被线程捕获住,该线程依然可以自行决定后续处理(终止运行,继续运行,做一些善后工作等等)

打断 sleep,wait,join 的线程

这几个方法都会让线程进入阻塞状态

打断 sleep 的线程, 会清空打断状态,以 sleep 为例

private static void test1() throws InterruptedException {
    
    
    Thread t1 = new Thread(()->{
    
    
        sleep(1);
    }, "t1");
    t1.start();
    sleep(0.5);
    t1.interrupt(); // 打断t1 线程
    log.debug(" 打断状态: {}", t1.isInterrupted());
}

输出

java.lang.InterruptedException: sleep interrupted
 at java.lang.Thread.sleep(Native Method)
 at java.lang.Thread.sleep(Thread.java:340)
 at java.util.concurrent.TimeUnit.sleep(TimeUnit.java:386)
 at cn.xiaozheng.n2.util.Sleeper.sleep(Sleeper.java:8)
 at cn.xiaozheng.n4.TestInterrupt.lambda$test1$3(TestInterrupt.java:59)
 at java.lang.Thread.run(Thread.java:745)
21:18:10.374 [main] c.TestInterrupt - 打断状态: false

打断正常运行的线程

打断正常运行的线程, 不会清空打断状态

private static void test2() throws InterruptedException {
    
    
    Thread t2 = new Thread(()->{
    
    
        while(true) {
    
    
            Thread current = Thread.currentThread();
            boolean interrupted = current.isInterrupted();
            if(interrupted) {
    
    
                log.debug(" 打断状态: {}", interrupted);
                break;
            }
        }
    }, "t2");
    t2.start();
    sleep(0.5);
    t2.interrupt();
}

输出

20:57:37.964 [t2] c.TestInterrupt - 打断状态: true

* 模式之两阶段终止

Two Phase Termination 在一个线程 T1 中如何“优雅”终止线程 T2?这里的【优雅】指的是给 T2 一个料理后事的机会。

错误思路

  • 使用线程对象的 stop() 方法停止线程
    • stop 方法会真正杀死线程,如果这时线程锁住了共享资源,那么当它被杀死后就再也没有机会释放锁, 其它线程将永远无法获取锁
  • 使用 System.exit(int) 方法停止线程
    • 目的仅是停止一个线程,但这种做法会让整个程序都停止

两阶段终止模式

无异常
有异常
while(true)
有没有被打断?
料理后事
结束循环
睡眠2S
执行监控记录
设置打断标记
利用 isInterrupted

interrupt 可以打断正在执行的线程,无论这个线程是在 sleep,wait,还是正常运行

class TPTInterrupt {
    
    
    private Thread thread;
    
    // 启动监控线程
    public void start(){
    
    
        thread = new Thread(() -> {
    
    
            while(true) {
    
    
                // 获取当前线程打断状态
                Thread current = Thread.currentThread();
                // 判断是否打断
                if(current.isInterrupted()) {
    
    
                    log.debug("料理后事");
                    break;
                }
                try {
    
    
                    Thread.sleep(1000); // 情况一 异常打断
                    log.debug("将结果保存"); // 情况二 正常打断
                } catch (InterruptedException e) {
    
    
                    // sleep 打断后 会清除打断标记,所以要重新设置
                    current.interrupt();
                    e.printStackTrace();
                }
                // 执行监控操作 
            }
        },"监控线程");
        thread.start();
    }
    public void stop() {
    
    
        thread.interrupt();
    }
}

调用

TPTInterrupt t = new TPTInterrupt();
t.start();
Thread.sleep(3500);
log.debug("stop");
t.stop();

结果

11:49:42.915 c.TwoPhaseTermination [监控线程] - 将结果保存
11:49:43.919 c.TwoPhaseTermination [监控线程] - 将结果保存
11:49:44.919 c.TwoPhaseTermination [监控线程] - 将结果保存
11:49:45.413 c.TestTwoPhaseTermination [main] - stop 
11:49:45.413 c.TwoPhaseTermination [监控线程] - 料理后事
利用停止标记
// 停止标记用 volatile 是为了保证该变量在多个线程之间的可见性
// 我们的例子中,即主线程把它修改为 true 对 t1 线程可见
class TPTVolatile {
    
    
    private Thread thread;
    private volatile boolean stop = false;
    public void start(){
    
    
        thread = new Thread(() -> {
    
    
            while(true) {
    
    
                Thread current = Thread.currentThread();
                if(stop) {
    
    
                    log.debug("料理后事");
                    break;
                }
                try {
    
    
                    Thread.sleep(1000);
                    log.debug("将结果保存");
                } catch (InterruptedException e) {
    
    
                }
                // 执行监控操作
            }
        },"监控线程");
        thread.start();
    }
    public void stop() {
    
    
        stop = true;
        thread.interrupt();
    }
}

调用

TPTVolatile t = new TPTVolatile();
t.start();
Thread.sleep(3500);
log.debug("stop");
t.stop();

结果

11:54:52.003 c.TPTVolatile [监控线程] - 将结果保存
11:54:53.006 c.TPTVolatile [监控线程] - 将结果保存
11:54:54.007 c.TPTVolatile [监控线程] - 将结果保存
11:54:54.502 c.TestTwoPhaseTermination [main] - stop 
11:54:54.502 c.TPTVolatile [监控线程] - 料理后事

打断 park 线程

打断 park 线程, 不会清空打断状态

private static void test3() throws InterruptedException {
    
    
    Thread t1 = new Thread(() -> {
    
    
        log.debug("park...");
        LockSupport.park();
        log.debug("unpark...");
        log.debug("打断状态:{}", Thread.currentThread().isInterrupted());
    }, "t1");
    t1.start();
    sleep(0.5);
    t1.interrupt();
}

输出

21:11:52.795 [t1] c.TestInterrupt - park... 
21:11:53.295 [t1] c.TestInterrupt - unpark... 
21:11:53.295 [t1] c.TestInterrupt - 打断状态:true 

如果打断标记已经是 true, 则 park 会失效

private static void test4() {
    
    
    Thread t1 = new Thread(() -> {
    
    
        for (int i = 0; i < 5; i++) {
    
    
            log.debug("park...");
            LockSupport.park();
            log.debug("打断状态:{}", Thread.currentThread().isInterrupted());
        }
    });
    t1.start();
    sleep(1);
    t1.interrupt();
}

输出

21:13:48.783 [Thread-0] c.TestInterrupt - park... 
21:13:49.809 [Thread-0] c.TestInterrupt - 打断状态:true 
21:13:49.812 [Thread-0] c.TestInterrupt - park... 
21:13:49.813 [Thread-0] c.TestInterrupt - 打断状态:true 
21:13:49.813 [Thread-0] c.TestInterrupt - park... 
21:13:49.813 [Thread-0] c.TestInterrupt - 打断状态:true 
21:13:49.813 [Thread-0] c.TestInterrupt - park... 
21:13:49.813 [Thread-0] c.TestInterrupt - 打断状态:true 
21:13:49.813 [Thread-0] c.TestInterrupt - park... 
21:13:49.813 [Thread-0] c.TestInterrupt - 打断状态:true 

提示 :可以使用 Thread.interrupted() 清除打断状态

5. 不推荐的方法

还有一些不推荐使用的方法,这些方法已过时,容易破坏同步代码块,造成线程死锁

方法名 static 功能说明
stop() 停止线程运行
suspend() 挂起(暂停)线程运行
resume() 恢复线程运行

6. 主线程与守护线程

默认情况下,Java 进程需要等待所有线程都运行结束,才会结束。有一种特殊的线程叫做守护线程,只要其它非守护线程运行结束了,即使守护线程的代码没有执行完,也会强制结束。

例:

log.debug("开始运行...");
Thread t1 = new Thread(() -> {
    
    
     log.debug("开始运行...");
     sleep(2);
     log.debug("运行结束...");
}, "daemon");
// 设置该线程为守护线程
t1.setDaemon(true);
t1.start();
sleep(1);
log.debug("运行结束...");

输出:

08:26:38.123 [main] c.TestDaemon - 开始运行... 
08:26:38.213 [daemon] c.TestDaemon - 开始运行... 
08:26:39.215 [main] c.TestDaemon - 运行结束... 

注意

  • 垃圾回收器线程就是一种守护线程
  • Tomcat 中的 Acceptor 和 Poller 线程都是守护线程,所以 Tomcat 接收到 shutdown 命令后,不会等待它们处理完当前请求

Guess you like

Origin blog.csdn.net/u013494827/article/details/125998256