Javaマルチスレッドと高い同時実行性を征服する-マルチスレッドの基本

この記事は、Javaマルチスレッドと高い同時実行性の知識を学ぶときに作成されたメモです。

この部分には多くのコンテンツがあり、内容に応じて5つの部分に分かれています。

  1. マルチスレッドの基本
  2. JUCの記事
  3. 同期コンテナと同時コンテナ
  4. スレッドプール
  5. MQの記事

この記事はマルチスレッドの基本的な記事です。

目次

1スレッドとは何ですか?

2スレッドステータス

3スレッドの作成

4スレッド同期

5同期

5.1Javaオブジェクトの構造

5.2同期ロックアップグレードプロセス

5.2.1バイアスロック

5.2.2軽量ロック(スピンロック)

5.2.3ヘビーウェイトロック

6揮発性

6.1volatileはスレッドの可視性を保証します

6.1.1スレッドの可視性を保証する必要があるのはなぜですか?

6.1.2 volatileはスレッドの可視性をどのように保証しますか?

6.2 volatileは、命令の並べ替えを禁止します

7スレッド間通信

7.1生産者と消費者の問題

7.2待機通知

7.3ポーリング中


1スレッドとは何ですか?

スレッドは、オペレーティングシステムのスケジューリングの基本単位です。

すべてのコンピューター情報処理は最終的にCPUによって完了し、各(シングルコア)CPUは同時に1つのスレッドからの要求のみを処理できます。

オペレーティングシステムはCPUのブローカーです。オペレーティングシステムはすべてのアプリケーションに次のように伝えます。「CPUは同時に1つのスレッドからの要求のみを処理できます。プロセスをスレッドごとに分割してから戻ることができます。」

アプリケーションプログラムが独自のプロセスを1つのスレッドに分割した後、オペレーティングシステムはいくつかのカーネルスレッドとアプリケーションスレッドを1対1で提供し、CPUにこれらのカーネルスレッドの要求を処理するように要求します。

図に示すように:

実行中のアプリケーションは1つのプロセスに対応し、1つのプロセスは複数のスレッドに対応します。

 

スレッドステータス

一般的に、スレッドには5つの状態があります。

  • 新しい状態(新規):スレッドオブジェクトが作成されると、新しい状態になります。
  • 準備完了状態(実行可能):新しいスレッドオブジェクトが開始されると、準備完了状態になります。準備完了状態のスレッドは、いつでもCPUによる実行をスケジュールできます。
  • 実行ステータス(実行中):スレッドはCPUによる実行がスケジュールされています。スレッドは準備完了状態からのみ実行状態に入ることができることに注意してください。
  • ブロック:スレッドは、何らかの理由でCPUを使用する権利を放棄し、一時的に実行を停止します。スレッドが準備完了状態になるまで、実行を継続する機会があります。
  • Dead状態(Dead):スレッドが実行を終了するか、例外のために終了すると、スレッドはそのライフサイクルを終了します。

Javaでスレッドを実装する場合、スレッドは6つの状態に分けられます。

  • NEW(新規):スレッドオブジェクトが作成されると、新しい状態になります。
  • RUNNABLE(run):Javaでは、スレッドの準備完了状態と実行状態がRUNNABLE状態に統合されます。
  • BLOCKED(ブロック):スレッドは一時的に実行を停止し、ロックリソースの取得を待機します。
  • WAITING(待機中):他のスレッドが特定のアクション(通知または割り込み)を実行するのを待機しています。
  • TIMED_WAITING(残業を待つ):他のスレッドが特定のアクション(通知または割り込み)を行うのを待つか、指定された時間後に準備完了状態になります。
  • TERMINATED(終了):スレッドの実行が終了しました。

 

3スレッドの作成

Javaでは、スレッドを作成するための2つの基本的な方法があります。

  • Threadクラスを継承します
  • Runnableインターフェースを実装する
public class CreateThread{
    static class MyThread extends Thread{ //继承Thread类
        @Override
        public void run(){ //重写run()方法
            System.out.println("Hello MyThread!");
        }
    }
 
    static class MyRunnable implements Runnable{ //实现Runnable接口
        @Override
        public void run(){ //重写run()方法
            System.out.println("Hello MyRunnable!");
        }
    }
 
