多线程两大问题:竟态条件、内存可见性
竟态条件
所谓竟态条件(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++;
}
如果你执行上面代码会发现输出的结果大部分时候都是九千多左右,并不符合我们的预期。之所以会发生这种情况就是竟态条件导致的,下面我们画图来粗略的看一看为什么会丢失一部分操作:
从上图基本可以清楚的看到,在多线程下不作任何措施的情况下去访问共享资源是非常不安全的,所以一般我们会使用不同情况去解决这个问题:
- 使用 synchronized 关键字
- 使用原子变量,比如上面 count 可以使用 AtomicInteger 代替
- 比如可以这样去定义
AtomicInteger count = new AtomicInteger(0);
,使用count.incrementAndGet()
原子性方法完成++操作。
- 比如可以这样去定义
- 使用显式锁
内存可见性
多个线程可以共享访问和操作相同的变量,但一个线程对一个共享变量的修改,另一个线程不一定
马上就能看到,甚至永远也看不到。
这可能有悖直觉,我们来看一个例子,如下代码所示:
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的情况下,这会是严重的问题。一个线程对内存的修改,另一个线程看不到,一是修改没有及时同步到内存,二是另一个线程根本就没从内存读。
解决这个问题也是有多种方法:
- 使用 synchronized 或者显示锁
- 使用 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() +"开始执行...");
}
输出结果和预期的一样:
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);
}
输出结果:
看到这个结果有没有发现什么?上面我们让每个线程都对count进行1000次加1,10个线程执行这个操作那么正常count的值最终应该是10000。
可是实际输出的结果并不是我们预期的那样,反而是每个线程都单独的对各自内部的count进行了1000次加1,而main函数因为并没有对count进行任何操作,所以输出为0。
这就是TheadLocal的作用,当我们开发场景中需要每个线程都独享这个共享资源,并且不想让每个线程内部都创建一个对象以不免资源浪费,就可以使用TheadLocal来实现。
ThreadLocal 的原理
那么TheadLocal是如何实现的保证每个线程都独享一份共享资源的呢?理解这个就需要了解ThreadLocal的源码实现了。
但是我不希望把它分析的太复杂,当我看完源码后对ThreadLocal的总结就一句话:TheadLocal 内部维护着每个线程所对应的值,这个值通过 ThreadLocalMap(和 HashMap 类似) 存放,键就是线程本身,值就是存放的数据。
接下来,带着这个理解来看ThreadLocal的源码:
set 方法:
- 首先我们从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);
}
- 接着分析 getMap 方法做了什么,它直接返回了一个Thread 类的 threadLocals 变量。
- 那么看一下 threadLocals 变量是什么,是我们上面说的 ThreadLocalMap 类,但看起来这个类时 ThreadLocal 的一个内部类,默认它是一个空的。
- 现在知道了getMap方法做了什么,那么继续往下分析,第一次进来肯定会走createMap方法,这个方法也很简单,创建了一个ThreadLocalMap对象,并以当前线程为键进行存值。
get 方法
- 理解了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();
}
- 先分析 Entry,发现它是 ThreadLocalMap 的一个静态内部类,主要用于存放key、value,关键在于它继承了 WeakReference,这就表示它是一个弱引用,并且指向的是 key 值。
- 为空时,调用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 的使用和原理就讲完了,感谢阅读,如有哪里理解有误,希望同学帮忙指正!
引用类型
- 强引用(Strong Reference)
- 定义:强引用是Java中最常见的引用类型。当一个对象具有强引用时,垃圾收集器是不会回收这个对象的。
- 用法:通常使用关键字new来创建一个对象,并产生一个强引用。例如:Object obj = new Object();
- 特点:
- 具有强引用的对象不会被垃圾收集器回收。
- 当系统内存空间不足时,JVM宁愿抛出OutOfMemoryError异常,也不会靠随意回收具有强引用的对象来满足内存需求。
- 只有在引用被显式地设置为null时,垃圾回收器才会在合适的时候回收该对象。
- 软引用(Soft Reference)
- 定义:软引用用来描述一些还有用但并非必需的对象。
- 用法:通过SoftReference类实现。例如:SoftReference softRef = new SoftReference<>(obj);
- 特点:
- 当系统内存足够时,软引用关联的对象不会被回收。
- 只有在内存不足时,垃圾收集器才会回收软引用关联的对象。
- 软引用通常用于实现内存敏感的缓存,如图片缓存等。
- 弱引用(Weak Reference)
- 定义:弱引用也是用来描述非必需对象的,但其强度比软引用更弱。
- 用法:通过WeakReference类实现。例如:WeakReference weakRef = new WeakReference<>(obj);
- 特点:
- 一个对象是否有弱引用存在,完全不会对其生存时间构成影响。
- 当垃圾收集器开始运行时,无论当前内存空间是否足够,都会回收被弱引用的对象。
- 弱引用通常用于实现可有可无的缓存、事件处理和线程控制等场景。
- 虚引用(Phantom Reference)
- 定义:虚引用是Java中最弱的一种引用类型。
- 用法:通过PhantomReference类实现,并通常与ReferenceQueue联合使用。
- 特点:
- 无法通过虚引用直接获取到被引用的对象实例。调用get()方法始终返回null。
- 主要用于在对象被垃圾回收时接收一个系统通知。
- 在对象被垃圾回收器回收之前,可以通过重写Reference类的finalize()方法执行一些清理操作。
- 虚引用不能用于阻止对象被垃圾回收,它仅仅提供了对象回收的通知。
总结:
- 强度:强引用 > 软引用 > 弱引用 > 虚引用
- 回收时机:强引用(除非显式设为null) > 软引用(内存不足时) > 弱引用(垃圾收集时) > 虚引用(不参与回收决策)
- 用途:强引用是基本使用;软引用用于缓存;弱引用用于实现可有可无的缓存、事件处理和线程控制等;虚引用主要用于追踪对象的销毁过程,执行清理操作。