Java 多线程并发【3】线程安全

线程安全比较严谨的定义是:

当多个线程访问一个对象时,如果不考虑这些线程的运行环境下的调度和交替执行,也就不需要进行额外的同步,或者在调用方进行任何其他的协调操作。调用这个对象的行为都可以获得正确的结果,那么这个对象是线程安全的。

这个定义通俗点的意思就是,多个线程访问一个对象时,如果每个线程依次使用这个对象,那么就不需要进行额外保障同步的操作。调用这个对象的行为的结果也一定是正确的。这就是线程安全(说白了就是:同步执行操作,一定线程安全)。

那么在 Java 中的线程安全是如何实现的呢?

在分析这个问题时,我们先把线程安全视为多个程度的线程安全,而不是非黑即白的一定是线程安全 / 线程不安全。

线程安全到不安全的程度依次是:

  • 不可变
  • 绝对线程安全
  • 相对线程安全
  • 线程兼容
  • 线程对立

不可变

在 Java 不可变的对象一定是线程安全的,无论是对象的方法实现还是方法的调用者都无需采取任何线程安全的保障措施。因为对于外部来说,这个对象是只读的,没有写操作导致的一系列问题。

绝对线程安全

绝对的线程安全满足文章开头的定义,这个定义是很严格的,不管运行时环境如何,调用者都不需要任何额外的线程安全保障措施。

通常绝对的线程安全是很难实现的,Java 中大多数标注自己是线程安全的类,都不是绝对的线程安全。

相对线程安全

相对线程安全就是我们通常意义上讲的线程安全,它需要保证对这个对象单独的操作是线程安全的,在调用时无需做额外的保障措施。但是对于一些有序的连续调用,还是需要调用时使用额外的同步手段来保证顺序执行。

在 Java 中,大多数线程安全的类都属于相对线程安全,如 Vector 、 HashTable 等。

线程兼容

线程兼容指对象本身并不是线程安全的,但是可以通过调用者正确地使用同步手段来保障对象在并发环境可以安全地使用。

我们常说的一个类不是线程安全的,绝大多数就是指线程兼容。

Java 的 API 中,所有不保障线程安全的类都是属于线程兼容,例如 ArrayList 和 HashMap 等。

线程对立

线程对立是指无论调用方是否采取同步保障措施,都无法在多线程环境中并发使用的代码。

Java 语言天生具备多线程特性,这种线程对立的代码很少会出现,并且应当尽量避免。

一个线程对立的例子是 Thread 的 suspend()resume() 方法,如果两个线程同时持有一个线程对象,一个尝试去中断线程,另一个尝试去恢复线程,如果并发进行,无论调用时是否进行了同步,目标线程都是存在死锁风险的。如果 suspend() 中断的线程就是即将执行 resume()的线程,那么一定会产生死锁,也是由于这个原因,这两个方法已经被废弃了。

线程安全的实现方案

线程安全的实现通常与代码逻辑有关,JVM 提供了同步和锁机制供开发者编写出线程安全的代码逻辑。线程安全的实现方案分为三种:

  1. 互斥同步
  2. 非阻塞同步
  3. 无同步方案

互斥同步

互斥同步是一种并发正确性的保障手段。同步是指在多个线程并发访问共享数据时,保证共享数据在同一个时刻只被一个线程使用(或一些,在使用信号量时的情况)。

而互斥是一种实现同步的手段。互斥是一种手段,同步是互斥导致的结果。

在 Java 中,最基本的互斥手段就是 synchronized 关键字。它的原理是:经过编译后,会在同步代码块前后分别插入两个字节码指令 monitorentermonitorexit。这两个指令需要一个 reference类型的参数来指明需要加解锁的对象。如果 synchronized 明确指明了对象参数,那就是这个对象的 reference,如果没有明确指定,那就根据 synchronized 修饰的是实例方法还是静态方法去取对应的对象实例或 Class 对象来作为加锁对象。

被加锁对象会在对象头中的锁计数器进行加一操作,来表示当前被加锁一次。当计数器为 0 时,添加到这个对象上的所有锁都被释放了。如果对象的锁计数器不为 0 ,尝试使用这个对象的线程就会阻塞等待。

synchronized 对同一个线程来说是可重入的,不会把自己锁死。

同步代码块在已进入的线程执行完成前,会阻塞后面其他线程的进入。

除了 synchronized 关键字,还可以使用 java.util.concurrent 包中提供的 ReentrantLock来实现同步。