    public static void main(String[] args){
        new MyThread().start(); //调用start()方法启动线程
        new Thread(new MyRunnable()).start(); //调用start()方法启动线程
    }
}

Callableインターフェースはjava.util.concurrentで提供され、Callableインターフェースを実装することによって作成されたスレッドは戻り値を持つことができます。

import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.FutureTask;

public class CreateThread {
    static class MyCallable implements Callable<Integer> { //泛型规定返回值类型
        @Override
        public Integer call() throws Exception { //重写call()方法,类似于Runnable接口中的run()方法
            System.out.println("Hello MyCallable");
            return 1024;
        }
    }

    public static void main(String[] args) throws ExecutionException, InterruptedException {
        MyCallable myCallable = new MyCallable();
        FutureTask futureTask = new FutureTask(myCallable); //适配器模式
        new Thread(futureTask).start(); //调用start()方法启动线程
        //打印返回值
        Integer result = (Integer) futureTask.get();
        System.out.println(result);
    }
}

さらに、Lambda式を使用してスレッドを作成することもできます。

public class CreateThread{
    public static void main(String[] args){
        new Thread(()->{
            System.out.println("Hello Lambda!");
        }).start();
    }
}

Threadクラスの一般的なメソッド:

sleep():スリープし、スレッドをブロッキング状態にし、他のスレッドに実行の機会を与えます。スリープ時間が終了すると、スレッドは準備完了状態になり、CPUリソースを他のスレッドと競合します。

yield():ポライトネス。スレッドを準備完了状態にし、CPUリソースを他のスレッドと競合させます。

join():マージ、現在のスレッドがブロックされ、最初に結合スレッドを実行してから、現在のスレッドの実行を続行します。スレッド間の順次実行を保証するために使用できます。

 

4スレッド同期

スレッドの同期:スレッドがメモリで動作している場合、スレッドの動作が完了するまで、他のスレッドはこのメモリアドレスで動作できません。スレッド操作が完了する前は、他のスレッドは待機状態になっています。

同期キーワードは、スレッドの同期を実現するためにJavaで使用されます。

同期キーワードの役割はオブジェクトをロックすることです。ロックは常に1つのスレッドのみが保持できます。

質問:同期はオブジェクトまたはコードをロックしますか?

回答:オブジェクトをロックします。たとえば、次のコードセグメントでは、正しい式は同期ロックoであり、スレッドはoを取得した後に中括弧{}でコードを実行できます。

public class Test{
    private int count = 10;
    private Object o = new Object();
    public void test(){
        synchronized(o){
            count--;
            System.out.println(Thread.currentThread().getName() + " count = " + count);
        }
    }
}

毎回新しいObjectオブジェクトを作成するのは面倒なので、上記のコードスニペットは次のように変更できます。

public class Test{
    private int count = 10;
    public synchronized void test(){
        count--;
        System.out.println(Thread.currentThread().getName() + " count = " + count);
    }
}

上記のプログラムでは、同期によってロックされたオブジェクトはこれです。

同期された変更済みメソッドが静的メソッド(静的)の場合、ロックされたオブジェクトはTest.classです。

 

同期

5.1Javaオブジェクトの構造

同期の基本的な実装について説明する前に、Javaオブジェクトの構造を理解する必要があります。

Javaオブジェクトはヒープ内で16バイトを占めます(配列オブジェクトを除く)。そのうちの1つは次のとおりです。

最初の8バイトはマークワードと呼ばれ、ロック情報、ハッシュコード、GC情報などを格納します。

ホットスポット(一般的に使用されるJVM)では、オブジェクトのマークワードに記録される情報は次の図のようになります。

9番目から12番目のバイトはklasspointerと呼ばれ、このオブジェクトに対応するクラスへのポインターです。

13〜16バイト目は、instancedata、instance data(メンバー変数)の場合があります。オブジェクトにインスタンスデータがない場合は、パディングとアラインメントを行います。オブジェクトの長さを8バイトで均等に割り切れる長さにする機能です。

(配列オブジェクトの場合、classpointerとinstancedataの間に追加のarraylength、つまり配列の長さがあります)

オブジェクトの同期ロックの概念を振り返ると、ロックの本質は、オブジェクトのマークワードにロック情報を記録することです。

