聊一聊 ThreadLocal 和 volatile

多线程两大问题:竟态条件、内存可见性

竟态条件

所谓竟态条件(race condition)就是在多个线程访问和操作同一个共享资源时,执行的结果受线程执行顺序的影响,可能正确也可能不正确。
首先要知道,每个线程都有自己的栈和程序计数器,当访问一个共享资源时,会把它读取到自己的内存中去,执行完操作以后再写回内存。
了解了上面这一点,接下来再看一个经典的竟态条件例子:

static int count = 0; 
public static void main(String[] args) {
        // 线程计数器
        CountDownLatch downLatch = new CountDownLatch(10);
        for (int i = 0; i < 10; i++) {
            new Thread(() -> {
                // 每个线程都对 count 进行1000次加1
                for (int i1 = 0; i1 < 1000; i1++) {
                    incrementCount();
                }
                downLatch.countDown();
            },"Task-"+ i).start();
        }

        try {
            // 等待10个线程执行完毕
            downLatch.await();
            System.out.println(count); // 正确应该输出 10 * 1000 = 10000
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }

}

public static void incrementCount() {
       count++;
}

如果你执行上面代码会发现输出的结果大部分时候都是九千多左右,并不符合我们的预期。之所以会发生这种情况就是竟态条件导致的,下面我们画图来粗略的看一看为什么会丢失一部分操作:

从上图基本可以清楚的看到,在多线程下不作任何措施的情况下去访问共享资源是非常不安全的,所以一般我们会使用不同情况去解决这个问题:

  1. 使用 synchronized 关键字
  2. 使用原子变量,比如上面 count 可以使用 AtomicInteger 代替
    1. 比如可以这样去定义 AtomicInteger count = new AtomicInteger(0);,使用count.incrementAndGet()原子性方法完成++操作。
  3. 使用显式锁

内存可见性

多个线程可以共享访问和操作相同的变量,但一个线程对一个共享变量的修改,另一个线程不一定
马上就能看到,甚至永远也看不到。
这可能有悖直觉,我们来看一个例子,如下代码所示:

private static boolean shutdown = false;
public static void main(String[] args) {

    Thread thread = new Thread(() -> {
        while (!shutdown) {

        }
        System.out.println(Thread.currentThread().getName() + "退出执行...");
    },"Task-1");
    thread.start();
    try {
        Thread.sleep(1000);
        shutdown = true;
    } catch (InterruptedException e) {
        throw new RuntimeException(e);
    }
    System.out.println(Thread.currentThread().getName() +"开始执行...");
}

因代码逻辑简单,这里就不做过多注释了,执行上面代码后线程 Task-1 理应正常退出执行,但是实际上执行结果是程序进入了死循环,线程 Task-1 永远也读取不到 shutdown 最新的值。这是为什么呢?
首先需要了解,计算机系统不止会把数据存到内存中 ,还会存在CPU寄存器和不同级别的缓存中,一般情况下都是先放进CPU寄存器或者缓存,然后再进一步同步到内存中去。当访问一个数据时,可能先从CPU寄存器和缓存中获取,而不一定从内存中取。当修改一个数据时,也不一定会立即同步到内存,可能会先更新到缓存,然后再慢慢写回内存。在单线程中这样的设计并不会导致什么问题,但是在多线程,特别是多CPU的情况下,这会是严重的问题。一个线程对内存的修改,另一个线程看不到,一是修改没有及时同步到内存,二是另一个线程根本就没从内存读。
解决这个问题也是有多种方法:

  1. 使用 synchronized 或者显示锁
  2. 使用 volatile 关键字

volatile 的使用

在Java中提供了一个 volatile 的关键字,它就是专门为了解决多线程的内存可见性问题的。具体使用也非常简单,就拿上面内存可见性的例子,我们只需要在 shutdown 变量之前加上 volatile 关键字,就可以完美解决内存可见性问题了。
加了volatile之后,Java会在操作对应变量时插入特殊的指令,保证读写到内存最新值,而非缓存的值。

private volatile static boolean shutdown = false;
public static void main(String[] args) {

    Thread thread = new Thread(() -> {
        while (!shutdown) {

        }
        System.out.println(Thread.currentThread().getName() + "退出执行...");
    },"Task-1");
    thread.start();
    try {
        Thread.sleep(1000);
        shutdown = true;
    } catch (InterruptedException e) {
        throw new RuntimeException(e);
    }
    System.out.println(Thread.currentThread().getName() +"开始执行...");
}

输出结果和预期的一样:
image.png

ThreadLocal 的使用

