Java 多线程编程核心技术 (二):对象及变量的并发访问(下)

阅读数:3438 2019 年 8 月 29 日 14:20

Java多线程编程核心技术(二):对象及变量的并发访问(下)

本文经授权转载自微信公众号薛勤的博客,主要介绍 Java 多线程中的同步,也就是如何在 Java 语言中写出线程安全的程序,如何在 Java 语言中解决非线程安全的相关问题。想看上篇请点击这里

3.volatile 关键字

关键字 volatile 的主要作用是使变量在多个线程间可见。

3.1 关键字 volatile 与死循环

如果不是在多继承的情况下,使用继承 Thread 类和实现 Runnable 接口在取得程序运行的结果上并没有多大的区别。如果一旦出现”多继承“的情况,则用实现 Runable 接口的方式来处理多线程的问题就是很有必要的。

复制代码
public class PrintString implements Runnable{
private boolean isContinuePrint = true;
@Override
public void run() {
while (isContinuePrint){
System.out.println("Thread: "+Thread.currentThread().getName());
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
public boolean isContinuePrint() {
return isContinuePrint;
}
public void setContinuePrint(boolean continuePrint) {
isContinuePrint = continuePrint;
}
public static void main(String[] args) throws InterruptedException {
PrintString printString = new PrintString();
Thread thread = new Thread(printString,"Thread-A");
thread.start();
Thread.sleep(100);
System.out.println(" 我要停止它!" + Thread.currentThread().getName());
printString.setContinuePrint(false);
}
}

运行结果:

Thread: Thread-A
我要停止它!main

上面的代码运行起来没毛病,但是一旦运行在 -server 服务器模式中 64bit 的 JVM 上时,会出现死循环。解决的办法是使用 volatile 关键字。

关键字 volatile 的作用是强制从公共堆栈中取得变量的值,而不是从线程私有数据栈中取得变量的值。

3.2 解决异步死循环

在研究 volatile 关键字之前先来做一个测试用例,代码如下:

复制代码
public class PrintString implements Runnable{
private boolean isRunnning = true;
@Override
public void run() {
System.out.println("Thread begin: "+Thread.currentThread().getName());
while (isRunnning == true){
}
System.out.println("Thread end: "+Thread.currentThread().getName());
}
public boolean isRunnning() {
return isRunnning;
}
public void setRunnning(boolean runnning) {
isRunnning = runnning;
}
public static void main(String[] args) throws InterruptedException {
PrintString printString = new PrintString();
Thread thread = new Thread(printString,"Thread-A");
thread.start();
Thread.sleep(1000);
printString.setRunnning(false);
System.out.println(" 我要停止它!" + Thread.currentThread().getName());
}
}

JVM 有 Client 和 Server 两种模式,我们可以通过运行:java -version 来查看 jvm 默认工作在什么模式。我们在 IDE 中把 JVM 设置为在 Server 服务器的环境中,具体操作只需配置运行参数为 -server。然后启动程序,打印结果:

Thread begin: Thread-A
我要停止它!main

代码 System.out.println("Thread end: "+Thread.currentThread().getName()); 从未被执行。

是什么样的原因造成将 JVM 设置为 -server 就出现死循环呢?

在启动 thread 线程时,变量 boolean isContinuePrint = true; 存在于公共堆栈及线程的私有堆栈中。在 JVM 设置为 -server 模式时为了线程运行的效率,线程一直在私有堆栈中取得 isRunning 的值是 true。而代码 thread.setRunning(false); 虽然被执行,更新的却是公共堆栈中的 isRunning 变量值 false,所以一直就是死循环的状态。内存结构图:

Java多线程编程核心技术(二):对象及变量的并发访问(下)

这个问题其实就是私有堆栈中的值和公共堆栈中的值不同步造成的。解决这样的问题就要使用 volatile 关键字了,它主要的作用就是当线程访问 isRunning 这个变量时,强制性从公共堆栈中进行取值。

将代码更改如下:

复制代码
volatile private boolean isRunnning = true;

再次运行:

Thread begin: Thread-A
我要停止它!main
Thread end: Thread-A

通过使用 volatile 关键字,强制的从公共内存中读取变量的值,内存结构如图所示:

Java多线程编程核心技术(二):对象及变量的并发访问(下)

使用 volatile 关键字增加了实例变量在多个线程之间的可见性。但 volatile 关键字最致命的缺点是不支持原子性。

下面将关键字 synchronized 和 volatile 进行一下比较:

  1. 关键字 volatile 是线程同步的轻量级实现,所以 volatile 性能肯定比 synchronized 要好,并且 volatile 只能修饰于变量,而 synchronized 可以修饰方法,以及代码块。随着 JDK 新版本的发布,synchronized 关键字在执行效率上得到很大提升,在开发中使用 synchronized 关键字的比率还是比较大的。

  2. 多线程访问 volatile 不会发生阻塞,而 synchronized 会出现阻塞。

  3. volatile 能保证数据的可见性,但不能保证原子性;而 synchronized 可以保证原子性,也可以间接保证可见性,因为它会将私有内存和公共内存中的数据做同步。

  4. 再次重申一下,关键字 volatile 解决的是变量在多个线程之间的可见性;而 synchronized 关键字解决的是多个线程之间访问资源的同步性。

线程安全包含原子性和可见性两个方面,Java 的同步机制都是围绕这两个方面来确保线程安全的。

3.3 volatile 非原子性的特征

关键字虽然增加了实例变量在多个线程之间的可见性,但它却不具备同步性,那么也就不具备原子性。

示例代码:

复制代码
public class MyThread extends Thread {
volatile private static int count;
@Override
public void run() {
addCount();
}
private void addCount() {
for (int i = 0;i<100;i++){
count++;
}
System.out.println(count);
}
public static void main(String[] args) {
MyThread[] myThreads = new MyThread[100];
for (int i=0;i<100;i++){
myThreads[i] = new MyThread();
}
for (int i=0;i<100;i++){
myThreads[i].start();
}
}
}

运行结果:


8253
8353
8153
8053
7875
7675

在 addCount 方法上加入 synchronized 同步关键字与 static 关键字,达到同步的效果。

再次运行结果:


9600
9700
9800
9900
10000

关键字 volatile 提示线程每次从共享内存中读取变量,而不是从私有内存中读取,这样就保证了同步数据的可见性。但在这里需要注意的是:如果修改实例变量中的数据,比如 i++,也就是比

i=i+1,则这样的操作其实并不是一个原子操作,也就是非线程安全。表达式 i++ 的操作步骤分解为下面三步:

  • 从内存中取 i 的值;

  • 计算 i 的值;

  • 将 i 值写入到内存中。

假如在第二步计算 i 值的时候,另外一个线程也修改 i 的值,那么这个时候就会脏数据。解决的方法其实就是使用 synchronized 关键字。所以说 volatile 关键字本身并不处理数据的原子性,而是强制对数据的读写及时影响到主内存中。

3.4 使用原子类进行 i++ 操作

除了在 i++ 操作时使用 synchronized 关键字实现同步外,还可以使用 AtomicInteger 原子类进行实现。

原子操作是不可分割的整体,没有其他线程能够中断或检查正在原子操作中的变量。它可以在没有锁的情况下做到线程安全。

示例代码:

复制代码
public class MyThread extends Thread {
private static AtomicInteger count = new AtomicInteger(0);
@Override
public void run() {
addCount();
}
private static void addCount() {
for (int i = 0;i<100;i++){
System.out.println(count.incrementAndGet());
}
}
public static void main(String[] args) {
MyThread[] myThreads = new MyThread[100];
for (int i=0;i<100;i++){
myThreads[i] = new MyThread();
}
for (int i=0;i<100;i++){
myThreads[i].start();
}
}
}

打印结果:


9996
9997
9998
9999
10000

成功达到累加的效果。

3.5 原子类也不安全

原子类在具有有逻辑性的情况下输出结果也具有随机性。

示例代码:

复制代码
public class MyThread extends Thread {
private static AtomicInteger count = new AtomicInteger(0);
public MyThread(String name) {
super(name);
}
@Override
public void run() {
this.addCount();
}
private void addCount() {
System.out.println(Thread.currentThread().getName()+" 加 100 之后:"+count.addAndGet(100));
count.addAndGet(1);
}
public static void main(String[] args) throws InterruptedException {
MyThread[] myThreads = new MyThread[10];
for (int i = 0; i < 10; i++) {
myThreads[i] = new MyThread("Thread-"+i);
}
for (int i = 0; i < 10; i++) {
myThreads[i].start();
}
Thread.sleep(2000);
System.out.println(MyThread.count);
}
}

打印结果:

Thread-0 加 100 之后:100
Thread-2 加 100 之后:201
Thread-1 加 100 之后:302
Thread-5 加 100 之后:602
Thread-4 加 100 之后:502
Thread-3 加 100 之后:402
Thread-6 加 100 之后:706
Thread-7 加 100 之后:807
Thread-9 加 100 之后:908
Thread-8 加 100 之后:1009
1010

可以看到,结果值正确但是打印顺序出错了,出现这样的原因是因为 AtomicInteger 的 addAndGet() 方法是原子的,但方法与方法之间的调用却不是原子的。也就是方法 addCount 的调用不是原子的。解决这样的问题必须要用同步。

3.6 synchronized 代码块有 volatile 同步的功能

关键字 synchronized 可以使多个线程访问同一个资源具有同步性,而且它还具有将线程工作内存中的私有变量与公共内存中的变量同步的功能。

我们把前面讲到的异步死循环代码改造一下:

复制代码
public class PrintString implements Runnable{
private boolean isRunnning = true;
@Override
public void run() {
String lock = new String();
System.out.println("Thread begin: "+Thread.currentThread().getName());
while (isRunnning == true){
synchronized (lock){
// 加与不加的效果就是是否死循环
}
}
System.out.println("Thread end: "+Thread.currentThread().getName());
}
public boolean isRunnning() {
return isRunnning;
}
public void setRunnning(boolean runnning) {
isRunnning = runnning;
}
public static void main(String[] args) throws InterruptedException {
PrintString printString = new PrintString();
Thread thread = new Thread(printString,"Thread-A");
thread.start();
Thread.sleep(1000);
printString.setRunnning(false);
System.out.println(" 我要停止它!" + Thread.currentThread().getName());
}
}

打印结果:

Thread begin: Thread-A
我要停止它!main
Thread end: Thread-A

关键字 synchronized 可以保证在同一时刻,只有一个线程可以执行某一个方法或某一个代码块。它包含两个特征:互斥相和可见性。同步 synchronized 不仅可以解决一个线程看到对象处于不一致的状态,还可以保证进入同步方法或者同步代码块的每个线程,都看到由同一个锁保护之前所有的修改效果。

学习多线程并发。要着重“外修互斥,内修可见”,这是掌握多线程、学习多线程并发的重要技术点。

评论

发布