ArrayListをスレッドセーフ化するには?

ArrayListをスレッドセーフ化するには?

前回は、DateFormatクラスがマルチスレッド問題によってバグを引き起こす例を紹介した。マルチスレッドによる問題に対処するには、スレッドごとにインスタンスを作成するか、synchronizedブロックによる同期化を行う。でも「正しい同期化」を行うには十分な知識とコードの把握が不可欠・・・。

そんな時に重宝するのが、Java標準で用意されている同期化オブジェクト。どんなケースにも対応できるわけではないけど、お手軽かつ必要十分な機能を備えている。これを使わない手はない。

関連記事:

スレッドセーフとは?

スレッドセーフ(Thread Safe)とは、Javaの複数スレッドからインスタンスを同時に操作しても不整合が起きない状態のことを言う。

スレッドセーフなクラスは、すべてのメソッドがスレッドセーフで、1つのインスタンスを複数スレッドから操作(メソッド呼び出し)をしても問題が起きることがない。

一方、スレッドセーフではないクラスには、スレッドセーフでないメソッドが含まれている。1つのインスタンスを複数スレッドから操作(メソッド呼び出し)すると、インスタンス内のデータが破損したり、予期せぬ例外が起きたりするので安心して使うことはできない。

JavaのArrayListは、スレッドセーフではないクラスだ。

第3の同期化方法

前回は同期化の方法として、以下2通りの方法を紹介した。

前回記事 本番リリース後にトラブル発生!魔のJavaマルチスレッド問題とは!?

  • [方法A]スレッドごとにクラスのインスタンスを生成
  • [方法B]クラスのメソッド呼び出しの同期化

今回紹介するのは、同期化オブジェクトによりラップするという方法。実はこの方法は、分類としては[方法B]の中に入る。ArrayListクラスのJavaDocを見てみると、そのやり方が記載されている。

この実装は同期化されません。複数のスレッドが同時に ArrayList のインスタンスにアクセスし、1 つ以上のスレッドが構造的にリストを変更する場合には、リストを外部的に同期化する必要があります。構造的な変更とは、1 つ以上の要素を追加または削除したり、基になる配列のサイズを明示的に変更したりする処理のことです。 要素の値だけを設定する処理は、構造的な変更ではありません。通常、リストの同期をとるには、リストを自然にカプセル化するオブジェクトで同期をとります。 この種のオブジェクトがない場合には、Collections.synchronizedList メソッドを使用してリストを「ラップ」する必要があります。これは、リストへの偶発的な非同期アクセスを防ぐために、作成時に行うのが最適です。 ArrayListのJavaDoc

ArrayListのJavaDoc

ArrayListの場合はCollectionsクラスのメソッドを使うことで、同期化オブジェクトによるラップを行うことができる。ArrayList以外では、HashMapやHashSetなどJava標準のコレクションクラスに対しても、この方法を使うことができる。使うのも簡単だ。

同期化オブジェクトによるラップとは?

まずはArrayListを使用してスレッド問題が発生するServletを例として挙げてみたい。このServletは、アクセスするとブラウザに1が表示され、アクセスするたびに1ずつカウントアップしていく、というもの。ただし、同期化を行っていないため、アクセスが集中すると例外が発生したり、数字がカウントアップしなかったりすることがある。

ソースコードは以下の通り。

import java.io.IOException;
import java.util.*;
import javax.servlet.*;
import javax.servlet.http.*;

public class TestServlet extends HttpServlet {
  private static final List<String> list = new ArrayList<String>();
  @Override
  protected void doGet(HttpServletRequest request, HttpServletResponse response)
    throws ServletException, IOException {
    // 同期化なし
    list.add("hoge");
    response.setContentType("text/plain");
    response.getWriter().write(list.size());
  }
}

この例を使って、まずは以下3つの方法を比較して考えてみる。

  1. 同期化を行わない方法
  2. 呼び出し側でメソッド呼び出しの同期化を行う方法(方法B)
  3. ラップオブジェクトでメソッド呼び出しの同期化を行う方法

同期化を行わない方法

ArrayListのaddメソッド呼び出しに対し、同期化を全く行わない場合を考えてみる。この場合はServletへのアクセスが集中すると、次のようにArrayList#ensureCapacityメソッド内で例外が発生することがある。

thread7

他にもスレッドの動作タイミングによっては、リストにエントリーが追加されない誤動作をする場合もある。

呼び出し側でメソッド呼び出しの同期化を行う方法(方法B)

