Java中引用类型和ThreadLocal

大厂面试题:

  • 1.Java中的引用类型有哪几种? 强软弱虚
  • 2.每种引用类型的特点是什么?
  • 3.每种引用类型的应用场景是什么?
  • 4.ThreadLocal你了解吗
  • 5.ThreadLocal应用在什么地方? Spring事务方面应用到了
  • 6.ThreadLocal会产生内存泄漏你了解吗?

1、java中引用类型及特点

强 引用: 最普通的引用 Object o = new Object()
软 引用: 垃圾回收器, 内存不够的时候回收 (缓存)
弱 引用: 垃圾回收器看见就会回收 (防止内存泄漏)
虚 引用: 垃圾回收器看见二话不说就回收,跟没有一样 (管理堆外内存) DirectByteBuffer -> 应用到NIO Netty

finalize(): 当对象被回收时, finalize()方法会被调用, 但是不推荐使用去回收一些资源,因为不知道他什么时候会被调用, 有时候不一定会调用

public class C {
    @Override
    protected void finalize() throws Throwable {
        System.out.println("finalize");
    }
}

1.1 强引用

正常引用,但没有人指向的时候就会被回收

import java.io.IOException;
/**
 * 强引用
 */
public class R1_NormalReference {
    public static void main(String[] args) throws IOException {
        //正常引用
        C c = new C();
        c = null;//没人指向
        System.gc();//DisableExplicitGC

        //阻塞一下,方便看结果
        System.in.read();
    }
}

1.2 软引用

垃圾回收器, 内存不够的时候回收 (缓存)

import java.io.IOException;
import java.lang.ref.SoftReference;

/**
 * 软引用
 */
