Javaマルチスレッド-学習の概要(フルバージョン)

スレッドとプロセス

       このプロセスは、リソースのスケジューリングと割り当てのためのシステムの基本単位であり、オペレーティングシステムの基盤です。スレッドは、システムスケジューリングの最小単位であり、プロセスの計算単位です。プロセスには、1つ以上のスレッドが含まれる場合があります。

スレッドのライフサイクル

       スレッドには、新規、準備完了と実行、ブロック、待機、タイミング待機、破棄の6つのライフサイクルがあります。
ここに画像の説明を挿入

新着

       スレッドを作成するには、いくつかの方法があります。スレッドクラスの作成、Runnableインターフェイスの実装、CallableおよびFutureの作成

 # 1、thread
new Thread() {
	@Override
	 public void run() {
	 }
}.start();

# runnable
public class RunnableThread implements Runnable {
    @Override
    public void run() {
        System.out.println("runnable thread");
    }
    public static void main(String[] args){
        Thread t = new Thread(new RunnableThread());
        t.start();
    }
}

# Callable&Future
public class CallableThread implements Callable<Integer> {
    @Override
    public Integer call() throws Exception {
        System.out.println("Callable Thread return value");
        return 0;
    }
    public static void main(String[] args) throws ExecutionException, InterruptedException {
        FutureTask<Integer> future = new FutureTask<Integer>(new CallableThread());
        new Thread(future).start();
        System.out.println(future.get());
    }
}

実際、注意深く見ると、最終的にRunnableに実装されます。Threadのソースコードの一部を一緒に解釈してみましょう
。1。スレッドに上記の6つの状態があるのはなぜですか?これは、によって定義されたjava.langです。 threadThreadオブジェクト。.Thread.State列挙プロパティ
ここに画像の説明を挿入
各状態の意味と実装は英語で明確に説明されています。実際、私が最初にスレッドの学習を始めたとき、まだいくつかの質問がありました。なぜスレッドを使用する必要があるのか​​、スレッドを実行するstartメソッドとrunメソッドの違いは?次に、ソースコードフローを個人的に解釈しましょう。

线程初始化方法:
/**
     * Initializes a Thread.
     *
     * @param g 线程组,是维护线程树的对象,所有线程必须具备的属性要素,这里可以判断线程是否具有相应的权限,以及是否合法,线程状态,是否守护线程等;目标是维护一组线程和线程组,同时我们要注意的线程之前的通讯是局限于线程组,是一组线程中维护的线程**
     * @param target 运行线程的对象,线程执行时拿到的run或者call方法的目标对象 
     * @param name 当前线程名称
     * @param stackSize 新建线程时栈大小,当为0时可忽略
     * 
     * @param acc  上下文权限控制
     */
private void init(ThreadGroup g, Runnable target, String name,
                      long stackSize, AccessControlContext acc) {
        // ………… 省略部分代码
         /*获取安全管理策略,主要用来检查权限相关因素,若权限不满足时,抛出异常SecurityException,启动时是通过jvm参数设置[java.security.manager],具体可查看 [java API](https://docs.oracle.com/en/java/javase/15/docs/api/java.base/java/lang/SecurityManager.html)*/
        SecurityManager security = System.getSecurityManager();
        if (g == null) { // 当java.security.manager不设置时,这里为空
        	// 若需要安全管理策略,直接取得线程组
            if (security != null) {
                g = security.getThreadGroup();
            }
            // 不存在父级树寻找
            if (g == null) {
                g = parent.getThreadGroup();
            }
        }
        // 检查权限
        g.checkAccess();

        /*
        * 检测是否能被实力构造和重写
        */
        if (security != null) {
            if (isCCLOverridden(getClass())) {
                security.checkPermission(SUBCLASS_IMPLEMENTATION_PERMISSION);
            }
        }
        // 以便垃圾回收,增加未启动线程数
        g.addUnstarted();
		
		// 设置是否守护线程,线程优先级,安全控制,执行目标,堆栈长度以及线程id等
       	………… 省略部分代码
    }