では、記録されたロック情報は何ですか?

現在のスレッドへのポインタ。

5.2同期ロックアップグレードプロセス

初期の同期ロックの効率は非常に低かった(ヘビーウェイトロックのみが使用されていたため)。jdkの新しいバージョンでは最適化されていました。現在、同期ロックには、新しいバイアスロックと軽量ロック(スピンロックとも呼ばれます)の4つの状態が含まれています。 、ヘビーウェイトロック。

ロックが強いほど、より多くのシステムリソースが消費されるため、低レベルのロックで解決できる問題は、可能な限り低レベルのロックで解決する必要があります。下位レベルのロックで問題を解決できない場合、ロックは上位レベルのロックにアップグレードされます。

ロックのアップグレードプロセスは次のとおりです。

    新しく作成されたオブジェクト(新規)->バイアスロック->軽量ロック(スピンロック)->重量ロック

(ここには非常に厄介な概念があります:ロックフリー、非重量級ロックを指します、それを理解してください、この概念を使用することはお勧めしません)

実際、図に示すように、ロックのアップグレードプロセスはより複雑です。

5.2.1バイアスロック

バイアスされたロックは、ほとんどの場合、同期されたロックされたオブジェクトには使用するスレッドが1つしかないと考えています。

新しく作成されたオブジェクトにバイアスロックを与えます。現在のスレッドのポインタJavaThread *をオブジェクトのマークワードに記録します。

このオブジェクトを取得する他のスレッドがある限り(軽い競合)、バイアスされたロックは軽量ロックにアップグレードされます。

デフォルトでは、バイアスロックメカニズムは、JVMが4秒間実行された後に開始されます。開始が遅れる理由は、JVMの開始時にオブジェクトをめぐって競合する複数のスレッドが存在する必要があるためです。

バイアスロックメカニズムがアクティブになると、新しく作成されたすべてのオブジェクトがデフォルトでバイアスされます(マークワードのバイアスロック位置1)。オブジェクトのバイアスロックビットが1で、マークワードにスレッドポインタが記録されていない場合、その状態は匿名バイアスと呼ばれます。スレッドがオブジェクトをフェッチすると、スレッドポインタが記録され、匿名バイアスロックがバイアスロックにアップグレードされます。 。

5.2.2軽量ロック(スピンロック)

(激しい競争の下で)部分ロックは軽量ロックにアップグレードされます。最初にオブジェクトの部分ロック状態が取り消され、次に各スレッドが独自のスレッドスタックにLR(ロックレコード)を生成し、それをオブジェクトのマークワードに追加しようとします。独自のLRへのポインタ。どのスレッドがポインタを書き込み、どのスレッドがオブジェクトの軽量ロックを取得します。

軽量ロックはCASによって実装されます。

CAS:コンペア・アンド・スワップ、コンペア・アンド・スワップ。

図に示すように、システムはデータEを使用して演算を実行した後、現在のE値(図のN)と演算前のE値を比較して結果を書き戻します。これらが等しい場合は、演算を書き込みます。結果;そうでない場合、それらが等しい場合は、現在のE値を取得して計算を再実行し、プロセスが繰り返されます。

CASについては2つの古典的な質問があります。

(1)ABA問題:操作中に他のスレッドがE値を何度も変更しましたが、最終的なE値は操作前と同じであり、「このAは他のAではない」という問題が発生します。この問題を解決する方法は?

バージョン番号を追加します。

(2)演算の原子性:「E値を比較する前の演算と一致する」演算と「計算結果を書き戻す」演算の2つの演算の原子性はどのように保証されますか?

CASの最下層はアセンブリ言語です:lock cmpxchg

ロック命令:バスをロックします。命令の実行時にCPUが他のCPUによって中断されることはありません。

CASは本質的に継続的にループするプログラムであり、CPUリソースを消費するため、スレッドの競合が激しい場合は、軽量ロックが重量ロックにアップグレードされます。

5.2.3ヘビーウェイトロック

ヘビーウェイトロックは、激しく競合する複数のスレッドを待機キューに入れ、オペレーティングシステムがスレッドのスケジューリングを担当します。

待機キューに配置されたスレッドは、CPUリソースを占有しません。