TheadLocal 的意思是线程本地变量,可以将它理解成一种容器,当将数据放进TheadLocal容器后,当每一个线程访问它的时候都会拷贝一份数据到自己本地,各自对这个数据进行读写操作,线程之间互不影响,完全隔离。
它的概念可能不是很好理解,下面改造一下竟态条件的代码进一步理解TheadLocal的作用:

// 定义一个 ThreadLocal 容器,存储Integer类型的数据并初始化一个0的值
static ThreadLocal<Integer> count = ThreadLocal.withInitial(() -> new Integer(0));
public static void main(String[] args) {
        // 线程计数器
        CountDownLatch downLatch = new CountDownLatch(10);
        for (int i = 0; i < 10; i++) {
            new Thread(() -> {
                // 每个线程都对 count 进行1000次加1
                for (int i1 = 0; i1 < 1000; i1++) {
                    incrementCount();
                }
                System.out.println(Thread.currentThread().getName()+": count => "+count.get());
                downLatch.countDown();
            },"Task-"+ i).start();
        }

        try {
            // 等待10个线程执行完毕
            downLatch.await();
            System.out.println(Thread.currentThread().getName()+": count => " + count.get());
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
}

public static void incrementCount() {
    Integer num = count.get();
    num++;
    count.set(num);
}

输出结果:
image.png
看到这个结果有没有发现什么?上面我们让每个线程都对count进行1000次加1,10个线程执行这个操作那么正常count的值最终应该是10000。
可是实际输出的结果并不是我们预期的那样,反而是每个线程都单独的对各自内部的count进行了1000次加1,而main函数因为并没有对count进行任何操作,所以输出为0。
这就是TheadLocal的作用,当我们开发场景中需要每个线程都独享这个共享资源,并且不想让每个线程内部都创建一个对象以不免资源浪费,就可以使用TheadLocal来实现。

ThreadLocal 的原理

那么TheadLocal是如何实现的保证每个线程都独享一份共享资源的呢?理解这个就需要了解ThreadLocal的源码实现了。
但是我不希望把它分析的太复杂,当我看完源码后对ThreadLocal的总结就一句话:TheadLocal 内部维护着每个线程所对应的值,这个值通过 ThreadLocalMap(和 HashMap 类似) 存放,键就是线程本身,值就是存放的数据。
接下来,带着这个理解来看ThreadLocal的源码:

set 方法:
  1. 首先我们从ThreadLocal的set方法开始入手:
public void set(T value) {
    // 获取当前线程,也就是说谁调用的就是获取谁
    Thread t = Thread.currentThread();
    // 根据线程获取到一个 ThreadLocalMap 对象
    ThreadLocalMap map = getMap(t);
    // 不为空就以当前线程为键,存放数据。否则就执行createMap方法
    if (map != null)
        map.set(this, value);
    else
        createMap(t, value);
}
  1. 接着分析 getMap 方法做了什么,它直接返回了一个Thread 类的 threadLocals 变量。
    image.png
  2. 那么看一下 threadLocals 变量是什么,是我们上面说的 ThreadLocalMap 类,但看起来这个类时 ThreadLocal 的一个内部类,默认它是一个空的。
    image.png
  3. 现在知道了getMap方法做了什么,那么继续往下分析,第一次进来肯定会走createMap方法,这个方法也很简单,创建了一个ThreadLocalMap对象,并以当前线程为键进行存值。
    image.png
get 方法
  1. 理解了ThreadLocal的存值逻辑,其实get方法我们自己就会写了,直接根据当前线程获取到ThreadLocalMap对象,如果不为空直接再根据当前线程取值就好了,来看下源码实现:
public T get() {
    // 获取当前线程
    Thread t = Thread.currentThread();
    // 根据当前线程获取ThreadLocalMap对象
    ThreadLocalMap map = getMap(t);
    // 如果不为空,就从 ThreadLocalMap 中取值并返回,否则执行 setInitialValue()
    if (map != null) {
        ThreadLocalMap.Entry e = ThreadLocalMap.getEntry(this);
        if (e != null) {
            @SuppressWarnings("unchecked")
            T result = (T)e.value;
            return result;
        }
    }
    return setInitialValue();
}
  1. 先分析 Entry,发现它是 ThreadLocalMap 的一个静态内部类,主要用于存放key、value,关键在于它继承了 WeakReference,这就表示它是一个弱引用,并且指向的是 key 值。
    image.png
  2. 为空时,调用setInitialValue()方法,这个方法其实就是在根据当前线程获取不到ThreadLocalMap的时候返回一个null回去:
 private T setInitialValue() {
        // initialValue() 方法直接返回一个 null
        T value = initialValue();
        Thread t = Thread.currentThread();
        ThreadLocalMap map = getMap(t);
        if (map != null)
            map.set(this, value);
        else
            createMap(t, value);
        return value;
    }
内存泄露问题

ThreadLocal 的 get、set 方法讲完了,是不是很简单,那么下面再来说一说 ThreadLocal 使用时可能会造成哪些问题,结合这个问题我们来讲一讲 ThreadLoca l的 remove 方法。
:::info
首先这里可以先去重温一下JAVA中的引用类型,在第4节帮你贴心整理了一份。
:::
前面有提到 ThreadLocalMap 的 key 是一个弱引用,那么在 JVM 进行垃圾回收时就会被直接回收掉。而 value 是一个 Object 对象,一般都是通过 new 的方式进行创建所以它属于强引用,并不会被垃圾回收。这就出现一个问题,当 ThreadLocalMap 中的 key 被回收了也就代表这个 value 也就不再被使用了,但是它又不会被垃圾回收所以就会一直在内存中,变为一个冗余的脏数据。如果不恰当的使用 ThreadLocal 就有可能会造成内存泄露的风险。
如何避免这种风险呢?在 ThreadLocal 的内部提供了一个 remove 方法,在每次使用完之后去调用它的 remove 方法将对应的键值对进行删除,就能避免内存泄露问题。
下面来看一下 remove 方法的实现:

 public void remove() {
     ThreadLocalMap m = getMap(Thread.currentThread());
     if (m != null)
         m.remove(this);
 }

实现很简单,就是获取到对应线程的 ThreadLocalMap 然后执行 remove 方法将其删除就OK了,至此 ThreadLocal 的使用和原理就讲完了,感谢阅读,如有哪里理解有误,希望同学帮忙指正!

引用类型

  1. 强引用(Strong Reference)
    • 定义:强引用是Java中最常见的引用类型。当一个对象具有强引用时,垃圾收集器是不会回收这个对象的。
    • 用法:通常使用关键字new来创建一个对象,并产生一个强引用。例如:Object obj = new Object();
    • 特点:
      • 具有强引用的对象不会被垃圾收集器回收。
      • 当系统内存空间不足时,JVM宁愿抛出OutOfMemoryError异常,也不会靠随意回收具有强引用的对象来满足内存需求。
      • 只有在引用被显式地设置为null时,垃圾回收器才会在合适的时候回收该对象。
  2. 软引用(Soft Reference)
    • 定义:软引用用来描述一些还有用但并非必需的对象。
    • 用法:通过SoftReference类实现。例如:SoftReference softRef = new SoftReference<>(obj);
    • 特点:
      • 当系统内存足够时,软引用关联的对象不会被回收。
      • 只有在内存不足时,垃圾收集器才会回收软引用关联的对象。
      • 软引用通常用于实现内存敏感的缓存,如图片缓存等。
  3. 弱引用(Weak Reference)
    • 定义:弱引用也是用来描述非必需对象的,但其强度比软引用更弱。
    • 用法:通过WeakReference类实现。例如:WeakReference weakRef = new WeakReference<>(obj);
    • 特点:
      • 一个对象是否有弱引用存在,完全不会对其生存时间构成影响。
      • 当垃圾收集器开始运行时,无论当前内存空间是否足够,都会回收被弱引用的对象。
      • 弱引用通常用于实现可有可无的缓存、事件处理和线程控制等场景。
  4. 虚引用(Phantom Reference)
    • 定义:虚引用是Java中最弱的一种引用类型。
    • 用法:通过PhantomReference类实现,并通常与ReferenceQueue联合使用。
    • 特点:
      • 无法通过虚引用直接获取到被引用的对象实例。调用get()方法始终返回null。
      • 主要用于在对象被垃圾回收时接收一个系统通知。
      • 在对象被垃圾回收器回收之前,可以通过重写Reference类的finalize()方法执行一些清理操作。
      • 虚引用不能用于阻止对象被垃圾回收,它仅仅提供了对象回收的通知。

总结:

  • 强度:强引用 > 软引用 > 弱引用 > 虚引用
  • 回收时机:强引用(除非显式设为null) > 软引用(内存不足时) > 弱引用(垃圾收集时) > 虚引用(不参与回收决策)
  • 用途:强引用是基本使用;软引用用于缓存;弱引用用于实现可有可无的缓存、事件处理和线程控制等;虚引用主要用于追踪对象的销毁过程,执行清理操作。
  • 28
    点赞
  • 19
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值