スレッドセーフとスレッドセーフではないときのメモリ領域の動き

はじめに

こんにちは。株式会社divxのエンジニア大久保です。

本記事では「スレッドセーフ」と「スレッドセーフではない」ときの「メモリ領域の動き」についてフォーカスを当てて解説します。

突然ですが「スレッドセーフ」と「スレッドセーフではない」ときの違いについて皆さんはご存じですか?

  • とにかくスレッドがセーフなんだよ

  • 並行処理ができるかできないかでしょ?

など僕も完全には答えられません。

表面的な言葉では理解できていても、仕組みを理解していないと人に明示的に説明ができません。

プログラミング言語のキャッチアップをするときは、言葉だけ理解するよりも、仕組みを理解することが応用の効く知識にしていく第一歩だと思います。

とくに今の時代のプログラミングは、裏側でどのように動いているのか意識しなくても成り立ちます。

だからこそプログラミングがどのように裏で動いているのか?

仕組みや根源的な技術を理解しておくと、ほかの言語やフレームワークを学ぶことになったとしても、ゼロから理解する必要がなくなり、キャッチアップするのが早くなります。

スレッドセーフとは

マルチスレッドの環境で実行されても内部データへのアクセスが競合せずに、常に内部データの整合性が保たれているように対策されている状態のことです。

マルチスレッドとは、複数のスレッドが同時に動くことです。CPUが1つしかないコンピューターでマルチスレッドのプログラムを実行するには、実行するスレッドを切り替えながら処理していくことになります。

スレッドとは実行される命令の列のことを指します。CPUは1つ1つの命令を順番に読み込み、解釈しながら実行します。

通常Javaプログラムはmainメソッドから1行ずつ順番に実行されています。mainメソッドから始まる実行経路をmainスレッドといいます。

スレッドセーフな変数

変数にはスレッドセーフな変数があり、それをローカル変数といいます。ローカル変数はスタック領域と呼ばれるメモリ空間上の領域にデータが保存されるので、1つのスレッドからしかアクセスすることができません。そのため他のスレッドが情報を更新したり、情報を取り違えて参照したりすることがありません。

スレッドセーフではないとは

マルチスレッドの環境で実行している際に、内部データへのアクセスが競合してしまい、内部データの整合性が保たれていない状態のことです。

複数のスレッドで共有している内容を、書き換える処理を含む場合があります。あるスレッドが書き換え処理を行っている途中に別のスレッドからも書き換えが行われ、内容が破損したり、失われたりします。このような複数スレッド間で競合が起きてしまうのはスレッドセーフではない状態ということになります。

スレッドセーフではない変数

インスタンス変数はスレッドセーフではありません。複数のスレッドで共有されるヒープ領域と呼ばれるメモリの領域にデータが保持されます。

スレッドセーフではない変数は複数のスレッドで共有されています。他のスレッドが情報を書き換えたり、情報を取り違えて参照したりしてしまうことがあります。

インスタンス変数とはインスタンスごとに固有の変数のことです。オブジェクトの状態を表す属性を保持するためのフィールドです。

マルチスレッドのメリット

プログラムをスレッドセーフにすることによって、内部データの整合性を考慮したりする必要がないので、マルチスレッド環境を実現できるようになります。

マルチスレッドにするメリットとして以下があります。

  • プロセッサを効率的に使用できる
  • システムリソースの節約
  • 設計を単純化できる

プロセッサを効率的に使用できる

プロセッサを同時に使用することによってスループットの向上が見込めます。スループットとは単位時間あたりに処理できる仕事量を意味します。

プロセッサを同時に使用して、演算および入出力の処理を行うことができます。

システムリソースの節約

スレッドがシステムリソースに及ぼす負荷を最低限に抑えられます。

スレッドでは、従来のプロセスに比べて、生成、保持、管理するために必要なオーバーヘッドが軽減されます。プロセスとはプログラムの実行単位のことです。

設計を単純化できる

単一の複雑な処理を、複数の単純な逐次処理に置き換えることができます。また、非同期処理を複数の同期処理で置き換えることがでます。

マルチスレッドのデメリット

デメリットはないように感じますが、マルチスレッドには以下のリスクもあります。

  • 意図しない実行結果になる
  • 開発時やテスト時にエラーが発生
  • 実行するスレッドが多すぎると実行効率が下がる

意図しない実行結果になる

同期化が上手くいっていない場合、複数のスレッドの実行順が予測不可能となり、意図しない実行結果となってしまいます。

開発時やテスト時にエラーが発生

