マルチスレッドの基本(3):synchronizedキーワードとJavaメモリモデルの概要

一緒に書く習慣をつけましょう!「ナゲッツデイリーニュープラン・4月アップデートチャレンジ」に参加して4日目です。クリックしてイベントの詳細をご覧ください

@ [toc]

1.スレッドセーフの問題

いくつかのJavaマルチスレッドの基本を理解した後、今度はマルチスレッドを使用して実際の問題を解決します。各スレッドが100000に数値を追加できると仮定すると、10個のスレッドを使用し、それらを同時に追加して、結果が1000000かどうかを確認します。コードは次のとおりです。

ackage com.dhb.concurrent.test;

import java.util.concurrent.CountDownLatch;

public class SyncDemo implements Runnable{

	private static int count = 0;
	static CountDownLatch countDownLatch = new CountDownLatch(10);

	public static void main(String[] args) throws InterruptedException{
		for(int i=0;i<10; i++) {
			Thread t = new Thread(new SyncDemo());
			t.start();
		}
		countDownLatch.await();
		System.out.println(count);

	}

	 private void add(){
		for (int i = 0; i < 100000; i++) {
			count++;
		}
		countDownLatch.countDown();
	}

	@Override
	public void run() {
		add();
	}
}
复制代码

上記のコードでは、それぞれ10個のスレッドが開始されていますが、出力結果が1000000かどうかを確認してください。

361177
复制代码

結果は期待と同じではありません。もう一度やり直して見てみましょう。

294781
复制代码

毎回異なります。これは、スレッドセーフの問題が存在する必要があることを意味します。つまり、10スレッドによる同時アクセスのプロセスで、カウントのメンバー変数がダーティリードである可能性があります。つまり、1つのスレッドが書き込みを完了しておらず、別のスレッドが未完了の結果を読み取ったため、最終結果は1000000ではありません。このため、この問題を解決するには、同期して解決するキーワードを考える必要があります。

2.同期の使用方法

並行性の問題は通常、2つのタイプの問題を解決する必要があります。1つは相互排除です。つまり、リソースは同時に1つのスレッドからのみアクセスできます。このスレッドがアクセス中の場合、他のスレッドはこの変数にアクセスできません。これは相互排除です。もう1つの問題は同期です。同期は、主にスレッド間の通信の問題を解決するためのものです。つまり、スレッドはこの変数にアクセスする前に必要なロックリソースを取得できないため、ブロッキング状態になり、CPU実行権限を放棄します。その後、再実行できる場合は、実行完了後にリソースにアクセスするスレッドに通知する必要があります。waitメソッドとnotifyメソッドは、優れたスレッド同期メソッドです。実際、英語で同期するということは同期を意味しますが、もっと興味深いのは、同期することで主に相互排除の問題が解決されることです。つまり、ロックします。コードを次のように変更します。

 private void add(){
	synchronized (SyncDemo.class) {
		for (int i = 0; i < 100000; i++) {
			count++;
		}
		countDownLatch.countDown();
	}
}
复制代码

実行結果を再度確認してください。

1000000
复制代码

案の定、出力は望ましい結果です。しかし、日光浴されたブロックを次のように変更するとどうなりますか?

 private void add(){
	synchronized (this) {
		for (int i = 0; i < 100000; i++) {
			count++;
		}
		countDownLatch.countDown();
	}
}
复制代码

結果は次のとおりです。

205975
复制代码

又不能满足了。这说明,synchronized代码块,括号中锁定的对象,是有讲究的,前面的SyncDemo.class,由于SyncDemo.class是个特殊的对象,只有一个对象。因此多线程访问的时候就会形成互斥。而改成this之后,由于这个类在使用的时候通过new,导致了多个实例,实例与实例之间加索就不能构成互斥关系。 另外,上述代码块也可以与如下情况等价:

 private synchronized void add(){
	for (int i = 0; i < 100000; i++) {
		count++;
	}
	countDownLatch.countDown();
}
复制代码

如果方法中除了代码块没有任何内容,那么这种方式与前面的synchronized(this)等价。 此外synchronized(SyncDemo.class)也与如下等价:

 private static synchronized void add(){
	for (int i = 0; i < 100000; i++) {
		count++;
	}
	countDownLatch.countDown();
}
复制代码

对,就是将方法改为静态方法,这样锁住的就是类了。我们总结一下:

分类 详细分类 被锁的对象 代码示例
方法 实例方法 类的实例对象 public synchronized void method(){ ... ... }
方法 实例方法 类对象 public static synchronized void method() { ... ... }
代码块 实例对象 类的实例对象 synchronized(this) { ... ... }
代码块 class对象 类对象 synchronized(SyncDemo.class){ ... ... }
代码块 任意实例对象Object 实例对象Object Object lock = new Object(); synchronized(lock){ ... ... }

理论上来说,synchronized()的括号中可以是任意对象。但是,需要注意的是, 一般我们最好不要用String和包装类做为被锁定的对象。 这是因为,在jvm中,对这些类进行特殊处理,String类,尤其是G1中要是开启了字符串去重,那么全部jvm中都只有这一个对象。这样会导致许多系统其他的功能受到影响。包装类由于有常量池,也会导致同样的问题,这样你会莫名其妙的感觉系统卡顿。

3.java的内存模型JMM

