Java代码在编译后会变成Java字节码,字节码被类加载器加载到JVM里,JVM执行字节码,最终将字节码转化为汇编指令在CPU上执行。
✅ Java内存区域
- 堆:Java虚拟机中最大的一块内存,是线程共享的内存区域,基本上所有的对象实例以及数组都是在堆上分配,堆区细分为Young区年轻代和Old区老年代,其中年轻代又分为Eden、S0、S1 3个部分,默认的比例大小为8:1:1.
- 虚拟机栈:虚拟机栈是描述Java方法执行的线程内存模型,每个方法执行的时候都会在栈创建一个栈帧,方法的调用过程对应着栈帧从栈中入栈到出栈的过程,每个栈帧的结构又包含局部变量表、操作数栈、动态连接、方法出口等信息。
局部变量表用于存储方法参数和局部变量
动态连接用于将符号引用表示的方法转换为实际方法的直接引用
- 本地方法栈:用于执行本地native方法的区域
- 程序计数器:也是线程私有的区域,⽤于记录当前线程下虚拟机下一条要执行的字节码的指令地址
- 方法区:是线程共享的内存区域,用于存储已被虚拟机加载的类型信息、常量、静态变量
- 常量池:就是一张表(如上图中的constant pool),虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量信息
- 运行时常量池:常量池是_.class文件中的,当该_类被加载以后**,它的常量池信息就会**放入运行时常量池**,并把里面的**符号地址变为真实地址
- 1.6之后的StringTable转移到堆中
- 直接内存:属于操作系统,常见于NIO操作时,用于数据缓冲区,不受JVM内存回收管理
✅ new一个对象的过程
当虚拟机遇见new关键字时候,判断当前类是否已经加载,如果类没有加载,首先执行类的加载机
制,加载完成后再为对象分配空间、初始化等。
- 首先校验当前类是否被加载,如果没有加载,执行类加载机制
- 加载:就是从字节码加载成⼆进制流的过程
- 验证:当然加载完成之后,当然需要校验Class文件是否符合虚拟机规范
- 准备:为静态变量、常量赋默认值
- 解析:把常量池中符号引用(以符号描述引用的目标)替换为直接引用的过程
- 初始化:执行static代码块(cinit)进行初始化,如果存在父类,先对父类进行初始化
当类加载完成之后,紧接着就是对象分配内存空间和初始化的过程
- 首先为对象分配合适大小的内存空间
- 接着为实例变量赋默认值
- 设置对象的头信息,对象hash码、GC分代年龄、元数据信息等
- 执行构造函数(init)初始化
✅ OOM问题及解决方案
✅ 说说有哪些垃圾回收算法
标记-清除:统⼀标记出需要回收的对象,标记完成之后统⼀回收所有被标记的对象,而由于标记的过程需要遍历所 有的GC ROOT,清除的过程也要遍历堆中所有的对象,所以标记-清除算法的效率低下,同时也带来了内存碎片的问题。
复制算法:它将内存分为大小相等的两块区域,每次使用其中的⼀块, 当⼀块内存使用完之后,将还存活的对象拷贝到另外⼀块内存区域中,然后把当前内存清空,这样性能 和内存碎片的问题得以解决。但是同时带来了另外⼀个问题,可使用的内存空间缩小了⼀半!
因此,诞生了我们现在的常用的年轻代+老年代内存结构:Eden+S0+S1组成,因为根据IBM的研究显示,98%的对象都是朝生夕死,所以实际上存活的对象并不是很多,完全不需要用到⼀半内存浪费,所以默认的比例是8:1:1。
这样,在使用的时候只使用Eden区和S0S1中的⼀个,每次都把存活的对象拷贝另外⼀个未使用的Survivor区,同时清空Eden和使用的Survivor,这样下来内存的浪费就只有10%了。
标记-整理:“标记 - 整理”算法的标记过程与“标记 - 清除”算法相同,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。
面试官 :那为什么新生代还要分Eden、From、To区域呢?
首先大部分对象的存活时间是很短的,如果不加Survivor区,那么新生代可能就会有两种垃圾回收方案。
第一种可能:每次回收都在新生代整块内存上进行,完整内存分为三步:
- 先找到需要清理的对象进行标记
- 清理这些被标记的对象
- 移动剩下的对象,对达到老年代晋升年龄的对象移动到老年代。
对象回收掉后会产生很多的内存碎片,需要移动没有被回收的对象(标记-整理算法),整个过程回收流程效率很低。
第二种可能:如果没有Survivor区(From + To),Minor GC(新生代回收)过程中,存活的对象直接被送到老年代,这样的话老年代很快被填满,触发Major GC(因为Major GC一般伴随着Minor GC,也可以看做触发了Full GC),Full GC频繁会影响程序的执行和响应速度。
面试官 :为什么要设置两个Survivor区呢?From 和 To。
我们来看一下, 如果只有一个Survivor区,新生代内存的回收流程。
我按照上面这张图画的讲,第一次Eden区域满了,内存回收很简单,直接把Eden区域存活对象放到Suvivor区域;
第二次内存回收,需要回收二个地方,Eden区域和Survivor区域。
- 因为Survivor区域也会存活的对象需要被回收,对Survivor区要采用标记整理垃圾收集算法,(先标记需要清理的对象,然后回收,然后把剩下的存活对象放到一起);
- Eden区域采用复制算法,把Eden区域存放的对象复制到Survivor区域,然后把整个Eden区清除。
看到网上有些文章说这里设置二个Survivor区域的原因是为了避免内存碎片,因为他假设第二次(以及后续)的回收,内存回收是先回收Eden区域,然后是Survivor区域,这样当然会有内存碎片(因为Survivor区域会对第一次从Eden区复制进来的存活对象进行回收),但是如果真是只有一个Survivor区域,垃圾回收设计者肯定是先回收Survivor区域,再回收Eden区域,等Survivor区回收整理好,再把Eden区存放对象搬到Survivor区,这样存活地址是连续的,没有内存碎片。所以真正的原因还是我下面说的效率问题。
这样做有几个问题:
- 经过几次回收之后,Survivor区域满了之后怎么办?直接搬到老年代?那老年代很快就爆炸了。搬到Eden区?那内存碎片产生了,可能Survivor区和Eden区回收完之后,还需要再整理一下内存去掉内存碎片,性能消耗也是很大的。
- 一般标记整理算法的性能消耗是比复制算法消耗要大的,尤其是在新生代98%的对象都是“朝生夕死”的,标记清楚的是98%的对象,剩下就2%对象,要整理内存,不然直接把这2%对象放到另一个地方,把整块内存清除,Eden整块内存清除效率很高的。
所以归根结底,二个Survivor区还是为了性能考虑,标记复制算法效率比标记整理效率高。(一个Survivor区域的话需要用到标记-整理算法,而两个Survivor就可以只用到复制算法,复制算法的效率要比标记-整理算法的效率高)
标记复制算法流程:
- Eden区域+Survivor From区满,进行存活对象标记,标记完,把存活对象复制到Survivor To区域;
- Survivor To区域变成From区域(一个逻辑标识),From区域变成To区域;
- 内存分配,继续步骤1,复制过程中有达到老年代晋升年龄(默认值15),移动到老年代。
✅ 垃圾回收器了解吗?年轻代和老年代都有哪些垃圾回收器?
1.垃圾收集器
并行收集:指多条垃圾收集线程并行工作,但此时用户线程仍处于等待状态。
并发收集:指用户线程与垃圾收集线程同时工作(不一定是并行的可能会交替执行)。用户程序在继续运行,而垃圾收集程序运行在另一个CPU上
吞吐量:即CPU用于运行用户代码的时间与CPU总消耗时间的比值(吞吐量 = 运行用户代码时间 / ( 运行用户代码时间 + 垃圾收集时间 )),也就是。例如:虚拟机共运行100分钟,垃圾收集器花掉1分钟,那么吞吐量就是99%
2.记忆集、并发的可达性分析
为了解决对象跨代引用所带来的问题,在新生代建立了记忆集的数据结构,避免把整个老年代加进GC Roots扫描范围。通过写屏障来维护卡表的状态。
三色标记——保障一个一致性的快照
白色——对象尚未被垃圾收集器访问过
黑色——对象被垃圾收集器访问过,且对象所有的引用都已经被扫描过
灰色——对象被垃圾收集器访问过,但至少还有一个引用没有被扫描过
当且仅当两个条件同时满足时,才会产生对象消失的问题,黑色对象被误标为白色
- 插入一条或多条从黑色对象到白色对象的新引用
- 删除全部从灰色到白色对象的引用
为了解决并发扫描时对象消失的问题,两种解决方案:增量更新和原始快照
增量更新破坏第一个条件:黑色对象一但新插入指向白色对象的引用,它就变成灰色对象
原始快照破坏第二个条件:无论关系删除与否,都会按照刚开始的对象图快照来进行搜索
CMS(Concurrent Mark Sweep):CMS收集器是以获取最短停顿时间为目标的收集器,相对于其他的收集器STW的时间更短暂,可以并行收集是他的特点,同时他基于标记-清除算法,整个GC的过程分为4步。
- 初始标记:标记GC ROOT能关联到的对象,需要STW
- 并发标记:从GCRoots的直接关联对象开始遍历整个对象图的过程,使用增量更新,不需要STW
- 重新标记:为了修正并发标记期间,因用户程序继续运作而导致标记产生改变的标记,需要STW
- 并发清除:清理删除掉标记阶段判断的已经死亡的对象,不需要STW
从整个过程来看,并发标记和并发清除的耗时最长,但是不需要停止用户线程,而初始标记和重新标记
的耗时较短,但是需要停止用户线程,总体而言,整个过程造成的停顿时间较短,大部分时候是可以和
用户线程⼀起工作的。
G1(Garbage First):G1收集器是JDK9的默认垃圾收集器,而且不再区分年轻代和老年代进行回收。
适用场景
- 同时注重吞吐量和低延迟(响应时间)
- 超大堆内存(内存大的),会将堆内存划分为多个大小相等的区域
- 整体上是标记-整理算法,两个区域之间是复制算法
把连续的Java堆划分为多个大小相等的独立区域(Region),每一个Region都可以是Eden空间、Survivor空间或老年代空间,Region作为单次回收的最小单元,会维护一个优先级列表,包含各个区域的回收价值,优先处理回收价值最大的Region。
G1使用记忆集避免全堆作为GC Roots扫描,记忆集是一种非收集区指向收集区域的指针集合,为了解决跨代引用。
G1使用原始快照(SATB)来保证并发标记阶段收集线程与用户线程互不干扰的运行,SATB就是在三色标记中,无论引用关系删除与否,都会按照刚开始扫描那一刻的对象图快照来进行搜索。
G1的Young GC和Mixed GC均采用标记-复制算法
标记阶段停顿分析
- 初始标记阶段:初始标记阶段是指从GC Roots出发标记全部直接子节点的过程,该阶段是STW的。由于GC Roots数量不多,通常该阶段耗时非常短。
- 并发标记阶段:并发标记阶段是指从GC Roots开始对堆中对象进行可达性分析,找出存活对象。该阶段是并发的,即应用线程和GC线程可以同时活动。并发标记耗时相对长很多,但因为不是STW,所以我们不太关心该阶段耗时的长短。
- 再标记阶段:重新标记那些在并发标记阶段发生变化的对象。该阶段是STW的。
筛选回收阶段
- 清理阶段清点出有存活对象的分区和没有存活对象的分区。转移阶段需要分配新内存和复制存活对象到空的Region中。该阶段是STW的。
3个STW过程中,初始标记因为只标记GC Roots,耗时较短。再标记因为对象数少,耗时也较短。清理阶段因为内存分区数量少,耗时也较短。转移阶段要处理所有存活的对象,耗时会较长。因此,G1停顿时间的瓶颈主要是标记-复制中的转移阶段STW。
✅ ZGC
- 停顿时间不超过10ms;
- 停顿时间不会随着堆的大小,或者活跃对象的大小而增加;
- 支持8MB~4TB级别的堆(未来支持16TB)。
ZGC在标记、转移和重定位阶段几乎都是并发的,这是ZGC实现停顿时间小于10ms目标的最关键原因。
ZGC只有三个STW阶段:初始标记,再标记,初始转移。其中,初始标记和初始转移分别都只需要扫描所有GC Roots,其处理时间和GC Roots的数量成正比,一般情况耗时非常短;再标记阶段STW时间很短,最多1ms,超过1ms则再次进入并发标记阶段。即,ZGC几乎所有暂停都只依赖于GC Roots集合大小,停顿时间不会随着堆的大小或者活跃对象的大小而增加。与ZGC对比,G1的转移阶段完全STW的,且停顿时间随存活对象的大小增加而增加。
ZGC通过着色指针和读屏障技术,解决了转移过程中准确访问对象的问题,实现了并发转移。大致原理描述如下:并发转移中“并发”意味着GC线程在转移对象的过程中,应用线程也在不停地访问对象。假设对象发生转移,但对象地址未及时更新,那么应用线程可能访问到旧地址,从而造成错误。而在ZGC中,应用线程访问对象将触发“读屏障”,如果发现对象被移动了,那么“读屏障”会把读出来的指针更新到对象的新地址上,这样应用线程始终访问的都是对象的新地址。JVM是如何判断对象被移动过呢?就是利用对象引用的地址,即着色指针。下面介绍着色指针和读屏障技术细节。
着色指针
着色指针是一种将信息存储在指针中的技术。
在64位系统中,如果没有被压缩的话,一个指向对象的指针(即地址值)是占64bit的,我们拿出4个bit,来记录一些信息.
ZGC仅支持64位系统,它把64位虚拟地址空间划分为多个子空间,如下图所示:
其中,[0~4TB) 对应Java堆,[4TB ~ 8TB) 称为M0地址空间,[8TB ~ 12TB) 称为M1地址空间,[12TB ~ 16TB) 预留未使用,[16TB ~ 20TB) 称为Remapped空间。
当应用程序创建对象时,首先在堆空间申请一个虚拟地址,但该虚拟地址并不会映射到真正的物理地址。ZGC同时会为该对象在M0、M1和Remapped地址空间分别申请一个虚拟地址,且这三个虚拟地址对应同一个物理地址,但这三个空间在同一时间有且只有一个空间有效。ZGC之所以设置三个虚拟地址空间,是因为它使用“空间换时间”思想,去降低GC停顿时间。“空间换时间”中的空间是虚拟空间,而不是真正的物理空间。
与上述地址空间划分相对应,ZGC实际仅使用64位地址空间的第0~41位,而第42~45位存储元数据,第47~63位固定为0。
ZGC将对象存活信息存储在42~45位中,这与传统的垃圾回收并将对象存活信息放在对象头中完全不同。
读屏障
读屏障是JVM向应用代码插入一小段代码的技术。当应用线程从堆中读取对象引用时,就会执行这段代码。需要注意的是,仅“从堆中读取对象引用”才会触发这段代码。
ZGC中读屏障的代码作用:在对象标记和转移过程中,用于确定对象的引用地址是否满足条件,并作出相应动作。
ZGC并发处理演示
接下来详细介绍ZGC一次垃圾回收周期中地址视图的切换过程:
- 初始化:ZGC初始化之后,整个内存空间的地址视图被设置为Remapped。程序正常运行,在内存中分配对象,满足一定条件后垃圾回收启动,此时进入标记阶段。
- 并发标记阶段:第一次进入标记阶段时视图为M0,如果对象被GC标记线程或者应用线程访问过,那么就将对象的地址视图从Remapped调整为M0。所以,在标记阶段结束之后,对象的地址要么是M0视图,要么是Remapped。如果对象的地址是M0视图,那么说明对象是活跃的;如果对象的地址是Remapped视图,说明对象是不活跃的。
- 并发转移阶段:标记结束后就进入转移阶段,此时地址视图再次被设置为Remapped。如果对象被GC转移线程或者应用线程访问过,那么就将对象的地址视图从M0调整为Remapped。
其实,在标记阶段存在两个地址视图M0和M1,上面的过程显示只用了一个地址视图。之所以设计成两个,是为了区别前一次标记和当前标记。也即,第二次进入并发标记阶段后,地址视图调整为M1,而非M0。
着色指针和读屏障技术不仅应用在并发转移阶段,还应用在并发标记阶段:将对象设置为已标记,传统的垃圾回收器需要进行一次内存访问,并将对象存活信息放在对象头中;而在ZGC中,只需要设置指针地址的第42~45位即可,并且因为是寄存器访问,所以速度比访问内存更快。
✅ 什么时候会触发YGC和FGC?对象什么时候会进入老年代?
当一个新的对象来申请内存空间的时候,如果Eden区⽆法满足内存分配需求,则触发YGC,使用中的 Survivor区和Eden区存活对象送到未使用的Survivor区,如果YGC之后还是没有足够空间,则直接进⼊老年代分配,如果⽼年代也无法分配空间,触发FGC,FGC之后还是放不下则报出OOM异常。
✅ JVM调优
- -Xms设置初始堆的大小,-Xmx设置最大堆的大小
✅ 类加载机制
类的加载指的是将类的.class文件中的二进制数据读入到内存中,将其放在运行时数据区的方法区内,然后在堆区创建一个 java.lang.Class对象,用来封装类在方法区内的数据结构。
JVM 虚拟机执行 class 字节码的过程大致可以分为六个阶段:加载、验证、准备、解析、初始化、卸载。
1.加载是”类加机制”的第一个过程,在加载阶段,虚拟机主要完成三件事:
(1)通过一个类的全限定名来获取其定义的二进制字节流
(2)将这个字节流所代表的的静态存储结构转化为方法区的运行时数据结构
(3)在堆中生成一个代表这个类的Class对象,作为方法区中这些数据的访问入口。
2.验证:当然加载完成之后,当然需要校验Class文件是否符合虚拟机规范
3.准备:为静态变量、常量赋默认值
4.解析:把常量池中符号引用(以符号描述引用的目标)替换为直接引用的过程
5.初始化:执行static代码块(cinit)进行初始化,如果存在父类,先对父类进行初始化
双亲委派的过程:某个特定的类加载器在接到加载类的请求时,首先将加载任务委托给父类加载器,依次递归,如果父类加载器可以完成类加载任务,就成功返回;只有父类加载器无法完成此加载任务时,才自己去加载。
为什么这么做?有什么好处?
双亲委派模型保证了Java程序的稳定运行,可以避免类的重复加载,Java中的类随着它的类加载器一起具备了一种带有优先级的层次关系
其次是java核心api中定义类型不会被随意替换,假设传递java.lang.Integer的类,通过双亲委托模式传递到启动类加载器,而启动类加载器在核心Java API发现这个名字的类,发现该类已被加载,并不会重新加载传递过来的java.lang.Integer,而直接返回已加载过的Integer.class,这样便可以防止核心API库被随意篡改。
Tomcat 的类加载器是怎么设计的
- 一个web容器可能需要部署两个应用程序,不同的应用程序可能会依赖同一个第三方类库的不同版本,不能要求同一个类库在同一个服务器只有一份,因此要保证每个应用程序的类库都是独立的,保证相互隔离。
2. 部署在同一个web容器中相同的类库相同的版本可以共享。
3. web容器也有自己依赖的类库,不能于应用程序的类库混淆。基于安全考虑,应该让容器的类库和程序的类库隔离开来
[
](https://blog.csdn.net/dangwanma6489/article/details/80244981)
第一个问题,如果使用默认的类加载器机制,那么是无法加载两个相同类库的不同版本的,默认的累加器是不管你是什么版本的,只在乎你的全限定类名,并且只有一份。
commonLoader:Tomcat最基本的类加载器,加载路径中的class可以被Tomcat容器本身以及各个Webapp访问;
catalinaLoader:Tomcat容器私有的类加载器,加载路径中的class对于Webapp不可见;
sharedLoader:各个Webapp共享的类加载器,加载路径中的class对于所有Webapp可见,但是对于Tomcat容器不可见;
WebappClassLoader:各个Webapp私有的类加载器,加载路径中的class只对当前Webapp可见;
[
](https://blog.csdn.net/dangwanma6489/article/details/80244981)
很显然,tomcat 不是这样实现,tomcat 为了实现隔离性,没有遵守这个约定,每个webappClassLoader加载自己的目录下的class文件,不会传递给父类加载器。
扩展一个问题:如果tomcat 的 Common ClassLoader 想加载 WebApp ClassLoader 中的类,该怎么办?
看了前面的关于破坏双亲委派模型的内容,我们心里有数了,我们可以使用线程上下文类加载器实现,使用线程上下文加载器,可以让父类加载器请求子类加载器去完成类加载的动作。
✅ Java垃圾收集
可达性分析->引用级别->二次标记(finalize方法)->垃圾收集 算法(4个)->回收策略(3个)->垃圾收集器(GMS、G1)
- 首先要判断对象是否死亡,常用的算法有引用计数法和可达性分析算法,但是前者无法解决对象之间循环引用的问题,所以一般使用可达性分析算法。选取一系列称为“GC Root”的根对象作为起始节点集,根据引用关系向下搜索,如果某个对象到GC Roots间没有任何引用链,该对象就会被判定为可回收的对象。
可以作为GC Roots的对象包括:
- 虚拟机栈引用的对象
- 类静态属性引用的对象
- 常量引用的对象
- 本地方法栈引用的对象
- 被同步锁持有的对象
- 1.强引用指普遍存在的引用赋值,即类似Object obj = new Object()这种引用关系,无论任何只 要强引用关系存在,GC永远不会回收被引用的对象。
2.当GC Root指向软引用对象时,在内存不足时,会回收软引用所引用的对象,软引用本身不会被清理
如果想要清理软引用,需要使用引用队列——ReferenceQueue
3.只有弱引用引用该对象时,在垃圾回收时,无论内存是否充足,都会回收弱引用所引用的对象
4.虚引用
- 虚引用的一个体现是释放直接内存所分配的内存,当引用的对象ByteBuffer被垃圾回收以后,虚引 用对象Cleaner就会被放入引用队列中,然后调用Cleaner的clean方法来释放直接内存
如果对象在进行可达性分析之后没有与GC Roots相连的引用链,将会被第一此标记,随后进行一次筛选,如果没有覆盖finalize()方法,或者该方法已经被调用,将会被等待GC回收,如果有覆盖则会加入F-Queue队列中,如果在该阶段与引用链上的任意一个对象建立关联,就可以不被GC回收。
分代收集算法:将Java堆分为新生代和老年代,新生代存活的对象达到一定的年龄之后会晋升到老年代中,同时用记忆集记录跨代引用避免对整个老年代扫描(跨代引用——老年代指向新生代)
标记—清楚算法:标记需要回收的对象,统一回收所有被标记的阶段,会存在内存碎片和回收效率的
问题。
复制算法:将存活的对象复制到另一块上面,把已使用的内存空间一次清理掉。
标记—整理:让所有存活的对象都向内存空间一端移动,清理掉边界以外的内存,移动对象会STW。
✅ 守护线程
所谓的守护线程,指的是程序运行时在后台提供的一种通用服务的线程。比如垃圾回收线程就是一个很称职的守护者,并且这种线程并不属于程序中不可或缺的部分。因此,当所有的非守护线程结束时,程序也就终止了,同时会杀死进程中的所有守护线程
✅ OOM
内存溢出:申请的内存超出了JVM能提供的内存大小,此时称之为溢出。
内存泄露:申请使用完的内存没有释放,导致虚拟机不能再次使用该内存,此时这段内存就泄露了,因为申请者不用了,而又不能被虚拟机分配给别人用。
1、静态集合类
如HashMap、LinkedList等等。如果这些容器为静态的,那么它们的生命周期与程序一致,则容器中的对象在程序结束之前将不能被释放,从而造成内存泄漏。简单而言,长生命周期的对象持有短生命周期对象的引用,尽管短生命周期的对象不再使用,但是因为长生命周期对象持有它的引用而导致不能被回收。(将某个对象的引用放到了全局的Map中,虽然方法结束了,但是由于垃圾回收器会根据对象的引用情况来回收内存,导致该对象不能被及时的回收)
2、未关闭的资源导致内存泄露
每当创建连接或者打开流时,JVM都会为这些资源分配内存。如果没有关闭连接,会导致持续占有内存。在任意情况下,资源留下的开放连接都会消耗内存,如果我们不处理,就会降低性能,甚至OOM。
解决办法:使用finally块关闭资源;关闭资源的代码,不应该有异常;jdk1.7后,可以使用try-with-resource块。
3、不正确的equals()和hashCode()
在HashMap和HashSet这种集合中,常常用到equal()和hashCode()来比较对象,如果重写不合理,将会成为潜在的内存泄露问题。
解决办法:用最佳的方式重写equals()和hashCode。
5、finalize()方法造成的内存泄露
重写finalize()方法时,该类的对象不会立即被垃圾收集器收集,如果finalize()方法的代码有问题,那么会潜在的引发OOM;
解决办法:避免重写finalize()。
7、使用ThreadLocal造成内存泄露
使用ThreadLocal时,每个线程只要处于存在状态就可保留对其ThreadLocal变量副本的隐式调用,且将保留其自己的副本。使用不当,就会引起内存泄露。
一旦线程不在存在,ThreadLocals就应该被垃圾收集,而现在线程的创建都是使用线程池,线程池有线程重用的功能,因此线程就不会被垃圾回收器回收。所以使用到ThreadLocals来保留线程池中线程的变量副本时,ThreadLocals没有显示的删除时,就会一直保留在内存中,不会被垃圾回收。
解决办法:不在使用ThreadLocal时,调用remove()方法,该方法删除了此变量的当前线程值。不要使用ThreadLocal.set(null),它只是查找与当前线程关联的Map并将键值对设置为当前线程为null。