どのような状況で軽量ロックを重量ロックにアップグレードできますか?

jdk1.6より前では、スレッドが10回以上回転するか、回転するスレッドの数がCPUコアの数の半分を超えると、ロックがアップグレードされます。

jdk1.6以降、アダプティブスピンはデフォルトで開始され、JVM自体がアップグレードするかどうかを制御します。

 

揮発性

Volatileは特性修飾子であり、コンパイラーの最適化によってこの命令が省略されないようにするための命令キーワードとして使用され、毎回直接読み取る必要があります。

たとえば、次のプログラム:

XBYTE[2]=55;
XBYTE[2]=56;
XBYTE[2]=57;
XBYTE[2]=58;

外部ハードウェアの場合、上記のステートメントはXBYTE [2]が4回割り当てられたことを意味しますが、コンパイラーはXBYTE [2] = 58のみが有効であると考えて、上記のステートメントを最適化します(つまり、最初の3つのステートメントのみを無視します)。マシンコードを生成します)。volatileと入力すると、コンパイラーは1つずつコンパイルし、対応するマシンコードを生成します(4つのマシンコードが生成されます)。

Volatileには2つの機能があります。スレッドの可視性を確保することと、命令の並べ替えを防ぐことです。

6.1volatileはスレッドの可視性を保証します

1つのスレッドによるメインメモリの変更は、他のスレッドによって時間内に監視できます。この機能は可視性と呼ばれます。

次に、2つの問題について説明します。

  • なぜスレッドの可視性を確保するのですか?
  • volatileはスレッドの可視性をどのように保証しますか?

6.1.1スレッドの可視性を保証する必要があるのはなぜですか?

まず、CPUがメインメモリから直接データを読み取るのではなく、その間にキャッシュする必要があることを知っておく必要があります。

キャッシュ(つまり、高速キャッシュ、キャッシュ)は、CPUとメインメモリの間に配置され、L1、L2、およびL3の3つのレベルに分割されます。CPUがメインメモリからデータを読み取るとき、最初に必要なデータをL1で検索します。L1がL2でデータを検出しない場合、L2はL3でデータを検出せず、L3にない場合は、メインからデータを読み取ります。メモリ。逆に、データは最初にメモリからL3に読み込まれ、次にL2に読み込まれ、次にL1に読み込まれます。

キャッシュがメインメモリからデータを読み取るとき(プログラムの局所性の原則に従って)、データはブロック単位で読み取られます。データの各ブロックはキャッシュラインと呼ばれます。キャッシュラインのサイズは通常64バイトです。したがって、メインメモリ内のデータ行は、複数のCPUのキャッシュによって同時に読み取られる可能性があります。特定のCPUがこのデータ行を変更した後、変更されたデータはメインメモリに書き込まれますが、他のCPUは引き続き独自のキャッシュから読み取ります。変更する前にデータを読み取りますが、そうではありません。

同じ行のデータが異なるCPUに読み込まれる場合、各CPUのデータに一貫性があることを確認する必要があります。

6.1.2 volatileはスレッドの可視性をどのように保証しますか?

スレッドの可視性を確保するためのvolatileの実装は、キャッシュライン間のデータの整合性を確保することです。

CPUがキャッシュライン間のデータ整合性を実現する方法は、MESIキャッシュ整合性プロトコルに準拠し、それでも障害が発生した場合はバスをロックすることです。

MESIキャッシュコヒーレンシプロトコルは、基盤となるIntelプロトコル、変更された変更、排他的排他、共有共有、無効な無効化です。

ロックバスは、アセンブリコマンドロックを使用します。

6.2 volatileは、命令の並べ替えを禁止します

CPUのアウトオブオーダー実行(命令の並べ替え):

CPUが2つの命令を実行する必要がある場合、最初の命令の実行は比較的遅く、2番目の命令の実行は比較的速いです。2つの命令が関連していない場合は、最初に2番目の命令を実行し、次に実行することができます。最初の命令を実行します。

CPUのシーケンス外実行は、単一スレッドでは問題を引き起こしませんが、複数スレッドでは問題を引き起こす可能性があります。

Volatileは、CPUが命令を並べ替えることを禁止します。実装方法はメモリバリアを追加することであり、メモリバリアの前後の命令を並べ替えることはできません。

