匿名内部类的内存泄漏—JVM

23 篇文章 0 订阅

一、问题背景

近期接手优化公司古董级项目时,由于部分功能向 FWK 中批量注册了 Callback 回调,而 FWK 中对应的代码并没有进行加锁,偶发 Callback 中的耗时逻辑引起 java.lang.IllegalStateException 异常,所以通过静态 Handler内部类 + WeakReference弱引用的方式,来处理 Callback 中的耗时逻辑,避免可能出现的内存泄漏。

在上述问题被修改完成后,发现外部类是使用单例模式实现的,而且所有的常量都是用 static final 修饰的。此时不禁反思,如果外部类是单例的,其中的常量和内部类,有没有必要使用 static 去做修饰?参照掌握的 JVM内存模型 知识,类比其它的情景,有了下面的疑问:

  1. 普通类、静态类、单例类、内部类,在 JVM 的哪个区域存储?什么时候?
  2. 成员变量、局部变量、静态变量、成员常量、静态常量,在 JVM 的哪个区域存储?什么时候?
  3. 构造方法、实例方法、静态方法、同步方法,在 JVM 的哪个区域存储?什么时候?
  4. 构造代码块、静态代码块局部代码块、同步代码块,在 JVM 的哪个区域存储?什么时候?执行顺序?

 

二、基础知识

1. JVM内存结构

  • 程序计数器

线程私有,主要代表当前线程所执行的字节码行号指示器。如果线程正在执行一个 Java方法,计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是 Native方法,计数器的值则为 Undefined。这是唯一一个没有 OutOfMemory 的区域。

  • Java虚拟机栈

线程私有,与线程同时创建,总数与线程关联,代表 Java方法 执行的内存模型。每个方法执行时都会创建一个栈桢来存储局部变量表、操作数栈、动态链接、方法返回地址和一些额外的附加信息。每个方法从调用至结束就对于一个栈桢在虚拟机栈中的入栈和出栈过程。

局部变量表:存放编译期的各种 基本类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference类型) 和 returnAddress类型(指向了一条字节码指令的地址)。

  • 本地方法栈

线程私有,主要与虚拟机用到的 Native方法 相关。一般情况下,不需要关心此区域。

线程共享,主要是存放对象实例和数组。是 JVM 所管理的内存中最大的一块,几乎所有的对象实例和数组都在这类分配内存,是垃圾收集器管理的主要区域。

字符串常量池:在类加载完成,经过验证,准备阶段之后,在堆中生成字符串对象实例,然后将该字符串对象实例的引用值存到 string pool 中。在JDK1.6及之前版本,字符串常量池是放在 Perm Gen区(方法区);在JDK1.7及之后版本,字符串常量池被移到了堆中。

  • 方法区

线程共享,主要用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。

静态常量池:即 *.class文件 中的常量池,主要存放字面量和符号引用量,占用 class文件 绝大部分空间。字面量相当于Java语言层面常量的概念,如文本字符串,声明为final的常量值等,符号引用则属于编译原理方面的概念,包括了类和接口的全限定名、字段名称和描述符、方法名称和描述符。

运行时常量池:在完成类装载操作后,将 class文件 中的常量池载入到内存中,由此可知,每个类都有一个运行时常量池,主要用于存放编译器生成的各种字面量和符号引用。运行期间也可能将新的常量放入池中,这种特性被开发人员利用比较多的是 String类 的 intern() 方法。

 

2. 类的生命周期

类从被加载到内存中,到被卸载出内存,一共分为以下几步:

  1. 加载(Loading)

  2. 验证(Verification)

  3. 准备(Preparation)

  4. 解析(Resolution)

  5. 初始化(Initialization)

  6. 使用(Using)

  7. 卸载(Unloading)

 

3. 类加载过程

类加载的全过程,包括加载验证准备解析初始化几个阶段。

加载

  • 通过一个类的全限定名来获取其定义的二进制字节流
  • 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
  • 在 Java堆 中生成一个代表这个类的 java.lang.Class 对象,作为对方法区中这些数据的访问入口

连接

(1) 验证:确保 class文件 符合虚拟机规范要求,如元数据验证,文件格式验证,字节码验证和符号验证等。

  • 文件格式的验证:验证字节流是否符合 class文件格式 的规范,并且能被当前版本的虚拟机处理,该验证的主要目的是保证输入的字节流能正确地解析并存储于方法区之内。经过该阶段的验证后,字节流才会进入内存的方法区中进行存储,后面的三个验证都是基于方法区的存储结构进行的
  • 元数据验证:对类的元数据信息进行语义校验(其实就是对类中的各数据类型进行语法校验),保证不存在不符合 Java语法规范 的元数据信息
  • 字节码验证:该阶段验证的主要工作是进行数据流和控制流分析,对类的方法体进行校验分析,以保证被校验的类的方法在运行时不会做出危害虚拟机安全的行为
  • 符号引用验证:这是最后一个阶段的验证,它发生在虚拟机将符号引用转化为直接引用的时候(解析阶段中发生该转化,后面会有讲解),主要是对类自身以外的信息(常量池中的各种符号引用)进行匹配性的校验