在前面学习伪共享的时候了解过,操作系统中,实际上CPU与主内存之间存在多级缓存架构。而这些多级高速缓存的速度远远高于主内存的读取速度。其结构如下: キャッシュモデル

高速缓存和主内存以及CPU的同步关系,需要通过缓存一致性协议来确保数据的一致性。如MESI、MSI等协议。通过这些协议,才能保证各内存高速缓存与主内存的数据一致性。这个模型如下图所示 キャッシュモデル

除了高速缓存之外,为了使CPU运算单元尽可能的充分利用,还会对输入的代码进行优化,其先后顺序会被改变。因此,在实际代码的执行过程中,其先后顺序不一定按照代码顺序来执行。这就是指令的重排序。关于这一点的细节再后续volatile关键字部分进行详细介绍。 那么JAVA实际上也是与这个模型类似,再java虚拟机中,虚拟机做为最外层的容器,其执行的逻辑与这个模型也非常相似。实际上,线程是CPU的最小执行单位,Java的内存模型实际上是对这个模型的抽象。在java中,也分为主内存和工作内存:

  • 主内存:java虚拟机规定,所有变量必须在主内存上产生,主内存也等价于是堆区。与前面的模型相比,这里的主内存可能是前面内存的一部分。
  • 工作内存:java虚拟机中的每个线程都有自己的工作内存,也就是线程的栈区。与前面的高速缓存相比,线程的工作过程中需要使用高速缓存。线程的工作内存实际上大部分内容在内存中,分配到CPU执行的时候,就会将需要执行的部分放入高速缓存。

那么java主内存和工作内存之间,也需要通过jvm的一些规则来保证数据的一致性。 需要说明的是,这两个模型只用于对比记忆,实际上二者并无直接关系。因为中间还有操作系统层的映射。而对于操作系统是如何在这两个模型之间转换的,还有很多内容本文并未涉及。 java内存模型如下: JMM

在java中,工作内存与主内存的交互,主要通过如下8种活动来进行,每个活动都是原子性的。

  • lock(锁定):作用于主内存的变量,一个变量在同一时间只能一个线程锁定,该操作表示这条线成独占这个变量
  • unlock(解锁):作用于主内存的变量,表示这个变量的状态由处于锁定状态被释放,这样其他线程才能对该变量进行锁定
  • read(读取):作用于主内存变量,表示把一个主内存变量的值传输到线程的工作内存,以便随后的load操作使用
  • load(载入):作用于线程的工作内存的变量,表示把read操作从主内存中读取的变量的值放到工作内存的变量副本中(副本是相对于主内存的变量而言的)
  • use(使用):作用于线程的工作内存中的变量,表示把工作内存中的一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时就会执行该操作
  • assign(赋值):作用于线程的工作内存的变量,表示把执行引擎返回的结果赋值给工作内存中的变量,每当虚拟机遇到一个给变量赋值的字节码指令时就会执行该操作
  • store(存储):作用于线程的工作内存中的变量,把工作内存中的一个变量的值传递给主内存,以便随后的write操作使用
  • write(写入):作用于主内存的变量,把store操作从工作内存中得到的变量的值放入主内存的变量中

可以看到,上述图种绿色部分就是在工作内存种执行的活动。其他活动则是在主内存种执行。其过程详细如下图: JMMプロセス

在每个线程中,其执行的时候的变量,实际上是其主内存中变量的副本。那么如果采用了synchronized,则会用lock操作锁定该变量,之后其他线程并无法访问。之后再进行read、load过程,之后使用或者赋值。 对于两个线程,分别从主内存中读取变量a和b的值,并不一样要read a; load a; read b; load b; 也会出现如下执行顺序:read a; read b; load b; load a; volatile修饰的变量则除外。 上述这些操作,JSR133规定,需要满足如下规则:

  • 1.読み取り、ロード、保存、書き込みのいずれかの操作を単独で表示することはできません。これらの2セットの操作は、ペアで表示される必要があります。読み取り後はロードし、保存後は書き込みを行う必要があります。読み取り後にワーキングメモリを受信したり、保存後にメインメモリを受信したりすることはできません。
  • スレッドは、最新の割り当て操作を破棄することはできません。その変更された値は、メインメモリに同期して戻す必要があります。
  • 変数はメインメモリにのみ作成できます。初期化されていない変数を作業メモリーで直接使用することはできません。使用および割り当て操作を実行する前に、ロードを実行する必要があります。
  • 変数は一度に1つのスレッドでのみロックできるため、相互排除が実現します。これは、同期の使用の本質でもあります。
  • ロックのない変数に対してロック解除操作を実行することは許可されていませんスレッドがロックを実行しない場合、ロック解除を実行することは絶対に許可されていませんもちろん、他の変数によってロックされている変数に対してロック解除を実行することも許可されていませんスレッド。
  • 変数のロックを解除する前に、変数を同期してメインメモリに戻す必要があります。つまり、書き込みを実行した後です。

4.まとめ

この記事では、スレッドセーフの問題から同期された使用法を紹介します。そして、Javaメモリモデルの簡単な紹介。もちろん、同期化されて再入可能であり、基礎となる特定のプラクティスと最適化に関する知識も非常に重要な部分です。これについては後で詳しく説明します。

おすすめ

転載: juejin.im/post/7082763875023585293