動作中のコンピューターやソフトウェアの機能を停止して、操作を受け付けなくなったり、外部からの通信に応答しなくなるエラーが発生します。また、複数の実行中のプログラムなどが、互いに他のプログラムの結果待ちとなり、待機状態に入ったまま動かなくなる可能性があります。

実行するスレッドが多すぎると実行効率が下がる

実行するスレッドが多すぎるとスレッドの管理にCPUリソースを多く割く必要が出てくるため、逆に実行効率が低下してしまいます。

スレッドセーフとスレッドセーフではないときのメモリの動き

それでは実際にメモリの動きを見ていきたいと思います。その前にJavaのメモリ領域の説明を簡単にしいたします。

JVMには大きく分けてスタック領域とヒープ領域、そして静的領域という仮想メモリ領域があります。

スタック領域とは

保存が必要な期間だけメモリ領域を確保し、不要になったら解放するように処理が行われます。

メソッドの中で宣言されたローカル変数はスタック領域に格納され、スタック領域はスレッドごとに固有の空間になります。

プリミティブ型の変数(変数で保持している値)はスタック領域で処理されます。

ヒープ領域とは

動的に確保と解放を繰り返せるメモリ領域のことです。プログラムの実行時には、OSからソフトウェアに対して一定量のヒープ領域が与えられます。

参照型変数の場合、保持する値(インスタンス)はヒープ領域に作成します。スタック領域には、その保持した値(インスタンス)への参照が格納されます。また、ヒープ領域は、全スレッドで共有して使用する空間になります。

静的領域とは

プログラム開始時に確保され、プログラムが終了するまで配置が固定され、グローバル変数などの静的変数が置かれます。

スレッドセーフではないプログラム

では、実際にスレッドセーフではないプログラムを実行します。

このプログラムの出力結果は10000を期待しています。しかし、valueを複数のスレッドで共有しているため、意図しない結果が出力されてしまいます。

スレッドセーフではないプログラム実行の流れ

  • 複数スレッドで共有する変数valueの作成
  • 10000個のスレッドの生成とインスタンスの生成
  • for文でThreadをstart()で実行させてincrement()でvalueの値を更新
  • Thread終了まで待機し、例外処理をcatch
  • printlnで結果nosafe.valueを出力
public class NoSafe {

  private int value = 0; //value作成

  public static void main(String[] args) { //10000個のスレッドの生成とインスタンスの生成
    final int NUM = 10000;
    var thread = new Thread[NUM];
    var nosafe = new NoSafe();

    for (var i = 0; i < NUM; i++) { //スレッドを開始
      thread[i] = new Thread(() -> {
        nosafe.increment();
      });
      thread[i].start(); 
    }

    for (var i = 0; i < NUM; i++) {
      try {
        thread[i].join(); //スレッドが終了するのを待機
      } catch (InterruptedException e) { //待機状態の際にスローされる
        e.printStackTrace();//例外情報を出力ストリームに出力
      }
    }

    System.out.println(nosafe.value); //valueを出力
  }

  void increment() { //値を加算
    this.value++;
  }
}
出力結果 → 9999

プログラムの出力結果10000を期待しているのに、9999と出力されてしまいました。この結果は処理を実行する度に、毎回異なる値がプログラムから出力されてしまいます。

incrementメソッドではフィールドのvalueの値をインクリメント演算子で加算しています。処理の途中で他のスレッドの割り込みが発生すると、実行結果が正しく反映されません。

スレッドセーフなプログラム

次はスレッドセーフなプログラムを実行してみます。

さきほどのスレッドセーフではないプログラムを、スレッドセーフなプログラムに修正したいので、incrementメソッドにSynchronizedを追加します。

スレッドセーフではないプログラムは、意図していない結果が出力されたのに対して、スレッドセーフなプログラムでは、10000という値が意図通りに出力されているのがわかります。

Synchronized Methodsとは

synchronizedをメソッドに付与して同期させます。1つのスレッドが同期メソッドを処理しているときに、その他のスレッドは実行を一時停止するので、複数のスレッドから同時に呼び出されなくなります。

スレッドセーフなプログラム実行の流れ

  • 複数スレッドで共有する変数valueの作成
  • 10000個のスレッドの生成とインスタンスの生成
  • synchronizedメソッドで同期させる
  • for文でThreadをstart()で実行させてincrement()でvalueの値を更新
  • Thread終了まで待機し、例外処理をcatch
  • System.out.printlnでnosafe.valueの処理結果を出力
public class Safe {
  private int value = 0;

  public static void main(String[] args) {
    final int NUM = 10000;
    var thread = new Thread[NUM];
    var safe = new Safe();
 
    for (var i = 0; i < NUM; i++) {
      thread[i] = new Thread(() -> {
        safe.increment();
      });
      thread[i].start();
    }
    for (var i = 0; i < NUM; i++) {
      try {
        thread[i].join();
      } catch (InterruptedException e) {
        e.printStackTrace();
      }
    }

    System.out.println(safe.value); 
  }