JVMでは、次の4つのメモリバリアを実装する必要があります。

loadload:読み取り命令と読み取り命令の間のバリア。バリアより下の読み取り命令は、バリアより上のすべての読み取り命令が完了した後にのみ実行できます。

storestore:書き込み命令と書き込み命令の間のバリア。バリアより下の書き込み命令は、バリアより上のすべての書き込み命令が完了した後にのみ実行できます。

ロードストア:読み取り命令と書き込み命令の間のバリア。バリアより下の書き込み命令は、バリアより上のすべての読み取り命令が完了した後にのみ実行できます。

storeload:書き込み命令と読み取り命令の間のバリア。バリアより下の読み取り命令は、バリアより上のすべての書き込み命令が完了した後にのみ実行できます。

これらの4種類のメモリバリアは、組み立て手順を通じて下部に実装されています。

揮発性操作の前後のメモリバリア:

 

7スレッド間通信

7.1生産者と消費者の問題

生産者と消費者の問題:

複数のスレッドが同時に同じ変数番号を操作します。

  • プロデューサーが操作するたびに、number ++
  • 消費者が操作するたびに、数-

数値== 0の場合、消費者はそれを操作できません。

コード:

public class Test {
    public static void main(String[] args) {
        Data data = new Data();
        int n = 4; //消费者数目
        int k = 5; //每个消费者的消费额
        new Thread(() -> {
            for (int i = 0; i < n * k; i++) {
                try {
                    data.increment();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }, "Producer").start(); //生产者
        for (int id = 0; id < n; id++) {
            new Thread(() -> {
                for (int i = 0; i < k; i++) {
                    try {
                        data.decrement();
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }, "Consumer" + id).start(); //消费者们
        }
    }
}

class Data {
    private int number = 0;
    //synchronized方法
    public synchronized void increment() throws InterruptedException {
        while (number > 3) { //while轮询
            this.wait(); //线程等待
        }
        number++;
        System.out.println(Thread.currentThread().getName() + "=>" + number);
        this.notifyAll(); //通知其它线程
    }
    //synchronized方法
    public synchronized void decrement() throws InterruptedException {
        while (number <= 0) { //while轮询
            this.wait(); //线程等待
        }
        number--;
        System.out.println(Thread.currentThread().getName() + "=>" + number);
        this.notifyAll(); //通知其它线程
    }
}

生産者と消費者の問題における3つの重要なポイント:

  • 同期
  • 待つ通知する
  • ポーリング中

同期については詳しく説明しません。

7.2待機通知

wait():現在のスレッドを待機させます。notify()またはnotifyAll()メソッドを使用してウェイクアップできます。

wait()とsleep()の違い:

  • wait()はObjectクラスのメソッドであり、sleep()はThreadクラスのメソッドです。
  • wait()を呼び出すとロックが解放され(待機状態になります)、sleep()を呼び出すとロックが解放されません(ブロッキング状態になります)。
  • wait()は同期コードブロックでのみ使用できますが、sleep()はどこでも使用できます

notifyAll():待機中のすべてのスレッドをウェイクアップします。

notify():待機中のスレッドをウェイクアップします。

JDKソースコードのコメントでは、notify()がウェイクアップすることを選択するスレッドは任意であると言われていますが、それはJVMの特定の実装に依存します。

Hotspotのnotify()の実装は、順次ウェイクアップ、つまり「先入れ先出し」です。

7.3ポーリング中

wait()メソッドは常にループに表示されます。これは、マルチスレッドでの誤ったウェイクアップの問題を防ぐためです。

生産者/消費者モデルでは、ある時点で製品の数が0になる可能性があり、複数の消費者スレッドが待機しています。このとき、プロデューサーは製品を生産し、待機中のすべてのコンシューマースレッドが起動されますが、最終的には1つのコンシューマースレッドのみが製品を取得でき、起動時に有効になります。他のコンシューマースレッドは待機を継続できます。 awakenedは無効です。つまり、誤ったウェイクアップです。

 

ビデオリンクの学習:

https://www.bilibili.com/video/BV1xK4y1C7aT

加油!(d•_•)d

おすすめ

転載: blog.csdn.net/qq_42082161/article/details/113861872