并发框架下的“基础类型”——浅析基本类型、ThreadLocal、原子类的线程安全机制
发布日期:2021-05-08 09:34:30 浏览次数:10 分类:精选文章

本文共 3234 字,大约阅读时间需要 10 分钟。

写在前面

在接触java-web开发的时候,常常会思考多个会话在调用同一个方法的时候会发生的事情。其实自然而然就会想到学习java时常提到的“线程安全”概念,原子性、顺序性、可见性。但是对于同样是线程安全的变量或者说容器,它们的安全特性是否有区别,换言之,在特定的场景下,即使我们不从性能角度出发思考问题,我们也仍然要使用不同的线程安全类型。

正文

1. 阅读一段代码

public class Main {       // 定义一个大数 1M    private static final int NUM = 1 << 20;    private static ExecutorService threadPool = Executors.newCachedThreadPool();    private static int normalInt = NUM;    private static AtomicInteger atomicInt = new AtomicInteger(NUM);    private static ThreadLocal
localInt = ThreadLocal.withInitial(() -> NUM); public static void main(String[] args) throws InterruptedException { // 切回主线程用的latch CountDownLatch latch = new CountDownLatch(NUM); long begin = System.currentTimeMillis(); // 计时开始 for (int i = 0; i < NUM; i++) { threadPool.execute(() -> { //分别用对应方法迭减 normalInt--; atomicInt.getAndAdd(-1); localInt.set(localInt.get() - 1); // latch计数 latch.countDown(); }); } long time = System.currentTimeMillis() - begin; // 计时结束 latch.await(); // 等待任务完成切回主线程 System.out.println("Origin: 2^20 = " + NUM + " normal: " + normalInt + " atomic: " + atomicInt.get() + " local: " + localInt.get()); System.out.println("time: " + time / 1000D + "s"); threadPool.shutdown(); }}
  • 在这段代码中,我们创建了1M个相同的任务,利用线程池多线程执行。每个任务都是对变量进行指定方法对三种成员分别迭减。观察最后输出的结果。对于这种计数场景,我们期望最后得到的结果为0。
  • 输出:
Origin: 2^20 = 1048576 normal: 3121 atomic: 0 local: 1048576time: 4.973s

2. 基本类型int为什么输出了非0

  • 首先要说的是,迭减(--)运算并不是一个原子操作,迭减的本质(class原码)是创建一个临时变量来存储“x - 1”的结果,然后再把这个值赋给x,这个过程是可分的。
  • 我们假设有两个线程对同一个变量做迭减运算。
    • 线程A:读x,计算x-1,存为y1
    • 线程B:读x,计算x-1,存为y2
    • 线程A:写x为y1
    • 线程B:写x为y2
  • 显而易见,y1和y2都是相同的值,迭减操作表现在最后的x的值上则是只减了一次。问题发生的本质原因就是原子性被破坏了,线程B的读写操作被A的写操作插入,导致B的写操作并不是基于所读取的原值进行的。
  • 按照测试代码输出的结果来看,类似的插入发生了3121次,导致了最后没有成功清0的情况。
  • 总结:基础类型线程不安全,如果要使用基础类型,在并发场景下则应当使用恰当的锁机制来保护基础类型变量。

3. ThreadLocal类型为什么没有任何改变?

  • 这就要谈到ThreadLocal的本质了,ThreadLocal内部维护了一个HashMap,这个HashMap让每一个访问ThreadLocal变量的线程分配到一个各自独立的拷贝。也就是说,每个线程生命周期结束之后,对于各自操作的对象的引用的维护也就消失了,在主线程最后输出的值实际上没有被任何工作线程操作过,自然就会输出原值。
  • 利用ThreadLocal进行操作很像是在Runnable实现类中写了一个局部变量,但其执行的本质并不同,也能更为优雅地实现方法层面对各线程任务的统一控制:
    • 局部变量存储在JVM的栈区,而ThreadLocal则是为每一个线程在堆区维护了同一个HashMap中的不同元素。
    • ThreadLocal将变量统一维护在了一起,这方便对方法层次或者实例层次对一个语义的变量进行统一管理。
    • ThreadLocal提供了可以自定义的初始化方法。
  • 总结:ThreadLocal是线程安全的,但是对于共享类任务(可见性)并不适用。对于不强调共享的任务来说,局部变量已经足够安全,但是为了统一管理这些变量至堆区,或者说解藕方法实现与Runnable实现,ThreadLocal仍然有应用的必要性。

4. 原子类型提供了线程安全

  • 如测试代码输出的结果,原子类型保障了这个强调可见性和原子性的任务的线程安全。其本质是原子类型的主要方法是原子的。
  • 我们不妨来看一下原子类(AtomicInteger是其中之一)的实现源码:
private static final Unsafe U = Unsafe.getUnsafe();private volatile int value;public final int getAndAdd(int delta) {       return U.getAndAddInt(this, VALUE, delta);}
  • 原子类维护了一个volatile变量保证其可见性,而后利用unsafe方法来具体执行操作,unsafe方法内部:
public final int getAndAddInt(Object o, long offset, int delta) {       int v;    do {           v = getIntVolatile(o, offset);    } while (!weakCompareAndSetInt(o, offset, v, v + delta));        return v;}
  • unsafe方法内部利用了CAS+自旋的方式实现了一个乐观锁,用乐观锁的思想来完成值的读写原子性保障。
  • 结论:原子类的方法可以保障线程安全,其实现本质是乐观锁+volatile关键字。在大多数情况下,原子类对比基本类型+锁的方式效率更高,因为原子类内部是乐观锁实现。换言之,从敏捷开发的角度来思考,原子类型永远值得被在并发环境下优先考虑
    。从性能角度出发,到底是选择基本类型+锁,还是原子类,本质上是一个悲观锁和乐观锁机制的抉择。
上一篇:计算机组成原理(知识体系-1)
下一篇:基于单片机ADC0809八路电压采集系统设计-毕设课设资料

发表评论

最新留言

能坚持,总会有不一样的收获!
[***.219.124.196]2025年04月09日 18时44分41秒