(2) 准备:为类变量分配内存并设置类变量初始值,这些内存都将在方法区中分配。

  • 这时候进行内存分配的仅包括类变量(static),而不包括实例变量,实例变量会在对象实例化时随着对象一块分配在 Java堆
  • 这里所设置的初始值通常情况下是数据类型默认的零值(如 0、0L、null、false 等),而不是在代码中被显式地赋予的值
  • 对基本数据类型来说,对于类变量(static) 和全局变量,如果不显式地对其赋值而直接使用,则系统会为其赋予默认的零值,而对于局部变量来说,在使用前必须显式地为其赋值,否则编译时不通过
  • 对于同时被 static 和 final 修饰的常量,必须在声明的时候就为其显式地赋值,否则编译时不通过;而只被 final 修饰的常量,既可以在声明时显式地为其赋值,也可以在类初始化时显式地为其赋值。总之,在使用前必须为其显式地赋值,系统不会为其赋予默认零值
  • 对于引用数据类型 reference 来说,如数组引用、对象引用等,如果没有对其进行显式地赋值而直接使用,系统都会为其赋予 null
  • 如果在数组初始化时没有对数组中的各元素赋值,那么其中的元素将根据对应的数据类型而被赋予默认的零值

(3) 解析:虚拟机将常量池中的符号引用转化为直接引用的过程,会把所有的类名,方法名,字段名这些符号引用替换为具体的内存地址或偏移量,也就是直接引用。

  • 符号引用:即一个字符串,但是这个字符串给出了一些能够唯一性识别一个方法,一个变量,一个类的相关信息
  • 直接引用:可以理解为一个内存地址,或者一个偏移量。比如类方法,类变量的直接引用是指向方法区的指针;而实例方法,实例变量的直接引用则是从实例的头指针开始算起到这个实例变量位置的偏移量

初始化

对类变量初始化,是执行类构造器的过程,即只对 static 修饰的变量或语句进行初始化。如果初始化一个类的时候,其父类尚未初始化,则优先初始化其父类。如果同时包含多个静态变量和静态代码块,则按照自上而下的顺序依次执行。

  • 创建类的实例
  • 调用类的静态方法
  • 访问某个类或接口的静态变量,或者对该静态变量赋值(被final修饰、编译器优化时已经放入常量池的例外)
  • 反射,如 Class.forName("com.xxx.xxx")
  • 初始化某个类的子类,则其父类也会被初始化
  • JVM启动时,被标明为启动类的类(含 main方法,直接使用java命令运行)

 附加:clinit 与 init

在编译生成 class文件 时,编译器会产生两个方法加于 class文件 中,一个是类的初始化方法clinit, 另一个是实例的初始化方法init。

1、clinit

类构造器,在类加载过程中的初始化阶段执行,包括静态变量初始化和静态块的执行。

注意事项

(1) 如果类中没有静态变量或静态代码块,那么clinit方法将不会被生成。

(2) 在执行clinit方法时,必须先执行父类的clinit方法。

(3) clinit方法只执行一次。

(4) static变量的赋值操作和静态代码块的合并顺序由源文件中出现的顺序决定。

2、init

   实例构造器,在类实例化过程中执行,执行内容包括成员变量初始化和代码块的执行。

注意事项

(1) 如果类中没有成员变量和代码块,那么clinit方法将不会被生成。

(2) 在执行init方法时,必须先执行父类的init方法。

(3) init方法每实例化一次就会执行一次。

(4) init方法先为实例变量分配内存空间,再执行赋默认值,然后根据源码中的顺序执行赋初值或代码块。

 

三、问题解答

1. 普通类、静态类、单例类、内部类,在 JVM 的哪个区域存储?什么时候?

  1) 类数据结构 => 方法区;类对象 => 堆

  2) 都在加载阶段

 

2. 成员变量、局部变量、静态变量、成员常量、静态常量,在 JVM 的哪个区域存储?什么时候?

  1) 成员变量 => 基本类型在Java虚拟机栈,对象类型在堆;局部变量 => 基本类型在Java虚拟机栈,对象类型在堆;静态变量 => 方法区,JDK1.7移到堆;成员常量、静态常量 => 方法区静态常量池,然后运行时常量池

  2) 成员变量 => 创建在加载阶段,赋值在实例化过程;局部变量 => 创建在加载阶段,赋值在运行期间;静态变量 => 创建在加载阶段,赋值初始化阶段;成员常量、静态常量 => 编译期间

 

3. 构造方法、实例方法、静态方法、同步方法,在 JVM 的哪个区域存储?什么时候?

  1) 都在方法区

  2) 都在加载阶段

 

4. 构造代码块、静态代码块、局部代码块、同步代码块,在 JVM 的哪个区域存储?什么时候?执行顺序?

  1) 都在方法区

  2) 都在加载阶段

  3) 静态代码块 > 构造代码块 = 同步代码块 > 构造方法 > 局部代码块

 

参考:

深入理解 Java 虚拟机

Jvm内存模型详细介绍

Java程序编译和运行的过程

  • 3
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值