次に、runの直接実行とstartメソッドの違いについて説明します。runの実行は、既存のJVMの現在のスレッド実行メソッド本体です。startの実行とは、jvmが配置されているプロセスにリソースを割り当て、スタックフレームスペースを作成して新しい実行ユニットを作成することです。スタックフレームスペースなどを割り当て、現在のスタックフレームスペースでThreadのrunメソッドを呼び出してから、runは着信ターゲットのrunメソッドを呼び出します(興味がある場合は、open jdkのstart0メソッドを解釈できます)。

# Thread#run
@Override
    public void run() {
        if (target != null) {
            target.run(); // runnable
        }
    }

Runable&Runnging

       新しいスレッドを作成してstartを実行すると、準備完了状態Runnableに入ります。runメソッドがスレッド内で呼び出されると、実行ステージRunningに入りますが、runメソッドを直接実行してもスレッドは開始されません。具体的な検証は次のとおりです。次のように。

public class RunStartThread extends Thread {
    public RunStartThread(String name) {
        super(name);
    }
    @Override
    public void run() {
        System.out.println(System.currentTimeMillis());
        try {
            sleep(2000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
    public static void main(String[] args) {
        RunStartThread rst = new RunStartThread("runThread");
        rst.run(); // 主线程运行run方法
        rst.start(); // 启动子线程运行run
    }
}

上記のコードのrunメソッドを実行して、以下に示すようにスレッドダンプを取得します。スレッド名が「main
ここに画像の説明を挿入
であることがわかります。startメソッドを実行すると、ダンプを再度取得すると、現在実行中のスレッド名が見つかります。は私のカスタムスレッド名runThreadです。さらに、startはrunnableのrunを直接呼び出すのではなく、ローカルスタックのstart0を呼び出して、jvmにスレッドスケジューリングを処理させることがわかりました。
ここに画像の説明を挿入

ブロックされた

       スレッドがブロック状態になると、通常は自動的に待機してから実行状態になるか、直接終了します。通常、次のコード例に示すように、ブロックは同期されます。したがって、開発中は主に同期を使用しないようにします。その理由は、キーの自動解放が制御できないためです。シングルスレッド操作では、同じオブジェクトを同時に実行することはできません。スレッドセーフプログラミングを本当に制御する必要がある場合は、ロックを使用してみてください。

public class BlockThreads {

   public static void main(String[] args) throws InterruptedException {
       TestThread th = new TestThread();
       th.runThread(th,"Thread1");
       th.runThread(th,"Thread2");
       th.runThread(th,"Thread3");
       System.out.println("111");
   }

   private static class TestThread {
       public synchronized void sayHello() throws InterruptedException {
           System.out.println(System.currentTimeMillis());
           Thread.sleep(3000);
       }

       public void runThread(TestThread th, String threadName) {
           new Thread(threadName) {
               @Override
               public void run() {
                   try {
                       th.sayHello();
                   } catch (InterruptedException e) {
                       e.printStackTrace();
                   }
               }
           }.start();
       }
   }
}

待っています

       スレッドを待機させるメソッドには、Object#wait(Object#notifyまたはObject#notifyAllリカバリ)、Thread#join、およびLockSupport#park(LockSupport#unpark)があり、待機状態のときにCPUがリソースを解放します。

待つ時間

       Object#wait(time)、LockSupport#parkNanos、およびLockSupport#parkUntil待機時間。実際、nginxチューニング、スレッド破棄時間チューニング、リクエスト同時実行タイムアウト設定など、多くのシナリオで待機時間の概念を使用しています。タイムアウト期間を効果的に設定すると、システムのスループットが向上します。

終了しました

       スレッドの破棄には、自動破棄と手動破棄が含まれます。自動破棄とは、スレッドがrunメソッドを実行した後、JVMがスレッドを破棄することを意味します。手動破棄はThread#stopメソッドを使用して破棄できますが、このメソッドは暴力的なメソッドであり、内部JVMが次のようになっている可能性があります。監視情報も監視できません。Thread#interrruptedメソッドは破棄判定を実行します。破棄できない場合は、InterruptedExceptionが発生します。

スレッドプールの概念とマルチスレッドの使用シナリオ

       スレッドは実行ユニットであり、スレッドプールは実行ユニットのグループで構成される集合体、つまりスレッドの使用方法です。スレッドプールは、スレッドの開始、取得、およびスケジュールのメカニズムを維持するために使用されます。マルチコアCPUおよびマルチタスクスケジューリングでは、スレッドプールを使用してマルチスレッドを処理し、CPUの高圧を制御しながらCPUの使用を増やし、パフォーマンスを向上させ、ブロッキングを回避できます。たとえば、SMS送信、httpリクエスト、時限タスク、非同期呼び出しなどです。

スレッドプールのパラメータ分析

       JDKに付属するスレッド作成オブジェクトはThreadPoolExecutorです。オブジェクトには、コアスレッド数、スレッドの最大数、スレッドの存続時間、スレッドファクトリ、スレッド拒否戦略など、いくつかのパラメータがあります。次のソースコード

 public ThreadPoolExecutor(int corePoolSize,
                             int maximumPoolSize,
                             long keepAliveTime,
                             TimeUnit unit,
                             BlockingQueue<Runnable> workQueue,
                             ThreadFactory threadFactory,
                             RejectedExecutionHandler handler)

いくつかの具体的な意味には、簡単なテキストの紹介があります。コアスレッドの数corePoolSizeは、実行中のスレッドの数です。同時にスレッドの数がコアスレッドの数よりも多い場合、スレッドは待機キューのworkQueueに入ります。待機キューが待機キューを超えると、新しい非コアスレッド(maximumPoolSize -corePoolSize)が実行されます。スレッド数がmaximumPoolSize + workQueue#sizeより大きい場合、拒否戦略が発生します。具体的な拒否戦略は次のようになります。後で説明します。美団の技術チームから引用したツメイグループの技術チーム
ここに画像の説明を挿入

スレッドプールブロッキングキューBlockingQueue

       JDKに付属する一般的な待機キューは、LinkedBlockingQueue、ArrayBlockingQueue、SynchronousQueueです(さらに、キューの長さは動的に変更できると述べられています。たとえば、LinkedBlockingQueueの容量は揮発性に設定されています)。

  1. LinkedBlockingQueueは、リンクリストノードストレージ、FIFOモデルであり、もちろんリンクリストタイプのストレージは無制限です。通常、これを設定すると、OOM例外が発生しない限り、長さが超えられないため、スレッドの最大数は基本的に無効になります。 、待機中のスレッドが多数ある同時実行性が高い状況で推奨されます。このブロッキングキューを使用してください。
  2. ArrayBlockingQueueは、待機キューの長さを指定します。このポイントは、待機キューを実現するためにデータキューをより正確に設定することです。
  3. SynchronousQueueにはキャッシュ待機キューがなく、キューは常に0です。この操作は通常、無制限の操作であり、CPU使用率を最大限に活用します。たとえば、Executors#newCachedThreadPoolは2番目のメソッドで実装されます。

スレッドプールファクトリThreadFactory

        一般的に使用されるオープンソースプロジェクトのスレッドプールファクトリには、CustomizableThreadFactory、ThreadFactoryBuilder、BasicThreadFactoryがあります。ファクトリメソッドは、主にスレッドの優先度とスレッド名、およびその他のスレッド属性を設定します。ここでは詳しく説明しません。主な簡単な実用例は次のとおりです。

ublic class ThreadFactoryTest implements ThreadFactory {
   private final AtomicInteger threadCount = new AtomicInteger(0);

   public static void main(String[] args) {
       ExecutorService executor = new ThreadPoolExecutor(2, 2, 60L, TimeUnit.SECONDS, new LinkedBlockingDeque<Runnable>(), new ThreadFactoryTest());
       executor.submit(() -> {
           System.out.println(String.format("thread-Name = %s,Thread priority = %d",
                   Thread.currentThread().getName(), Thread.currentThread().getPriority()));
       });
       executor.submit(() -> {
           System.out.println(String.format("thread-Name = %s,Thread priority = %d",
                   Thread.currentThread().getName(), Thread.currentThread().getPriority()));
       });
   }
/**
* @param r 传入的线程
**/
   @Override
   public Thread newThread(Runnable r) {
       Thread th = new Thread(r);
       th.setPriority(2);
       th.setName("设置线程名前缀" + this.threadCount.incrementAndGet());
       return th;
   }
}

スレッドプール拒否戦略RejectedExecutionHandler

       一般的なJDK拒否ポリシーAbortPolicyはポリシーを中止し、CallerRunsPolicyが超過すると実行を実行し、DiscardPolicyが超過すると破棄し、DiscardOldestPolicyはキューの最後のポリシーを破棄します。
ここに画像の説明を挿入

JDKエグゼキュータスレッドプールのいくつかの実装方法

       エグゼキュータはツール(通常は配列、システム、コレクション、オブジェクトなどのツールであり、意味がありません。エグゼキュータはスレッドメソッドnewFixedThreadPool、newCachedThreadPool(ThreadPoolExecutorメインソースコード分析)、newScheduledThreadPool、newSingleThreadExecutor、newSingleThreadScheduledを作成します。

作成タイプの簡単な分析

newCachedThreadPool

newCachedThreadPoolは、実現する2つの構築方法を提供します。1つはパラメーターなしでExecutors#newCachedThreadPool();を構築する方法、もう1つはパラメーターを使用してExecutors#newCachedThreadPool(ThreadFactory threadFactory)を構築する方法です。
利点:コアスレッドの数が0に設定されます。CPUがアイドル状態になると、スレッドはすぐに実行状態になります。待機キューは無制限の同期待機キューSynchronousQueueであり、複数のCPUの場合に十分に活用されます。実際、上記の2つの要素を組み合わせると、最大スレッド数が数値設定は無効と同等ですので、マルチコアCPUの特性を生かしてください。

public static ExecutorService newCachedThreadPool() {
       return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
                                     60L, TimeUnit.SECONDS,
                                     new SynchronousQueue<Runnable>());
   }

短所:同時実行性が高いと、CPUが100%を占有し、他のスレッドタスクを処理できなくなり、CPUの長期的な高圧が熱くなり、高温になります。私たちのシステムでは、CPU使用率が80%を超えないようにし、通常の使用率が60%を超えないようにすることをお勧めします。2つの実行方法と追加のソースコード分析を紹介しましょう

newFixedThreadPool(追加のソースコード分析)

Executors#newFixedThreadPool固定サイズのスレッドプール。つまり、コアスレッドの数をスレッドの最大数と同じに設定します。待機キューは、無制限の線形待機キューです。ここでの利点は、スレッドの再利用を最大限に活用することです。もちろん、その理由については、ソースコードの証言を解釈する
必要があります。AbstractExecutorService#submitメソッドはタスクFutureTaskを作成し、ThreadPoolExecutor#executeを呼び出します。

public Future<?> submit(Runnable task) {
       if (task == null) throw new NullPointerException();
       RunnableFuture<Void> ftask = newTaskFor(task, null); // 新建future任务
       execute(ftask);
       return ftask;
   }

次に、ThreadPoolExecutor#executorの実行メソッドの分析に焦点を当てます。これは3つのステップに分かれています(ここで説明するポイント、スレッドの数と状態はAtomicIntegerの32ビットCTLを通過し、上位3ビットは状態の保持、および低い29はスレッドプールの最大数です):

int c = ctl.get();/* 获取主线程状态控制29位变量 */
       if (workerCountOf(c) < corePoolSize) { /* 前29位作为统计线程数,判断worker是否大于核心线程数 */
           if (addWorker(command, true)) /* 添加worker工作线程,若新建成功则执行线程,并返回true */
               return;
           c = ctl.get(); /* 再次检测当前线程地位29 */
       }
       if (isRunning(c) && workQueue.offer(command)) { // worker无法获取和创建,插入等待队列
           int recheck = ctl.get(); // 再次检测线程池worker大小
           if (! isRunning(recheck) && remove(command))  // 若线程池不可运行状态,且移除当前线程成功,则拒绝策略
               reject(command);
           else if (workerCountOf(recheck) == 0) //  若当前没有线程worker,即核心线程为0,则立即执行队列
               addWorker(null, false);
       }
       else if (!addWorker(command, false)) // 队列已经满了,则直接添加非核心线程并运行
           reject(command); // 运行或者创建非核心线程失败,则拒绝策略

上記の分析から、スレッドプールの状態が上位3ビットに保存され、下位29ビットが実行中のスレッドの数を保存していることがわかります。

次に、ワーカーとタスクに焦点を当てます

 private boolean addWorker(Runnable firstTask, boolean core) {
   	retry:
   	for (;;) {
   		// ……………… 省略部分代码
   		for (;;) {
   			// ……………… 省略部分代码
   			// 这里判断最大worker数,查看是核心线程还是最大线程,若超出范围直接返回创建worker失败
   			if (wc >= CAPACITY ||
                   wc >= (core ? corePoolSize : maximumPoolSize))
                   return false;
   			if (compareAndIncrementWorkerCount(c)) // 创建worker前检测后并增加运行线程数
                   break retry;
   		}
   	}
   	// …………
       Worker w = null;
       try {
       	// 新建worker,同时调用ThreadFactory的newThread进而线程池的参数,比如参数名称等
           w = new Worker(firstTask); 
           final Thread t = w.thread;
           if (t != null) {
               final ReentrantLock mainLock = this.mainLock; // 获取线程池锁
               mainLock.lock();
               try {
                   int rs = runStateOf(ctl.get());  
                   // 判断是否有效范围内
                   if (rs < SHUTDOWN ||
                       (rs == SHUTDOWN && firstTask == null)) {
                       if (t.isAlive()) // precheck that t is startable
                           throw new IllegalThreadStateException();
                       workers.add(w);
                       int s = workers.size();
                       if (s > largestPoolSize)
                           largestPoolSize = s;
                       workerAdded = true;
                   }
               } finally {
                   mainLock.unlock();
               }
               if (workerAdded) {
                   t.start();  // 创建worker成功后直接调用线程的start方法执行线程,并且返回成功,调用worker启动后将会执行run方法。
                   workerStarted = true;
               }
           }
       } finally {
           if (! workerStarted)
               addWorkerFailed(w);
       }
       return workerStarted;
}

コード分​​析後、ワーカーは最終的にタスクのスケジューリングを担当します。スレッドの数がコアスレッドの数よりも多い場合、ワーカーによって渡されたスレッドは空になり、実行されるタスクはキューのworkQueueに配置されます。 (つまり、新しいワーカー->ワーカーの開始-> runWork-> getTask-> runTask)、真実は次のようにrunWorkerメソッドにあります。送信優先度の概念」があり、コアスレッドタスクが最初に実行されます!= null、次にgetTask()が実行されます。これは、キューオーバーフローのあるスレッドが実際に最初に実行されることを意味します。上記のコード実行はオーバーフローキューを説明しているため、直接addworkerはキューの追加よりも直接優先されます。

/*
worker启动时,委托给主线程的runWorker
*/
 public void run() {
            runWorker(this);
        }



 final void runWorker(Worker w) {
// ………省略部分代码
  // 获取当前任务,当时非核心时且添加进队列时为null,需要从队列中获取
  Runnable task = w.firstTask;
// …………
/* 当任务为空,且队列也不为空是,不执行 */
 while (task != null || (task = getTask()) != null) {
	try {
                        task.run();
                    } catch (RuntimeException x) {
                        thrown = x; throw x;
                    } catch (Error x) {
                        thrown = x; throw x;
                    } catch (Throwable x) {
                        thrown = x; throw new Error(x);
                    } finally {
                        afterExecute(task, thrown);
                    }
	} finally {
                    task = null; //这个地方非常关键,执行完后队列中查找。
                    w.completedTasks++;
                    w.unlock();
    }
}

スレッドプールパラメータ設定方式

一般的に考慮される2つの要因は、CPUとIOです。一般的なパラメータ設定はCPUとIOを集中的に使用しますが、ビジネスにかかる時間、tps、その他の割り当て要因など、実際のビジネスシナリオと組み合わせて分析する必要があります。コアスレッドは合理的にパラメータを動的に設定するのが最善です(JDKは、コアスレッドの数、スレッドの最大数、待機キューの長さの動的調整と、Meituanなどのapollo構成センターの動的更新を組み合わせてサポートします。技術チーム
。CPUを集中的に使用しますコアスレッドのはCPUの有効数+1に設定され、スレッドの最大数はCPUの有効数+1の2倍に設定されます。これにより、アニメーションが中断される場合があります。
IO集約型とは、次のように、コアスレッド数が2×実効CPU数、最大スレッド数が25×実効CPU数であることを意味します。ここに画像の説明を挿入

動的に設定されたパラメータ

スレッドプールのサイズを動的に設定すると、ピークの問題を処理し、スレッドプールデータを調整できます。採用された方法は、コアスレッドの数を動的に設定するThreadPoolExecutor#setCorePoolSizeです。InterruptIdleWorkersは、リソースを占有するためにアイドル状態のワーカーをクリアできます。次のコード:

public void setCorePoolSize(int corePoolSize) {
       if (corePoolSize < 0)
           throw new IllegalArgumentException();
       int delta = corePoolSize - this.corePoolSize; // 设置的核心线程数和原来的差值
       this.corePoolSize = corePoolSize;
       if (workerCountOf(ctl.get()) > corePoolSize)  // 工作worker是否大于设置的核心线程数,如果大于则当worker空余时清空。
           interruptIdleWorkers();  // 这方法其实很重要,我们可以用来设置回收没有使用的核心线程数,
       else if (delta > 0) {  // 若设置线程数大于原有线程数,则看队列是否有等待线程,如果有则直接循环创建worker并执行task任务,知道worker大于最大线程数或者队列已空
           // We don't really know how many new threads are "needed".
           // As a heuristic, prestart enough new workers (up to new
           // core size) to handle the current number of tasks in
           // queue, but stop if queue becomes empty while doing so.
           int k = Math.min(delta, workQueue.size());
           while (k-- > 0 && addWorker(null, true)) {
               if (workQueue.isEmpty())
                   break;
           }
       }
   }

まとめと考察

1. addWorker(Runnable firstTask、boolean core)新しいワーカーを作成します。このメソッドのパラメーターfirstTaskが空の場合、予熱の機能はスプリング遅延読み込みに似ています。2.setCorePoolSize
はコアスレッドの数を動的に設定し
ます。3。setMaximumPoolSizeは動的にスレッドの最大数を設定します
4、CPUとIOを集中的に使用します
5、runWorkerワーカー実行タスク
6、interruptIdleWorkersはアイドル状態のコアスレッドを破棄し
ます7、実行者はスレッドメソッドを作成しますnewFixedThreadPool、newCachedThreadPool、newScheduledThreadPool、newSingleThreadExecutor
8、実行と送信優先度と2つの違いは、戻り値などがあることです。
9.スレッドを作成するいくつかの方法:スレッド、実行可能、呼び出し可能、未来、およびスレッドのステータス、JVMスタックデバッグの使用
10.一般的なブロッキングスレッドLinkedBlockingQueue、ArrayBlockingQueue、SynchronousQueue
11.一般的なスレッドファクトリCustomizableThreadFactory、ThreadFactoryBuilder、BasicThreadFactoryおよび目的
12.RejectedExceptionHandler AbortPolicy、CallerRunsPolicy、DiscardPolicy、DiscardOldestPolicyのいくつかの一般的な実装クラス
13.コアスレッドの数とスレッドの最大数およびブロッキングキューを動的に設定する方法
14.ThreadGroupとsecurityManagerの意味は何ですか
15.フォローアップの追加............。 ....。

SpringBootはスレッドプールを使用します

       ここでの制作はシンプルで実用的であり、詳細な使用法は実際のシーンと組み合わせる必要があります。


@SpringBootTest
@EnableAsync
class PoolApplicationTests {

   @Autowired
   private PoolService poolService;

   @Test
   void contextLoads() {
       poolService.say1();
       poolService.say2();
   }
}

@Service
public class PoolService {
   @Value("${spring.pool.core.size:5}")
   private int coreSize;
   @Value("${spring.pool.max.size:10}")
   private int maxNumSize;

   /**
    * 自定义线程池
    *
    * @return
    */
   @Bean("executor")
   public Executor executor() {
       ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
       executor.setCorePoolSize(coreSize);
       executor.setMaxPoolSize(maxNumSize);
       executor.setQueueCapacity(20);
       executor.initialize();
       return executor;
   }

   /**
    * 在目标线程池执行任务
    */
   @Async(value = "executor")
   public void say1() {
       System.out.println(Thread.currentThread().getName());
   }

   @Async(value = "executor")
   public void say2() {
       System.out.println(Thread.currentThread().getName());
   }
}

参照

[1] JDK1.8ソースコード
[2] Meituan技術チーム(部分的な写真の引用と知識ポイント)
[3]スレッドとプロセスBaidu百科事典

おすすめ

転載: blog.csdn.net/soft_z1302/article/details/110440449