  void increment() {
        synchronized(this) {
    this.value++;
   }
 }
}
出力結果 → 10000

スレッドセーフなプログラムでは、incrementメソッドにsynchronizedを付与することで、スレッドを同期させました。スレッドを同期させることにより、期待通りに10000が出力することに成功しました。

次はスレッドセーフとスレッドセーフではないときのメモリの違いや、処理にかかる時間の差を確認します。

ヒープサイズの取得

Javaプログラムでは、ヒープサイズを実際に確認できます。Javaのヒープサイズとは、ヒープ領域の大きさでのことで、初期サイズ(-Xms)と最大サイズ(-Xmx)を設定できます。

public class Main {
    
    public static void main(String[] args) {
        
        long total = Runtime.getRuntime().totalMemory();
        long free = Runtime.getRuntime().freeMemory();
        long max = Runtime.getRuntime().maxMemory();
        System.out.println("total: " + total);
        System.out.println("free: " + free);
        System.out.println("max: " + max);
    }
}
  • totalMemory = -Xmsで割り当てる初期サイズのヒープサイズ
  • freeMemory = totalMemoryから現在の使用領域を引いたサイズ
  • maxMemory = -Xmxにあたる最大サイズのヒープサイズ

実際にメモリの使用量を確認します。

ヒープサイズ初期値

total: 268435456
free: 266881496
max:  4294967296

ヒープサイズ NoSafe(スレッドセーフではない)プログラムを実行した直後

total: 268435456
free: 266881504
max:  4294967296

ヒープサイズ Safe(スレッドセーフ)プログラムを実行した直後 

total: 268435456
free: 266881488
max:  4294967296

◎実行結果

NoSafeプログラムの方が、Safeプログラムよりも消費メモリが少ないという結果が出力されました。

◎実行速度

NoSafeプログラムの実行から出力までかかった時間は約3秒で、Safeプログラムは実行から出力までかかった時間は約4秒でした。

大きい数値で検証

さきほどのスレッドセーフとスレッドセーフではないプログラムNUMの値10000を、今度は1052301という中途半端な値に変更して検証します。

ヒープサイズ 初期値

total: 268435456
free: 266881496
max:  4294967296

ヒープサイズ NoSafeプログラムを実行した直後 1.25分

total: 268435456
free: 266881504
max: 4294967296

ヒープサイズ Safeプログラムを実行した直後 1.26分

total: 268435456
free: 266881488
max: 4294967296

◎実行結果

スレッドの実行数値を変えても、1回目と2回目のスレッドセーフのメモリ消費量と、1回目と2回目のスレッドセーフではないプログラムのメモリ消費量は変わりませんでした。

◎実行速度

  • NoSafeプログラムの実行から出力までかかった時間は約1分25秒
  • Safeプログラムは実行から出力までかかった時間は約1分26秒

NoSafeプログラムと、Safeプログラムの処理する時間がに、大きな差が生まれることはありませんでした。

◎結論

今回の実行環境では、スレッドセーフなプログラムの処理と、スレッドセーフではないプログラムの処理にかかる時間や、消費するヒープサイズにも大きな差は生まれませんでした。

※今回のプログラムは大きな差を生み難いプログラムでしたので、開発環境やプログラムによっては結果は異なる場合があります。

終わりに

本記事では、スレッドセーフとスレッドセーフではないプログラムのメモリの動きにフォーカスを当てて、紹介してきました。

今まではメモリの動きなんて気にしたことがない方にも、新たな視点でプログラムを書くきっかけになったのではないでしょうか。

また、仕組みや根源的な技術を理解するための一助になる記事となれば嬉しいです。

divxでは一緒に働ける仲間を募集しています。 興味があるかたはぜひ採用ページを御覧ください。 divx.co.jp

最後までお読み頂きまして、ありがとうございました。

実行環境

% java -version
openjdk version "18.0.1.1" 2022-04-22
OpenJDK Runtime Environment Homebrew (build 18.0.1.1+0)
OpenJDK 64-Bit Server VM Homebrew (build 18.0.1.1+0, mixed mode, sharing)

MacBook Air(M1,2020)

Mac OS Montereyバージョン11.6.4

参考

Oracle Java Documentation

Java Platform, Standard Edition 8

基礎からわかるTCP/IPネットワークコンピューティング入門(書籍)

スッキリわかるJava入門実践編第3版(書籍)

インスタンスを作るって言うけど、パソコンのどこにできるの?