这里不详细讲解锁的具体内容,后续会进行详细的对比讲解。

不管是Lock 接口的实现还是 synchronized ,都会映射到操作系统层面的内核线程,而对内核线程的操作会消耗系统资源。

非阻塞同步

互斥同步最主要的问题就是进行线程阻塞和唤醒会带来性能问题,因此互斥同步也称为阻塞同步。从处理问题的角度上,互斥同步属于悲观的并发策略:总是认为不加同步措施,就会出现问题,无论数据是否发生竞争,都进行加锁、用户态内核态切换、维护锁计数器和检查是否有被阻塞的线程需要唤醒等操作。

而与之对立的是基于冲突检测的乐观并发策略:先进行操作,如果没有其他线程竞争数据,操作就成功了;如果有其他线程进行数据争用,采取其他补救措施(重试),这种乐观的并发策略的多数实现都不需要挂起线程,因此称为非阻塞同步。

Java 中非阻塞同步实现方案是 CAS 操作,JDK 中封装了 sun.misc.Unsafe 类提供了快速的 CAS 操作方法,但并没开放给开发者直接使用,而是通过 J.U.C 中其他的一些 API 来间接使用,常见的就是原子类:

AtomicInteger i = new AtomicInteger();
i.incrementAndGet();

// incrementAndGet 内部调用
public final int incrementAndGet() {
    return unsafe.getAndAddInt(this, valueOffset, 1) + 1;
}

尽管 CAS 看起来是个很优美的方案,但这种操作并不能覆盖互斥同步的所有使用场景,并且 CAS 从语义上来说并不是完美的,它存在一个逻辑漏洞:

ABA 问题

如果一个变量 V 初次读取的时候是 A 值,并且在准备赋值的时候检查到它仍然为 A 值,那我们就能说它的值没有被其他线程改变过了吗?如果在这段期间它的值曾经被改成了 B ,后来又被改回为 A ,那 CAS 操作就会误认为它从来没有被改变过。

J.U.C 包为了解决这个 ABA 问题,提供了一个带有标记的原子引用类 AtomicStampedReference ,它可以通过控制变量值的版本来保证 CAS 的正确性,但大部分 ABA 问题并不会影响并发程序的正确性,所以也就没有什么实质作用,如果需要解决 ABA 问题,还是得用互斥同步中的方案。

无同步方案

保证线程安全并不一定要进行同步保障操作。同步只是保证共享数据在多线程争用时的正确性保障手段,如果一个方法本身就不涉及共享数据,那么也就不需要任何同步保障措施。因此,会存在一些天生线程安全的代码。

可重入代码

可以在代码执行的任何时刻中断它,转而去执行其他逻辑,而当重新回来执行后续代码逻辑时,程序不会出现任何的错误。

相对线程安全来说,可重入是更基本的特征,可重入可以保证线程安全。

可以通过一个简单的原则来判读代码是否是具备可重入特性:如果一个方法,它的返回结果是可以预测的,只要输入了相同的数据,就都能返回相同的结果,那它就满足可重入性的要求,当然也就是线程安全的。

线程本地存储

我们所说的线程安全都是在处理共享数据。如果数据是线程私有的,那么也就不存在线程争用的问题。

符合这种特征的例子有很多,例如,大多数消费队列的架构模式(生产者-消费者模式)都会将产品的消费过程在同一个线程中进行。

Java语言中,如果一个变量要被多线程访问,可以使用 volatile 关键字声明它为“易变的”;如果一个变量要被某个线程独享,可以通过ThreadLocal 类来实现线程本地存储的功能。

总结

  • 线程安全

    • 线程安全广义上是分层级的。
    • 我们常说的线程安全是相对线程安全;常说的不是线程安全指的是线程兼容。
    • 线程对立代码会导致死锁等问题,应当尽量避免。
  • 线程安全的实现

    • 可重入代码
    • 线程本地存储
    • 互斥同步是一种悲观策略,以性能为代码保证线程安全。

    • 非阻塞同步是一种乐观策略,通常方案是 CAS 。

    • 无同步方案是指代码天生就是线程安全的。


原文始发于微信公众号(八千里路山与海):Java 多线程并发【3】线程安全

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。

文章由极客之音整理,本文链接:https://www.bmabk.com/index.php/post/85133.html

(0)
小半的头像小半

相关推荐

发表回复

登录后才能评论
极客之音——专业性很强的中文编程技术网站,欢迎收藏到浏览器,订阅我们!