synchronizedブロックにより、ArrayList#addメソッド呼び出しを同期化する場合、マルチスレッド問題は発生しない。ソースコードは次のようになる。

import java.io.IOException;
import java.util.*;
import javax.servlet.*;
import javax.servlet.http.*;

public class TestServlet extends HttpServlet {
  private static final List<String> list = new ArrayList<String>();
  @Override
  protected void doGet(HttpServletRequest request, HttpServletResponse response)
    throws ServletException, IOException {
    // synchronizedブロックによる呼び出し側での同期化
    synchronized (list) {
      list.add("hoge");
      response.setContentType("text/plain");
      response.getWriter().write(list.size());
    }
  }
}

動作は次のようになる。

thread8

ラップオブジェクトでメソッド呼び出しの同期化を行う方法

いよいよラップオブジェクトを使う方法の登場。ソースコードの変更箇所は、ArrayListをインスタンス化している部分だけだ。new ArrayList<String>() でインスタンス化した後、これをCollections#synchronizedListメソッドの引数に渡してラップしたListを取得する。

import java.io.IOException;
import java.util.*;
import javax.servlet.*;
import javax.servlet.http.*;

public class TestServlet extends HttpServlet {
  // ラップオブジェクトによる同期化
  private static final List<String> list =
    Collections.synchronizedList(new ArrayList<String>());
  @Override
  protected void doGet(HttpServletRequest request, HttpServletResponse response)
    throws ServletException, IOException {
    list.add("hoge");
    response.setContentType("text/plain");
    response.getWriter().write(list.size());
  }
}

たったこれだけで同期化処理は完了。 同期化はどこで行われているかというと、次のようにCollectionsクラスの内部クラスSynchronizedRandomAccessListが同期化を代行してくれる。

thread9

Collectionsクラスの内部クラスSynchronizedRandomAccessListは、Collections#synchronizedListメソッド呼び出し時にインスタンス化される。そしてこのSynchronizedRandomAccessListクラスはListインタフェースを実装していて、addメソッド/getメソッドなどほぼ全てのメソッドがsynchronizedブロックにより同期化されている。Collections#synchronizedListメソッドの戻り値はこのSynchronizedRandomAccessListクラスのインスタンスだから、以後addメソッドを呼び出す度に同期化が行われることになる。

public class Collections {
  public static <T> List<Tgt; synchronizedList(List<T> list) {
    return (list instanceof RandomAccess ?
      new SynchronizedRandomAccessList<T>(list) :
      new SynchronizedList<T>(list));
  }

  static class SynchronizedCollection<E> implements Collection<E>, Serializable {
    Collection<E> c;
    Object	   mutex;
    SynchronizedCollection(Collection<E> c) {
      if (c==null)
        throw new NullPointerException();
      this.c = c;
      mutex = this;
    }
      : (中略)
  }

  static class SynchronizedList<E>
    extends SynchronizedCollection<E>
    implements List<E> {
    List<E> list;

    SynchronizedList(List<E> list) {
      super(list);
      this.list = list;
    }
    // ほぼすべてのメソッドがsynchronizedブロックで同期化されている
    public void add(int index, E element) {
      synchronized(mutex) {list.add(index, element);}
    }
    public E remove(int index) {
      synchronized(mutex) {return list.remove(index);}
    }
      : (中略)
  }

  static class SynchronizedRandomAccessList<E>
    extends SynchronizedList<E>
    implements RandomAccess {
    SynchronizedRandomAccessList(List<E> list) {
      super(list);
    }
      : (中略)
  }

ラップオブジェクトのメリット・デメリット

このように同期化オブジェクトでラップする方法には次のようなメリットがある。

  • インスタンス作成時にラップするだけで使用できる
  • 同期化オブジェクトがListインタフェースなど同一のインタフェースを実装しているため、呼び出し側の修正がほぼ不要
  • 全てのメソッド呼び出しが同期化される

一方、次のような注意事項もある。

  • 同期化する必要がないインスタンスに対して使用するとパフォーマンスが劣化する
  • ラップをすることにより、元のオブジェクトを直接参照することが困難となる(Collections#synchronizedListメソッドの戻り値は別クラスのインスタンスとなるため)
  • getメソッド呼び出しからaddメソッド呼び出しまで、といったように複数メソッドで構成される一連の操作を同期化することはできない

制限事項はあるとはいっても、悩ましいマルチスレッド処理がとにかく簡単・安全に対応できるのが最大のメリットだ。

Page Top