public class R2_SoftReference {
    public static void main(String[] args) {
        SoftReference<byte[]> soft = new SoftReference<>(new byte[1024 * 1024 * 10]);//10M
        System.out.println(soft.get());
        //gc回收
        System.gc();
        try {
            Thread.sleep(500);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(soft.get());

        //再分配一个数组,好heap(堆)放不下, 这个时候系统会回收一次, 如果不够,会把软引用回收
        byte[] bytes = new byte[1024 * 1024 * 15];
        System.out.println(soft.get());

    }
}

结果:
[B@1540e19d
[B@1540e19d
null

前提设置 -Xmx30M 堆内存最大30M 用于测试

给堆内存分配空间

1.3 弱引用

遇到GC就会被回收

import java.lang.ref.WeakReference;
/**
 * 弱引用
 */
public class R3_WeakReference {
    public static void main(String[] args) {
        WeakReference<C> weak = new WeakReference<>(new C());
        System.out.println(weak.get());
        //gc回收
        System.gc();
        //遇到GC就会被回收
        System.out.println(weak.get());
    }
}

结果:
com.cz.reference.C@3c679bde
null
finalize

1.4 虚引用

不管三七二十一 遇到直接回收

一个对象是否有虚引用的存在,完全不会对其生命周期构成影响,也无法通过虚引用获得一个对象实例。
虚引用只有一个含有队列的构造函数,也就是说,虚引用必须和队列同时使用,换句话说,虚引用是通过队列来实现它的价值的。

当虚引用所指向的那块内存被回收之后,JVM就会把那个虚引用的变量放到队列中,表示对象被回收。

在这里插入图片描述

import java.lang.ref.PhantomReference;
import java.lang.ref.Reference;
import java.lang.ref.ReferenceQueue;
import java.util.LinkedList;
import java.util.List;
/**
 * 虚引用
 */
public class R4_PhantomReference {
    private static final List<Object> LIST = new LinkedList<>();
    private static final ReferenceQueue QUEUE = new ReferenceQueue();

    public static void main(String[] args) {

        PhantomReference<C> phantomReference = new PhantomReference<>(new C(),QUEUE);

        new Thread(() -> {
            while (true){
                LIST.add(new byte[1024*1024]);
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                    Thread.currentThread().interrupt();
                }
                System.out.println(phantomReference.get());
            }
        }).start();

        new Thread(() -> {
            while (true){
                Reference<? extends C> poll = QUEUE.poll();
                if (poll != null){
                    System.out.println("-----虚引用对象被JVm回收了--------" + poll);
                    return;
                }
            }
        }).start();
    }
}
结果:
null
null
finalize
null
null

使用虚引用的目的就是为了得知对象被GC的时机,所以可以利用虚引用来进行销毁前的一些操作,比如说资源释放等。这个虚引用对于对象而言完全是无感知的,有没有完全一样,但是对于虚引用的使用者而言,就像是待观察的对象的把脉线,可以通过它来观察对象是否已经被回收,从而进行相应的处理。
事实上,虚引用有一个很重要的用途就是用来做堆外内存的释放,DirectByteBuffer就是通过虚引用来实现堆外内存的释放的。

总结: 强软弱虚

  • 强 正常的引用
  • 软 内存不够, 进行清除
    • 大对象的内存
    • 常用对象的缓存
  • 弱 遇到GC就会被回收
    • 缓存, 没有容器引用指向的时候就需要清除缓存
    • ThreadLocal
    • WeakReferenceMap
  • 虚 看见就回收, 且看不到值
    • 管理堆外内存

2 ThreadLocal

从Java官方文档中的描述:ThreadLocal类用来提供线程内部的局部变是。这种变畺在多线程环境下访问(通 过get和set方法访问)时能保证各个线程的变星=相对独立于其他线程内的变垦。ThreadLocal实例通常来说都是 private static类型的,用于关联线程和线程上下文。

2.1 特点

特点 内容
1.线程并发 在多线程并发场景下
2.传递数据 我们可以通过ThreadLocal在同一线程,不同组件中传递公共变量
(保存每个线程的数据,在需要的地方可以直接获取, 避免参数直接传递带来的代码耦合问题)
3.线程隔离 每个线程的变量都是独立的, 不会互相影响.(核心)
(各线程之间的数据相互隔离却又具备并发性,避免同步方式带来的性能损失)

2.2 ThreadLocal vs Synchronized

虽然ThreadLocal模式与Synchronized关键字都用于处理多线程并发访问变量的问题, 不过两者处理问题的角度和思路不同

synchronized ThreadLocal
原理 同步机制采用以时间换空间的方式,只提供了一份变量, 让不同的线程排队访问 ThreadLocal采用以空间换时间的方式, 为每一个线程都提供了一份变量的副本, 从而实现同访问而相不干扰
侧重点 多个线程之间访问资源的同步 多线程中让每个线程之间的数据相互隔离

2.3 ThreadLocal的内部结构

早期设计

现在让我们看一下ThreadLocal的内部原理, 探究它能实现线程数据隔离的原理

在这里插入图片描述

每个ThreadLocal都创建一个Map, 然后用Thread(线程) 作为Map的key, 要存储的局部变量作为Map的value, 这样就能达到各个线程的局部变量隔离的效果, 这是最简单的设计方法. 早期设计

JDK8 优化设计(现在的设计)

JDK8中ThreadLocal的设计是 : 每个Thread维护一个ThreadLocalMap, 这个Map的key是ThreadLocal实例本身,value才是真正要存储的值Object

在这里插入图片描述

  • 每个THreadLocal线程内部都有一个Map(ThreadLocalMap)

  • Map里面存储的ThreadLocal对象(key)和线程变量副本(Value)也就是存储的值

  • Thread内部的Map是由ThreadLocal维护的, 有THreadLocal负责向map获取和设置线程变量值

  • 对于不同的线程, 每次获取value(也就是副本值),别的线程并不能获取当前线程的副本值, 形成了副本的隔离,互不干扰.

对比一下,发现早期 的设计 ThreadLocal维护了(包含了,对应了)一个ThreadLocalMap,这个map中的Key就是线程

这样的坏处是:

  • 一般多线程的时候map存储的Entry很多,线程越来越大,map越来越大

  • 当一个Thread销毁时,由于ThreadLocalMap不会随之销毁,因为还有其他线程在里面存着。

  • 还有一点,如果拿Thread作为Key,试想一个Thread内,如果我定义了多个ThreadLocal对象,如果我想获取一个线程中存储的变量,那么我该如何获取确定的哪一个变量呢?

现在这种设计,讲Thread与ThreadLocal关系互换,ThreadLocalMap由Thread 对象维护(包含,阅读源码,发现ThreadLocalMap是 Thread的成员变量)这样,一个Thread内可以创建多个ThreadLocal对象,并获取对应的值

在这里插入图片描述

2.4 ThreadLocalMap

通过查看源码,发现 对于ThreadLocal对象的增删查改操作,其底层都是调用ThreadLocalMap实现的(具体关系可以看上面的图)

  • 一般首先要获取当前线程
  • 通过线程对象得到线程实例对象的成员变量ThreadLocalMap,
  • 对这个ThreadLocalMap进行增删查改

ThreadLocalMap成员变量

/**
       * The initial capacity -- MUST be a power of two.
       * 初始化容量,必须是2的整数次幂
       */
      private static final int INITIAL_CAPACITY = 16;

      /**
       * 存放数据的table, 同样数组长度必须是2的整数次幂
       * The table, resized as necessary.
       * table.length MUST always be a power of two.
       */
      private Entry[] table;

      /**
       * 数组里entrys的个数,可以判断table是否超过阈值 (存储的格式)
       * The number of entries in the table.
       */
      private int size = 0;

      /**
       * 阈值 进行扩容的阈值,表使用大于他的时候,进行扩容
       * The next size value at which to resize.
       */
      private int threshold; // Default to 0

存储结构

/**
       * The entries in this hash map extend WeakReference, using
       * its main ref field as the key (which is always a
       * ThreadLocal object).  Note that null keys (i.e. entry.get()
       * == null) mean that the key is no longer referenced, so the
       * entry can be expunged from table.  Such entries are referred to
       * as "stale entries" in the code that follows.
       翻译:
       * Entry继承WeakReference, 并且用ThreadLocal作为key
       * 如果key为null(entry.get() == null)意味着key不在被引用,因此这时候entry也可以从tab
       *中清除(被垃圾回收器回收) 
       */
      static class Entry extends WeakReference<ThreadLocal<?>> {
          /** The value associated with this ThreadLocal. */
          Object value;

          Entry(ThreadLocal<?> k, Object v) {
              super(k);
              value = v;
          }
      }

下面的一些介绍来自博客threadlocal 内部为什么是一个map,而不是set?

img

从上图中看出,在每个Thread类中,都有一个ThreadLocalMap的成员变量,该变量包含了一个Entry数组,该数组真正保存了ThreadLocal类set的数据。

Entry是由threadLocal和value组成,其中threadLocal对象是弱引用,在GC的时候,会被自动回收。而value就是ThreadLocal类set的数据。

下面用一张图总结一下引用关系:

img

上图中除了Entry的key对ThreadLocal对象是弱引用,其他的引用都是强引用。

需要特别说明的是,上图中ThreadLocal对象我画到了堆上,其实在实际的业务场景中不一定在堆上。因为如果ThreadLocal被定义成了static的,ThreadLocal的对象是类共用的,可能出现在方法区。

为什么用ThreadLocal做key?

不知道你有没有思考过这样一个问题:ThreadLocalMap为什么要用ThreadLocal做key,而不是用Thread做key?

一个线程中很有可能不只使用了一个ThreadLocal对象。这时使用Thread做key不就出有问题?

如你的service实现类中

@Service
public class ThreadLocalService {
private static final ThreadLocal<Integer> threadLocal1 = new ThreadLocal<>();
private static final ThreadLocal<Integer> threadLocal2 = new ThreadLocal<>();
private static final ThreadLocal<Integer> threadLocal3 = new ThreadLocal<>();
}

假如使用Thread做key时,你的代码中定义了3个ThreadLocal对象,那么,通过Thread对象,它怎么知道要获取哪个ThreadLocal对象呢?

img

因此,不能使用Thread做key,而应该改成用ThreadLocal对象做key,这样才能通过具体ThreadLocal对象的get方法,轻松获取到你想要的ThreadLocal对象。

img

Entry的key为什么设计成弱引用?

前面说过,Entry的key,传入的是ThreadLocal对象,使用了WeakReference对象,即被设计成了弱引用。

那么,为什么要这样设计呢?

假如key对ThreadLocal对象的弱引用,改为强引用。

img

我们都知道ThreadLocal变量对ThreadLocal对象是有强引用存在的。

即使ThreadLocal变量生命周期完了,设置成null了,但由于key对ThreadLocal还是强引用。此时,如果执行该代码的线程使用了线程池,一直长期存在,不会被销毁。

就会存在这样的强引用链:Thread变量 -> Thread对象 -> ThreadLocalMap -> Entry -> key -> ThreadLocal对象。

那么,ThreadLocal对象和ThreadLocalMap都将不会被GC回收,于是产生了内存泄露问题。

为了解决这个问题,JDK的开发者们把Entry的key设计成了弱引用。

弱引用的对象,在GC做垃圾清理的时候,就会被自动回收了。

如果key是弱引用,当ThreadLocal变量指向null之后,在GC做垃圾清理的时候,key会被自动回收,其值也被设置成null。

如下图所示:

img

接下来,最关键的地方来了。

由于当前的ThreadLocal变量已经被指向null了,但如果直接调用它的get、set或remove方法,很显然会出现空指针异常。因为它的生命已经结束了,再调用它的方法也没啥意义。

此时,如果系统中还定义了另外一个ThreadLocal变量b,调用了它的get、set或remove,三个方法中的任何一个方法,都会自动触发清理机制,将key为null的value值清空。

如果key和value都是null,那么Entry对象会被GC回收。如果所有的Entry对象都被回收了,ThreadLocalMap也会被回收了。

这样就能最大程度的解决内存泄露问题。

需要特别注意的地方是:

  • key为null的条件是,ThreadLocal变量指向null,并且key是弱引用。如果ThreadLocal变量没有断开对ThreadLocal的强引用,即ThreadLocal变量没有指向null,GC就贸然的把弱引用的key回收了,不就会影响正常用户的使用?
  • 如果当前ThreadLocal变量指向null了,并且key也为null了,但如果没有其他ThreadLocal变量触发get、set或remove方法,也会造成内存泄露。
  • 为了直接回收资源可以使用完ThreadLocal对象之后调用ThreadLocal对象的remove方法

弱引用就能解决内存泄漏?

通过上面的Entry对象中的key设置成弱引用,就能彻底解决内存泄露问题?

答案是否定的。

如下图所示:

image-20230331000858798

假如ThreadLocalMap中存在很多key为null的Entry,但后面的程序,一直都没有调用过有效的ThreadLocal的get、set或remove方法。

那么,Entry的value值一直都没被清空。

所以会存在这样一条强引用链:Thread变量 -> Thread对象 -> ThreadLocalMap -> Entry -> value -> Object。

其结果就是:Entry和ThreadLocalMap将会长期存在下去,会导致内存泄露。

如何解决内存泄露问题?

前面说过的ThreadLocal还是会导致内存泄露的问题,我们有没有解决办法呢?

答:有办法,调用ThreadLocal对象的remove方法。 remove方法中会把对应Entry中的key和value都设置成null

不是在一开始就调用remove方法,而是在使用完ThreadLocal对象之后。列如:

先创建一个,其中包含了ThreadLocal的逻辑。

先创建一个CurrentUser类,其中包含了ThreadLocal的逻辑。

public class CurrentUser {
    private static final ThreadLocal<UserInfo> THREA_LOCAL = new ThreadLocal();

    public static void set(UserInfo userInfo) {
        THREA_LOCAL.set(userInfo);
    }

    public static UserInfo get() {
       THREA_LOCAL.get();
    }

    public static void remove() {
       THREA_LOCAL.remove();
    }
}

然后在业务代码中调用相关方法:

public void doSamething(UserDto userDto) {
   UserInfo userInfo = convert(userDto);

   try{
     CurrentUser.set(userInfo);
     ...

     //业务代码
     UserInfo userInfo = CurrentUser.get();
     ...
   } finally {
      CurrentUser.remove();
   }
}

需要我们特别注意的地方是:一定要在finally代码块中,调用remove方法清理没用的数据。如果业务代码出现异常,也能及时清理没用的数据。remove方法中会把Entry中的key和value都设置成null,这样就能被GC及时回收,无需触发额外的清理机制,所以它能解决内存泄露问题。

所以

无论 ThreadLocalMap 中的 key 使用哪种类型引用都无法完全避免内存泄漏,跟使用弱引用没有关系。

要避免内存泄漏有两种方式:
1 .使用完 ThreadLocal ,调用其 remove 方法删除对应的 Entry
2 .使用完 ThreadLocal ,当前 Thread 也随之运行结束

相对第一种方式,第二种方式显然更不好控制,特别是使用线程池的时候,线程结束是不会销毁的

细心的同学会发现,在以上两种内存泄漏的情况中.都有两个前提:
1 .没有手动侧除这个 Entry
2 · CurrentThread 依然运行
第一点很好理解,只要在使用完下 ThreadLocal ,调用其 remove 方法翻除对应的 Entry ,就能避免内存泄漏。
第二点稍微复杂一点,由于ThreodLocalMap 是 Threod 的一个属性,被当前线程所引甲丁所以它的生命周期跟 Thread 一样长。那么在使用完 ThreadLocal 的使用,如果当前Thread 也随之执行结束, ThreadLocalMap 自然也会被 gc 回收,从根源上避免了内存泄漏。

综上, ThreadLocal 内存泄漏的根源是:

由于ThreadLocalMap 的生命周期跟 Thread 一样长,如果没有手动删除对应 key 就会导致内存泄漏