Java并发编程之线程安全
博客专区 > 12叔 的博客 > 博客详情
Java并发编程之线程安全
12叔 发表于6个月前
Java并发编程之线程安全
  • 发表于 6个月前
  • 阅读 38
  • 收藏 2
  • 点赞 0
  • 评论 0

新睿云服务器60天免费使用,快来体验!>>>   

摘要: 本文主要探讨引起线程安全的原因和可见性,有序性和原子性问题

引子

上文讲到我们使用线程来提高系统的资源利用率和吞吐量,同时也会带来线程安全的问题

今天我们就来探讨一下引发线程安全的原因

首先我们定义一下什么是线程安全 简单来说线程安全就是正确性,指的是程序在并发情况下执行的结果和预期一致.

安全性问题是首要解决的问题,而引起线程安全的问题本质是线程之间的通信方式的问题.

操作系统里面定义了几种通信的方式

  1. 管道 pipeline
  2. 信号 signal
  3. 消息队列 messsage queue
  4. 共享内存 shared memory
  5. 信号量 semaphore
  6. Socket

我们主要讨论共享内存的方式

共享内存

在共享内存的并发模型里,线程之间共享程序的公共状态,线程之间通过写-读内存中的公共状态来隐式进行通信。

线程之间的共享变量存储在主内存中,每个线程都有自己的本地内存,存储了该线程的共享变量副本,所以线程A和线程B之前需要通信的话,必须经过以下两个步骤

  • 线程A把本地内存中更新过的共享变量刷新到主内存中
  • 线程B到主内存中读取线程A之前更新过的共享变量

输入图片说明

由于共享内存的这种通信方式,在多线程并发的情况下从而会引发可见性和有序性问题, 从而导致线程安全的问题.

我们线程安全问题主要关注三个核心的概念 可见性,有序性和原子性

可见性

可见性是指,当多个线程并发访问共享变量时,一个线程对共享变量的修改,其它线程能够立即看到。

可见性的关键还是在对变量的写操作之后能够在理解写回到主内存, 这样其他线程就能从主内存中看到最新的写的值,

但是现在主流的计算机中,都有几级缓存。每个线程读取共享变量时,都会将该变量加载进其对应CPU的高速缓存里,修改该变量后,CPU会立即更新该缓存,但并不一定会立即将其写回主内存(实际上写回主内存的时间不可预期)。 此时其它线程(尤其是不在同一个CPU上执行的线程)访问该变量时,从主内存中读到的就是旧的数据,而非第一个线程更新后的数据.

下面这个例子说明了这个问题

public class Visibility {

    private static boolean status;
    private static int i;

    public static void exec() {

        while (!status) {
            Thread.yield();
        }

        System.out.println(i);

    }

    public static void main(String[] args) {

        new Thread(() -> exec()).start();

        status = true;
        i = 99;
    }
}

这个例子可能会打印i=0的情况 就是因为线程无法保证对共享内存的可见性

我们可以用 volatile,synchronized, 显式锁,原子变量这些同步手段都可以保证可见性

但是注意 仅仅满足可见性并不一定能保证线程安全,因为还可能会存在有序性问题

有序性

有序性保证共享变量在并发的情况下,实际执行的结果和单线程的执行结果是一样的.

有序性的语意注意有2种,

  1. 最常见的就是保证多线程执行的串行顺序

下面这个例子说明了这个问题

当多个线程访问共享变量的情况下,就会发生有序性问题

public class Order {

    private volatile int c = 1  //我们用volatile保证了共享变量的可见性

    public void incr() {
        c++;
    }

    public int value() {
        return c;
    }


}


我们分析一下程序执行的步骤

A线程执行

  1. 读取c 的量1
  2. c+1

A线程挂起 B线程执行

  1. 读取c 的量1
  2. c+1
  3. 写入c=2

A线程执行

  1. 写入c=2

输入图片说明

这样看到incr()方法执行了2次但是结果还是c=2

为什么会出现这种情况,主要原因还是在于线程之间执行顺序的随机性,导致内存的不一致

我们把这种如果对资源的访问顺序敏感的现象,称做竞态条件。 导致竞态条件发生的代码区称作临界区。 上例中incr()方法就是一个临界区,它会产生竞态条件。

  1. 防止重排序引起的问题
boolean started = false; // 语句1
long counter = 0L; // 语句2
counter = 1; // 语句3
started = true; // 语句4

从代码顺序上看,上面四条语句应该依次执行,但实际上JVM真正在执行这段代码时,并不保证它们一定完全按照此顺序执行

处理器或编译器为了提高程序整体的执行效率,可能会对代码进行优化,其中的一项优化方式就是调整代码顺序,按照更高效的顺序执行代码,这种方式叫指令重排.

volatile, final, synchronized,显式锁都可以保证有序性.

除了从应用层面保证目标代码段执行的顺序性外,JVM还通过被称为happens-before原则隐式地保证有序性。两个操作的执行顺序只要可以通过happens-before推导出来,则JVM会保证其顺序性,反之JVM对其顺序性不作任何保证,可对其进行任意必要的重新排序以获取高效率

只有同时满足了有序性和可见性的才能保证线程安全.

原子性

先来了解一下原子性的概念 原子性是指某些操作在语意上是原子的,即一个操作(可能包含有多个子操作)要么全部执行,要么全部都不执行 对基本类型的读/写操作,CAS操作等在机器指令级别都是原子的.

那么如果能保证我们保证一个线程执行的所有方法都具有原子性,那么不存在有序性问题了.

加锁可以保证复合语句的原子性,synchronized可以保证多条语句在synchronized块中语意上是原子的,显式锁保证临界区的原子性。

public synchronized void testLock () {
    int j = i;
    i = j + 1;
}

原子变量(如AtomInteger)使用CAS封装了对变量的原子操作。

AtomicInteger atomicInteger = new AtomicInteger();
for(int b = 0; b < numThreads; b++) {
  new Thread(() -> {
    for(int a = 0; a < iteration; a++) {
      atomicInteger.incrementAndGet();
    }
  }).start();
}

总结

本文我们主要论述了引发线程安全的原因,根据共享内存的引出线程安全的三个核心概念可见性和有序性和原子性的问题 想要解决对共享变量访问的引起的线程安全问题的方法是使用同步,接下来我们将探讨同步原理和使用方式,和如何设计线程安全的并发代码

标签: Java 并发
  • 打赏
  • 点赞
  • 收藏
  • 分享
共有 人打赏支持
12叔
粉丝 141
博文 24
码字总数 52121
作品 3
×
12叔
如果觉得我的文章对您有用,请随意打赏。您的支持将鼓励我继续创作!
* 金额(元)
¥1 ¥5 ¥10 ¥20 其他金额
打赏人
留言
* 支付类型
微信扫码支付
打赏金额:
已支付成功
打赏金额: