まず理解する必要がありますオペレーティング システム スレッドのスケジューリングはプリエンプティブまたはランダムであるため、スレッド スケジューリングの実行時にスレッドの実行順序が不確実になります。一部のコードの実行順序の違いは、プログラムの実行結果に影響しませんが、コードによっては、実行順序が異なると、書き換えられた実行結果に影響が生じ、プログラムにバグが発生します。複数のスレッドが同時に実行されるときにプログラムにバグを引き起こすコードを、スレッドアンセーフコードといいます。スレッドの安全性の問題。
以下に、スレッドセーフ問題の代表的な例である整数自己インクリメント問題を紹介します。
ある日、教師が次のような問題を割り当てました。2 つのスレッドを使用して変数 count
10
をインクリメントします。 1 万回、各スレッドは自己インクリメント タスク 5
を 1 万回実行し、変数 count
の初期値は 0
です。
この質問は非常に簡単です。最終結果を口頭で計算することもできます。答えは 10
百万です。
Xiao Ming は非常に素早く作業し、すぐに次のコードを書きました:
class Counter { private int count; public void increase() { ++this.count; } public int getCount() { return this.count; }}public class Main11 { private static final int CNT = 50000; private static final Counter counter = new Counter(); public static void main(String[] args) throws InterruptedException { Thread thread1 = new Thread(() -> { for (int i = 0; i < CNT; i++) { counter.increase(); } }); Thread thread2 = new Thread(() -> { for (int j = 0; j < CNT; j++) { counter.increase(); } }); thread1.start(); thread2.start(); thread1.join(); thread2.join(); System.out.println(counter.getCount()); }}
論理的に言えば、結果は 10
百万になるはずです。実行結果を見てみましょう:
実行結果は 10
より小さいです。プログラムを実行してみると、結果が毎回異なることがわかりますが、ほとんどの場合、結果は次のようになります。値はすべて予想より小さくなります。なぜそうなるのかを分析してみましょう。
上記では、マルチスレッドを使用してプログラムを実行し、変数値 0 の変数を 100,000 回インクリメントしますが、最終的な実際の結果は予想した結果よりも小さくなります。その理由は、スレッド スケジューリングの順序がランダムであり、自己インクリメント命令セットがスレッド間で交差するためです。実行時に 2 回の自己増加が発生します。増加しますが、値は 1 回しか増加しないため、得られる結果は小さくなります。
自動インクリメント操作には次の命令が含まれる可能性があることがわかっています:
メモリ内の変数の値をレジスタにロードします。この操作は ロード
として実行されます。
レジスタ内で自動インクリメント操作を実行するには、操作を add
として記録するとよいでしょう。
レジスタ値をメモリに保存します。この操作を save
として記録することもできます。
いくつかの一般的な状況を要約するタイムラインを描いてみましょう:
ケース 1: スレッド間命令セット、クロスオーバーなし、実行結果は次のとおりです。図中のレジスタ A はスレッド 1 が使用するレジスタ、レジスタ B はスレッド 2 が使用するレジスタを表します。
ケース 2: スレッド間の命令セットに重複があり、実行結果が予想よりも低くなります。
ケース 3: スレッド間の命令セットが完全に交差しており、実際の結果は予想よりも低くなります。
上記の状況によると、スレッドの実行中にクロス命令がない場合は実行結果は正常ですが、クロスがあると自動命令の結果が正常に実行されないことがわかります。 -increment 操作はより少なくなります 1
、上記から結論を導き出すことができます。つまり、自動インクリメント操作はアトミックではないため、複数のスレッドの同時実行により命令の実行が発生する可能性があります。重複すると、スレッドの安全性の問題が発生します。
では、上記のスレッドの安全性の問題を解決するにはどうすればよいでしょうか?もちろん、オブジェクトをロックすることもあります。
「プリエンプティブ実行」によって引き起こされるスレッドの安全性の問題を解決するには、次のことができます。操作のオブジェクトをロックします。スレッドがオブジェクトのロックを取得すると、オブジェクトをロックします。他のスレッドがオブジェクトのタスクを実行する必要がある場合、スレッドがオブジェクトのタスクの実行を完了するまで待つ必要があります。実行することができます。
举个例子,假设要你去银行的ATM机存钱或者取款,每台ATM机一般都在一间单独的小房子里面,这个小房子有一扇门一把锁,你进去使用ATM机时,门会自动的锁上,这个时候如果有人要来取款,那它得等你使用完并出来它才能进去使用ATM,那么这里的“你”相当于线程,ATM相当于一个对象,小房子相当于一把锁,其他的人相当于其他的线程。
在java中最常用的加锁操作就是使用synchronized
关键字进行加锁。
synchronized 会起到互斥效果, 某个线程执行到某个对象的 synchronized 中时, 其他线程如果也执行到同一个对象 synchronized 就会阻塞等待。
线程进入 synchronized 修饰的代码块, 相当于加锁
,退出 synchronized 修饰的代码块, 相当于 解锁
。
java中的加锁操作可以使用synchronized
关键字来实现,它的常见使用方式如下:
方式1: 使用synchronized
关键字修饰普通方法,这样会使方法所在的对象加上一把锁。
例如,就以上面自增的程序为例,尝试使用synchronized
关键字进行加锁,如下我对increase
方法进行了加锁,实际上是对某个对象加锁,此锁的对象就是this
,本质上加锁操作就是修改this
对象头的标记位。
class Counter { private int count; synchronized public void increase() { ++this.count; } public int getCount() { return this.count; }}
多线程自增的main方法如下,后面会以相同的栗子介绍synchronized
的其他用法,后面就不在列出这段代码了。
public class Main11 { private static final int CNT = 50000; private static final Counter counter = new Counter(); public static void main(String[] args) throws InterruptedException { Thread thread1 = new Thread(() -> { for (int i = 0; i < CNT; i++) { counter.increase(); } }); Thread thread2 = new Thread(() -> { for (int j = 0; j < CNT; j++) { counter.increase(); } }); thread1.start(); thread2.start(); thread1.join(); thread2.join(); System.out.println(counter.getCount()); }}
看看运行结果:
方式2: 使用synchronized
关键字对代码段进行加锁,但是需要显式指定加锁的对象。
例如:
class Counter { private int count; public void increase() { synchronized (this){ ++this.count; } } public int getCount() { return this.count; }}
运行结果:
方式3: 使用synchronized
关键字修饰静态方法,相当于对当前类的类对象进行加锁。
class Counter { private static int count; synchronized public static void increase() { ++count; } public int getCount() { return this.count; }}
运行结果:
常见的用法差不多就是这些,对于线程加锁(线程拿锁),如果两个线程同时拿一个对象的锁,就会产生锁竞争,两个线程同时拿两个不同对象的锁不会产生锁竞争。
对于synchronized
这个关键字,它的英文意思是同步,但是同步在计算机中是存在多种意思的,比如在多线程中,这里同步的意思是“互斥”;而在IO或网络编程中同步指的是“异步”,与多线程没有半点的关系。
synchronized 的工作过程:
获得互斥锁lock
从主内存拷贝变量的最新副本到工作的内存
执行代码
将更改后的共享变量的值刷新到主内存
释放互斥锁unlock
synchronized 同步块对同一条线程来说是可重入的,不会出现自己把自己锁死的问题,即死锁问题,关于死锁后续文章再做介绍。
综上,synchronized关键字加锁有如下性质:互斥性,刷新内存性,可重入性。
synchronized关键字也相当于一把监视器锁monitor lock,如果不加锁,直接使用wait
方法(一种线程等待的方法,后面细说),会抛出非法监视器异常,引发这个异常的原因就是没有加锁。
对自增那个代码上锁后,我们再来分析一下为什么加上了所就线程安全了,先列代码:
class Counter { private int count; synchronized public void increase() { ++this.count; } public int getCount() { return this.count; }}public class Main11 { private static final int CNT = 50000; private static final Counter counter = new Counter(); public static void main(String[] args) throws InterruptedException { Thread thread1 = new Thread(() -> { for (int i = 0; i < CNT; i++) { counter.increase(); } }); Thread thread2 = new Thread(() -> { for (int j = 0; j < CNT; j++) { counter.increase(); } }); thread1.start(); thread2.start(); thread1.join(); thread2.join(); System.out.println(counter.getCount()); }}
多线程并发执行时,上一次就分析过没有指令集交叉就不会出现问题,因此这里我们只讨论指令交叉后,加锁操作是如何保证线程安全的,不妨记加锁为lock
,解锁为unlock
,两个线程运行过程如下:
线程1首先拿到目标对象的锁,对对象进行加锁,处于lock
状态,当线程2来执行自增操作时会发生阻塞,直到线程1的自增操作完毕,处于unlock
状态,线程2才会就绪取执行线程2的自增操作。
加锁后线程就是串行执行,与单线程其实没有很大的区别,那多线程是不是没有用了呢?但是对方法加锁后,线程运行该方法才会加锁,运行完该方法就会自动解锁,况且大部分操作并发执行是不会造成线程安全的,只有少部分的修改操作才会有可能导致线程安全问题,因此整体上多线程运行效率还是比单线程高得多。
首先,线程不安全根源是线程间的调度充满随机性,导致原有的逻辑被改变,造成线程不安全,这个问题无法解决,无可奈何。
多个线程针对同一资源进行写(修改)操作,并且针对资源的修改操作不是原子性的,可能会导致线程不安全问题,类似于数据库的事务。
由于编译器的优化,内存可见性无法保证,就是当线程频繁地对同一个变量进行读操作时,会直接从寄存器上读值,不会从内存上读值,这样内存的值修改时,线程就感知不到该变量已经修改,会导致线程安全问题(这是编译器优化的结果,现代的编译器都有类似的优化不止于Java),因为相比于寄存器,从内容中读取数据的效率要小的多,所以编译器会尽可能地在逻辑不变的情况下对代码进行优化,单线程情况下是不会翻车的,但是多线程就不一定了,比如下面一段代码:
import java.util.Scanner;public class Main12 { private static int isQuit; public static void main(String[] args) { Thread thread = new Thread(() -> { while (isQuit == 0) { } System.out.println("线程thread执行完毕!"); }); thread.start(); Scanner sc = new Scanner(System.in); System.out.println("请输入isQuit的值,不为0线程thread停止执行!"); isQuit = sc.nextInt(); System.out.println("main线程执行完毕!"); }}
运行结果:
我们从运行结果可以知道,输入isQuit
后,线程thread
没有停止,这就是编译器优化导致线程感知不到内存可见性,从而导致线程不安全。
我们可以使用volatile
关键字保证内存可见性。
我们可以使用volatile
关键字修饰isQuit
来保证内存可见性。
import java.util.Scanner;public class Main12 { volatile private static int isQuit; public static void main(String[] args) { Thread thread = new Thread(() -> { while (isQuit == 0) { } System.out.println("线程thread执行完毕!"); }); thread.start(); Scanner sc = new Scanner(System.in); System.out.println("请输入isQuit的值,不为0线程thread停止执行!"); isQuit = sc.nextInt(); System.out.println("main线程执行完毕!"); }}
运行结果:
synchronized与volatile关键字的区别:synchronized
关键字能保证原子性,但是是否能够保证内存可见性要看情况(上面这个栗子是不行的),而volatile
关键字只能保证内存可见性不能保证原子性。
保证内存可见性就是禁止编译器做出如上的优化而已。
import java.util.Scanner;public class Main12 { private static int isQuit; //锁对象 private static final Object lock = new Object(); public static void main(String[] args) { Thread thread = new Thread(() -> { synchronized (lock) { while (isQuit == 0) { } System.out.println("线程thread执行完毕!"); } }); thread.start(); Scanner sc = new Scanner(System.in); System.out.println("请输入isQuit的值,不为0线程thread停止执行!"); isQuit = sc.nextInt(); System.out.println("main线程执行完毕!"); }}
运行结果:
编译器优化除了导致内存可见性感知不到的问题,还有指令重排序也会导致线程安全问题,指令重排序也是编译器优化之一,就是编译器会智能地(保证原有逻辑不变的情况下)调整代码执行顺序,从而提高程序运行的效率,单线程没问题,但是多线程可能会翻车,这个原因了解即可。
Java 标准库中很多都是线程不安全的。这些类可能会涉及到多线程修改共享数据, 又没有任何加锁措施。例如,ArrayList,LinkedList,HashMap,TreeMap,HashSet,TreeSet,StringBuilder。
但是还有一些是线程安全的,使用了一些锁机制来控制,例如,Vector (不推荐使用),HashTable (不推荐使用),ConcurrentHashMap (推荐),StringBuffer。
还有的虽然没有加锁, 但是不涉及 “修改”, 仍然是线程安全的,例如String。
在线程安全问题中可能你还会遇到JMM模型,在这里补充一下,JMM其实就是把操作系统中的寄存器,缓存和内存重新封装了一下,其中在JMM中寄存器和缓存称为工作内存,内存称为主内存。
其中缓存分为一级缓存L1,二级缓存L2和三级缓存L3,从L1到L3空间越来越大,最大也比内存空间小,最小也比寄存器空间大,访问速度越来越慢,最慢也比内存的访问速度快,最快也没有寄存器访问快。
除了Thread类中的能够实现线程等待的方法,如join
,sleep
,在Object类中也提供了相关线程等待的方法。
序号 | 方法 | 说明 |
---|---|---|
1 | public final void wait() throws InterruptedException | 释放锁并使线程进入WAITING状态 |
2 | public final native void wait(long timeout) throws InterruptedException; | 相比于方法1,多了一个最长等待时间 |
3 | public final void wait(long timeout, int nanos) throws InterruptedException | 相比于方法2,等待的最长时间精度更大 |
4 | public final native void notify(); | 唤醒一个WAITING状态的线程,并加锁,搭配wait方法使用 |
5 | public final native void notifyAll(); | 唤醒所有处于WAITING状态的线程,并加锁(很可能产生锁竞争),搭配wait方法使用 |
上面介绍synchronized
关键字的时候,如果不对线程加锁会产生非法监视异常,我们来验证一下:
public class TestDemo12 { public static void main(String[] args) throws InterruptedException { Thread thread = new Thread(() -> { try { Thread.sleep(5000); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println("执行完毕!"); }); thread.start(); System.out.println("wait前"); thread.wait(); System.out.println("wait后"); }}
看看运行结果:
果然抛出了一个IllegalMonitorStateException
,因为wait
方法的执行步骤为:先释放锁,再使线程等待,你现在都没有加锁,那如何释放锁呢?所以会抛出这个异常,但是执行notify
是无害的。
wait
方法常常搭配notify
方法搭配一起使用,前者能够释放锁,使线程等待,后者能获取锁,使线程继续执行,这套组合拳的流程图如下:
现在有两个任务由两个线程执行,假设线程2比线程1先执行,请写出一个多线程程序使任务1在任务2前面完成,其中线程1执行任务1,线程2执行任务2。
这个需求可以使用wait/notify
来实现。
class Task{ public void task(int i) { System.out.println("任务" + i + "完成!"); }}public class WiteNotify { //锁对象 private static final Object lock = new Object(); public static void main(String[] args) throws InterruptedException { Thread thread1 = new Thread(() -> { synchronized (lock) { Task task1 = new Task(); task1.task(1); //通知线程2线程1的任务完成 System.out.println("notify前"); lock.notify(); System.out.println("notify后"); } }); Thread thread2 = new Thread(() -> { synchronized (lock) { Task task2 = new Task(); //等待线程1的任务1执行完毕 System.out.println("wait前"); try { lock.wait(); } catch (InterruptedException e) { e.printStackTrace(); } task2.task(2); System.out.println("wait后"); } }); thread2.start(); Thread.sleep(10); thread1.start(); }}
运行结果:
以上がJava マルチスレッドのスレッド セーフ問題を解決する方法の詳細内容です。詳細については、PHP 中国語 Web サイトの他の関連記事を参照してください。