JVM笔记

300 阅读29分钟

JVM

JVM同时也是个规范,不同厂家按照这个规范,实现了自己的虚拟机。最常见的是HotSpot虚拟机。

不同厂家的虚拟机

运行时数据区域

运行时数据区域即 JVM 执行 Java 程序的过程中会把它管理的内存分为若干个不同的数据区域。

完整的JVM流程

程序计数器(Program Count Register)

程序计数器又称程序寄存器,可以看做当前线程所执行字节码的行号指示器。程序计数器记录正在执行的虚拟机字节码指令的地址(行号)。当前 JVM 虚拟机指令执行到哪行,就把下一行要执行指令的地址(行号)放置到程序计数器中。(如果正在执行的是本地方法则为空,因为本地方法是 C/C++ 编写的,是非 Java 程序控制的)。

之所以要记录下一行指令的地址,是因为 JVM 多线程是通过轮流切换线程并分配处理器执行时间来实现的,所以为了线程切换后能恢复到正确位置,每条线程的程序计数器都是独立的。

特点

  • 程序计数器是线程私有的,是线程安全的。
  • 不会存在内存溢出

Java虚拟机栈(Java Virtual Mechine Stacks)

Java 虚拟机栈描述的是 Java 方法执行的内存模型。
Java 方法执行的内存模型:每个线程运行时都需要一个内存空间,栈会分配一定空间给这个线程,这段空间由多个栈帧构成,每个栈帧对应一个 Java 方法。(即栈帧就是每个java方法运行时需要的内存)Java 方法从调用直至执行完成的过程,对应着一个栈帧在 Java 虚拟机栈中入栈和出栈的过程。

栈帧存储的内容:

  • 局部变量表:存放的是方法参数和方法内部定义的局部变量。它们可能是基本数据类型和对象引用(引用指针)。
  • 常量池引用
  • 操作数栈:控制入栈和出栈
  • 动态链接:
  • 方法出口: 方法返回地址(方法返回值)

ps:static 定义的静态变量不放在栈帧中,静态是线程共享,不安全的

特点

  • 每个线程运行时所需要的内存称为虚拟机栈,每个线程自己内存是私有的,是线程安全的。
  • 每个栈由多个栈帧(Frame)组成,对应着每次方法调用时所占用的内存
  • 每个线程只能有一个活动栈帧,对应着正在执行的那个方法
  • 栈帧入栈分配内存,出栈自动回收内存,不需要垃圾回收管理栈内存
  • 栈内存不是越大越好,内存越大可以进行更多次的递归调用,但是线程可用数越少。(例如总共物理内存500M,1个线程使用内存1M,理论上可以有500个线程同时运行。但是如果每个线程设置使用内存2M,理论上只有250个线程同时运行。一般采用系统默认的栈内存即可。)

异常

1.StackOverflowError 异常

当线程请求的栈深度超过最大值,会抛出 StackOverflowError 异常。
例1: 递归调用,没有设置终止条件,导致栈溢出。

例2: Emp 类引用了 Dept 类,Dept 类也引用了 Emp 类 类之间循环引用,导致栈溢出,可以通过添加@JsonIgnore,打破类循环引用。

2.OutOfMemoryError异常

栈进行动态扩展时如果无法申请到足够内存,会抛出 OutOfMemoryError 异常。

指定栈内存大小

可以通过 -Xss 这个虚拟机参数来指定每个线程的 java 虚拟机栈内存大小,在 JDK1.4 默认为256k,而在 JDK1.5+ 默认1M。

java -Xss2M

方法内的局部变量是否线程安全思考

public class Demo {
    public static void main(String[] args) {
        //线程1
        StringBuilder sb = new StringBuilder();
        //新开线程2
        new Thread(()->{
            m2(sb); //输出456123,而不是123
        }).start();

        //线程1修改sb对象
        sb.append(4);
        sb.append(5);
        sb.append(6);
    }

    public static void m1(){
        //m1方法 StringBuilder 是线程内的局部变量,作用范围只在方法内,是线程私有的,所以不会有线程安全问题。
        StringBuilder sb = new StringBuilder();
        sb.append(1);
        sb.append(2);
        sb.append(3);
        System.out.println(sb.toString());
    }
    public static void m2(StringBuilder sb){
        //m2方法 StringBuilder不是方法内的局部变量,而是方法的参数,有可能其他线程能访问到它,它不再是线程私有的,而是多个线程共享的,有线程安全问题
        sb.append(1);
        sb.append(2);
        sb.append(3);
        System.out.println(sb.toString());
    }
    public static StringBuilder m3(){
        //m3方法 返回了StringBuilder对象,其他线程也能拿到这个StringBuilder对象,进行修改,所以也不是线程安全的
        StringBuilder sb = new StringBuilder();
        sb.append(1);
        sb.append(2);
        sb.append(3);
        return sb;
    }
}

总结:

  • 如果方法内局部变量没有逃离方法的作用范围,它是线程安全的
  • 如果是局部变量引用了对象,并逃离方法的作用范围,需要考虑线程安全问题

实际线程诊断案例

案例1:CPU占用过高
  1. 执行一段java代码 nohup java代码后台运行

  2. 查看内存占用情况找对应进程和线程 top 检测内存使用情况,查出有问题进程编号 PID 为 32655。top只显示进程,还需要找到对应的线程。
    ps H -eo pid,tid,%cpu | grep 32655 ps进一步定位哪个线程引起的CPU过高 H打印进程数 -eo规定输出哪些内容 grep过滤

  3. 根据进程找对应java线程信息 jstack 32655列出进程编号 32655 的所有java线程。
    jstack的线程编号是16进制的,而ps的线程编号是10进制的
    10进制 32655 对应 16进制 7f99
    java虚拟机名称时固定的,Thread_xx是用户执行java的线程

  4. 找到对应代码位置

案例2:程序运行很长时间没有结果
  1. 执行一段java代码 多个线程发生了死锁,导致程序运行很长时间没有结果

  2. 根据进程找对应java线程信息 jstack 32752 Thread_0 和 Thread_1 发生了死锁

  3. 找到对应代码位置 线程0锁住了对象A,然后休眠2s——>1s后——>线程1锁住了对象B,然后尝试锁住对象A,但是由于线程0已经锁住对象A,线程1只能等待对象A释放——>到了2s,线程0醒来,尝试获取对象B,但是对象B已经被线程1锁住了,只能等待对象B释放——>线程0和线程1死锁

本地方法栈(Native Method Stacks)

本地方法栈与 java 虚拟机栈类似,它们之间的区别只不过是本地方法栈:JVM调用本地方法时提供的内存空间。
本地方法一般是用其他语言(C、C++或汇编语言等)编写的,并且被编译为基于本机硬件和操作系统的程序,对待这些方法需要特别处理。

ps:native修饰的就是本地方法,没有方法体,具体由其他语言来实现

异常

与 Java 虚拟机栈一样,本地方法栈也会抛出 StackOverflowError 和 OutOfMemoryError 异常。

堆(Heap)

通过new关键字,创建对象都会使用堆内存。所有对象都在这里分配内存,是所有线程共享的,同时也是垃圾回收的主要区域(“GC堆”)。 现代的垃圾收集器基本都是采用分代收集算法,其主要的思想是针对不同类型的对象采取不同的垃圾回收算法。可以将堆分成两块:

  • 新生代(Young Generation)
  • 老年代(Old Generation)

特点

  • 它是线程共享的,堆中对象都要考虑线程安全问题
  • 有垃圾回收机制

异常

  • 当堆内存溢出时,会抛出 OutOfMemoryError:Java heap space异常 例子: 字符串对象放到集合里,相当于有人使用,则不会被垃圾回收。随着字符串越来越多,所以把堆空间占满了。

设置堆空间最大值

可以通过 -Xms 和 -Xmx 这两个虚拟机参数来指定一个程序的堆内存大小,第一个参数设置初始值,第二个参数设置最大值。

java -Xms1M -Xmx2M

堆内存诊断

  1. jps工具查看当前系统中有哪些java进程

  2. jmap工具查看某一时刻的堆内存占用情况 jmap -heap -18756

  3. jconsole是图形界面的,多功能的监测工具,可以连续监测使用情况 jconsole是JDK自带的工具,直接命令行输入jconsole即可打开。

案例

垃圾回收后,内存占用仍然很高
使用 jvisualVM JDK自带的工具,比jsonsole更强大。 dump获取堆内存快照 查找使用内存较大的对象

方法区(Method Area)

方法区用于存放已被加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。

对这块区域进行垃圾回收的主要目标是对常量池的回收和对类的卸载,但是一般比较难实现。
HotSpot 虚拟机把它当成永久代来进行垃圾回收。但很难确定永久代的大小,因为它受到很多因素影响,并且每次 Full GC 之后永久代的大小都会改变,所以经常会抛出 OutOfMemoryError 异常。为了更容易管理方法区,从 JDK1.8 开始,移除永久代,并把方法区移至元空间,它位于本地内存中,而不是虚拟机内存中。
方法区是一个JVM规范,永久代与元空间都是其一种实现方式。在 JDK1.8之后,原来永久代的数据被分到了堆和元空间中。元空间存储类的元信息,静态变量和常量池等放入堆中。

异常

  • 当方法区内存溢出时,会抛出 OutOfMemoryError 异常

1.6版本和1.8版本的方法区JVM实现的区别

方法区内存溢出

1.8以前会导致永久代内存溢出

1.8之后会导致元空间内存溢出

元空间使用的是系统内存,比较充裕,所以那么容易溢出了。

运行时常量池(Runtime Constant Pool)

常量池:它就是一张表,虚拟机指令更具这张常量表找到要执行的类名、方法名、参数类型、字面量等信息。 运行时常量池:常量池是字节码(.class) 文件中的,当该类被加载,它的常量池信息就会放入运行时常量池,并把里面的符号地址(符合引用)变为真实地址。运行时常量池是方法区的一部分。

反编译

javac Helloword.java 将java 源文件编译成字节码文件(.class)。
javap -v HelloWorld.class 将 HelloWorld.class 反编译,让我们读得懂字节码。

StringTable

StringTable 俗称串池或字符串池

特点

  • 常量池中的字符串仅是符号,第一次用到时才变为对象
  • 利用串池的机制,来避免重复创建字符串对象
  • 字符串变量拼接的原理是StringBuilder(JDK1.8)
  • 字符串常量拼接的原理是编译期优化
  • 可以使用intern方法,主动将串池中还没有的字符串对象放入串池 JDK1.8将这个字符串对象尝试放入串池,如果有则并不会放入;如果没有则放入串池,会把串池中的对象返回。
    JDK1.6将这个字符串对象尝试放入串池,如果有则并不会放入;如果没有会把此对象复制一份,放入串池,会把串池中的对象返回。

字符串加载延迟特性

案例

除非特别说明,下面案例都是以基于JDK1.8及以上版本

例1:

例2:

例子3:

例子4(JDK1.8版本情况):

对比 JDK1.8将这个字符串对象尝试放入串池,如果有则并不会放入;如果没有则放入串池,会把串池中的对象返回。

例子5(JDK1.6版本情况):

JDK1.6将这个字符串对象尝试放入串池,如果有则并不会放入;如果没有会把此对象复制一份,放入串池,会把串池中的对象返回。

例子6

StringTable调优

StringTable本质是个HashTable

  • 调整 -XX:StringTableSize= 桶个数
  • 考虑将字符串对象是否入池。(例如应用中有大量字符串,且字符串有重复,可以让字符串入池,来减少字符串的个数,节约堆内存的使用。)

直接内存(Direct Memory)

直接内存不属于JVM管理,而是属于操作系统内存。

在 JDK1.4 中新引入了 NIO 类,它可以使用 Native 函数库直接分配堆外内存,然后通过 java 堆里的 DirectByteBuffer 对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在堆内存和堆外内存来回拷贝数据。

特点

  • 常见于 NIO 操作时,用于数据缓冲区
  • 分配回收成本较高,但读写性能高
  • 不受 JVM 内存回收管理

直接内存读写性能高原因*

未使用直接内存 使用直接内存

直接内存溢出

直接内存释放原理 使用了 Unsafe 对象完成直接内存的分配回收,并且回收需要主动调用 freeMemory 方法。 ByteBuffer 的实现类内部,使用了 Cleaner(虚引用)来监测 ByteBuffer 对象,一旦 ByteBuffer 对象被垃圾回收,那么就会由 ReferenceHandler 线程通过 Cleaner 的 clean 方法调用 freeMemory 来释放直接内存

垃圾回收机制

传统C/C++语言,程序员需要手动负责回收已经分配内存。 显式回收垃圾回收的缺点:

  • 程序忘记及时回收,从而导致内存泄露,降低系统性能。
  • 程序错误回收程序核心类库的内存,导致系统崩溃。 Java语言不需要程序员直接控制内存回收,是由JRE在后台自动回收不再使用的内存,称为垃圾回收机制,简称GC。
  • 可以提高编程效率。
  • 保护程序的完整性。
  • 其开销影响性能。Java虚拟机必须跟踪程序中有用的对象,确定哪些是无用的。

特点

  • 垃圾回收机制回收JVM堆内存里的对象空间,不负责回收栈内存数据。
  • 对其他物理连接,比如数据库连接、输入流输出流、Socket连接无能为力。
  • 垃圾回收发生具有不可预知性,程序无法精确控制垃圾回收机制执行。
  • 可以将对象的引用变量设置为null,暗示垃圾回收机制可以回收该对象。

如何判断对象可以回收

引用计数法

循环引用问题导致无法垃圾回收。所以 JVM 没有采用引用计数法。

可达性分析算法

JVM 中的垃圾回收器采用可达性分析来探索所有存活的对象。扫描堆中的对象,看是否能够沿着 GC Root对象 为起点的引用链找到该对象,找不到,表示可以回收。

例如在盘子里的葡萄,把根部提起来,与根部断开的葡萄,就是可以回收的葡萄(对象)。留在跟上的就是非可回收的葡萄(对象)

哪些对象可作为 GC Root

四种引用

强引用

只有所有 GC Roots对象都不通过“强引用”引用该对象,该对象才能被垃圾回收。 可以通过对象设置为null,来达到不再强引用该对象,被垃圾回收 -Xmx 20M 设置最大堆内存为20M

软引用(SoftReference)

仅有软引用引用该对象时i,在垃圾回收后,内存仍不足时会再次出发垃圾回收,回收软引用对象。
可以配合引用队列来释放软引用自身。 -XX:+PrintGCDetails -verbose:gc 详细显示垃圾回收参数

配合引用队列使用,将数组中为null的软引用对象去除 已经被垃圾回收的软引用为null,但null仍然占元素位置,我们打算把null去除

弱引用(WeakReference)

仅有弱引用引用该对象时,在垃圾回收时,无论内存是否充足,都会回收弱引用对象。
可以配合引用队列来释放弱引用自身。

虚引用(PhantomRefernce)

必须配合引用队列使用,主要配合 ByteBuffer 使用,被引用对象回收时,会将虚引用入队,由 Reference Handler 线程调用虚引用相关方法释放内存。

终结器引用(FinalReference)

无需手动编码,但其内部配合引用队列使用,在垃圾回收时,终结器引用入队(被引用对象暂时没有被回收),再由 Finalizer 线程通过终结器引用找到被引用对象并调用它的 finalize 方法,第二次 GC 时才能被引用对象。

垃圾回收算法

根据不同情况,会采用下面三种算法。

标记清除(Mark Sweep)

  • 速度较快
  • 会造成内存碎片

标记整理(Mark Compact)

  • 速度慢
  • 没有内存碎片

复制(Copy)

  • 不会有内存碎片
  • 需要占用双倍内存空间

分代垃圾回收

步骤

  1. 对象首先分配在伊甸园区域
  2. 新生代空间不足时,触发 minor gc,伊甸园和 From 存活的对象使用 copy 复制到 to 中,存活的对象年龄加1,并且交换 from 和 To。
  3. minor gc 会引发 stop the world,暂停其他用户线程,等垃圾回收结束,用户线程才恢复运行。(因为 From 和 To 涉及复制和交换操作,若不暂停线程,多线程会混乱)
  4. 当对象年龄超过阈值时,会晋升至老年代,最大年龄阈值15(4bit,不同厂商JVM有不同)
  5. 当老年代空间不足,会先尝试触发 minor gc ,如果之后空间仍不足,那么触发 full gc ,stop the world(STW) 的时间更长。
  6. 空间还是不足则报 OutOfMemoryError: Java heap space。(OOM)

案例

相关JVM 参数 例子1: 初始情况 初始和最大堆空间设置20M
新生代空间设置10M 初始 java 需要占用一定空间,所以eden初始被占用了28%,即8M X 28% = 2.24M,还剩5.76M空间

例子2: 放入7168k (7M)的对象,eden内存不足,则触发第一次minor gc。
新生代由原来的 1984kb 减小到 667kb。
eden 放入7168k 约等于占90%。
from 放入幸存的 667kb 约等于占65%。

例子3: 继续放入 7168k (7M) + 512k + 512k ,新生代内存空间不足,则把对象晋升到老年代。

例子4: 放入 8192k (8M)大对象,直接晋升到老年代。新生代不会进行 minor gc。

例子5: 当一个线程抛出OutOfMemoryError(OOM)异常后,它所占据的内存资源会全部被释放掉。
所以main主线程还是正常运行。

垃圾回收器

垃圾回收器可以分为下面三类。

串行垃圾

串行垃圾回收器是单线程的。 安全点是用户线程开始阻塞的位置,线程阻塞后,串行垃圾回收器单线程的执行,串行垃圾回收器完成后,用户线程才继续执行。

适用场景:堆内存较小,适合个人电脑

吞吐量优先

吞吐量优先垃圾回收器是多线程的。 适用场景:堆内存较大,多核CPU;单位时间内吞吐量大,让 STW 的总耗时最短。

响应时间优先

响应时间优先垃圾回收器是多线程的。 适用场景:堆内存较大,多核CPU;stop the world(STW)会暂停线程,尽可能让单次 STW 的处理时间最短。

G1(Garbage First)

G1 也有称 Garbage One

垃圾回收调优(GC 调优)

现在的JVM有多种垃圾回收实现算法,表现各异。垃圾回收机制回收任何对象之前,总会先调用它的finalize方法(如果覆盖该方法,让一个新的引用变量重新引用该对象,则会重新激活对象)。
程序员可以通过System.gc()或者Runtime.getRuntime().gc()来通知系统进行垃圾回收,会有一些效果,但是系统是否进行垃圾回收依然不确定。
永远不要主动调用某个对象的finalize方法,应该交给垃圾回收机制调用。

说明内存泄漏和内存溢出的区别和联系,结合项目经验描述Java程序中如何检测?如何解决?

内存泄漏memory leak

是指程序在申请内存后,无法释放已申请的内存空间,一次内存泄漏似乎不会有大的影响,但内存泄漏堆积后的后果就是内存溢出。

内存溢出 out of memory

指程序申请内存时,没有足够的内存供申请者使用,或者说,给了你一块存储int类型数据的存储空间,但是你却存储long类型的数据,那么结果就是内存不够用,此时就会报错OOM,即所谓的内存溢出。

二者的关系

内存泄漏的堆积最终会导致内存溢出内存溢出就是你要的内存空间超过了系统实际分配给你的空间,此时系统相当于没法满足你的需求,就会报内存溢出的错误。内存泄漏是指你向系统申请分配内存进行使用(new),可是使用完了以后却不归还(delete),结果你申请到的那块内存你自己也不能再访问(也许你把它的地址给弄丢了),而系统也不能再次将它分配给需要的程序。就相当于你租了个带钥匙的柜子,你存完东西之后把柜子锁上之后,把钥匙丢了或者没有将钥匙还回去,那么结果就是这个柜子将无法供给任何人使用,也无法被垃圾回收器回收,因为找不到他的任何信息。内存溢出:一个盘子用尽各种方法只能装4个果子,你装了5个,结果掉倒地上不能吃了。这就是溢出。比方说栈,栈满时再做进栈必定产生空间溢出,叫上溢,栈空时再做退栈也产生空间溢出,称为下溢。就是分配的内存不足以放下数据项序列,称为内存溢出。说白了就是我承受不了那么多,那我就报错。

内存泄漏的分类(按发生方式来分类)

常发性内存泄漏。发生内存泄漏的代码会被多次执行到,每次被执行的时候都会导致一块内存泄漏。偶发性内存泄漏。发生内存泄漏的代码只有在某些特定环境或操作过程下才会发生。常发性和偶发性是相对的。对于特定的环境,偶发性的也许就变成了常发性的。所以测试环境和测试方法对检测内存泄漏至关重要。一次性内存泄漏。发生内存泄漏的代码只会被执行一次,或者由于算法上的缺陷,导致总会有一块仅且一块内存发生泄漏。比如,在类的构造函数中分配内存,在析构函数中却没有释放该内存,所以内存泄漏只会发生一次。隐式内存泄漏。程序在运行过程中不停的分配内存,但是直到结束的时候才释放内存。严格的说这里并没有发生内存泄漏,因为最终程序释放了所有申请的内存。但是对于一个服务器程序,需要运行几天,几周甚至几个月,不及时释放内存也可能导致最终耗尽系统的所有内存。所以,我们称这类内存泄漏为隐式内存泄漏。

内存溢出的原因及解决方法

内存溢出原因

内存中加载的数据量过于庞大,如一次从数据库取出过多数据;集合类中有对对象的引用,使用完后未清空,使得JVM不能回收;代码中存在死循环或循环产生过多重复的对象实体;使用的第三方软件中的BUG;启动参数内存值设定的过小* *

内存溢出的解决方案
  • 第一步,修改JVM启动参数,直接增加内存。(-Xms,-Xmx参数一定不要忘记加。)
  • 第二步,检查错误日志,查看“OutOfMemory”错误前是否有其它异常或错误。
  • 第三步,对代码进行走查和分析,找出可能发生内存溢出的位置。
    重点排查以下几点:
    1. 检查对数据库查询中,是否有一次获得全部数据的查询。一般来说,如果一次取十万条记录到内存,就可能引起内存溢出。这个问题比较隐蔽,在上线前,数据库中数据较少,不容易出问题,上线后,数据库中数据多了,一次查询就有可能引起内存溢出。因此对于数据库查询尽量采用分页的方式查询。
    2. 检查代码中是否有死循环或递归调用。
    3. 检查是否有大循环重复产生新对象实体。
    4. 检查对数据库查询中,是否有一次获得全部数据的查询。一般来说,如果一次取十万条记录到内存,就可能引起内存溢出。这个问题比较隐蔽,在上线前,数据库中数据较少,不容易出问题,上线后,数据库中数据多了,一次查询就有可能引起内存溢出。因此对于数据库查询尽量采用分页的方式查询。
    5. 检查List、MAP等集合对象是否有使用完后,未清除的问题。List、MAP等集合对象会始终存有对对象的引用,使得这些对象不能被GC回收。
  • 第四步,使用内存查看工具动态查看内存使用情况。

类加载机制

类文件结构

根据JVM规范,类文件结构如下

类生命周期

类是在运行期间第一次使用时动态加载的,而不是一次性加载所有类。因为如果一次性加载,那么会占用很多的内存。

类加载过程

包含了加载、验证、准备、解析和初始化这 5 个阶段。

1.加载

通过类的完全限定名称获取定义该类的二进制字节流。
将该字节流表示的静态存储结构转换为方法区的运行时存储结构。 在内存中生成一个代表该类的 Class 对象,作为方法区中该类各种数据的访问入口。

2.验证

确保 Class 文件的字节流信息符合虚拟机的规范

3.准备

类变量是被 static 修饰的变量,准备阶段为类变量分配内存并设置初始值,使用的是方法区的内存。
实例变量不会在这阶段分配内存,它会在对象实例化时随着对象一起被分配在堆中。应该注意到,实例化不是类加载的一个过程,类加载发生在所有实例化操作之前,并且类加载只进行一次,实例化可以进行多次。

初始值一般为 0 值,例如下面的类变量 value 被初始化为 0 而不是 123。

public static int value = 123;

如果类变量是常量,那么它将初始化为表达式所定义的值而不是 0。例如下面的常量 value 被初始化为 123 而不是 0。

public static final int value = 123;

4.解析

将常量池的符号引用替换为直接引用的过程。
其中解析过程在某些情况下可以在初始化阶段之后再开始,这是为了支持 Java 的动态绑定。

5.初始化

初始化阶段才真正开始执行类中定义的 Java 程序代码。初始化阶段是虚拟机执行类构造器 <clinit>() 方法的过程。在准备阶段,类变量已经赋过一次系统要求的初始值,而在初始化阶段,根据程序员的代码来初始化类变量和其他资源。

<clinit>() 是由编译器自动收集类中所有类变量的赋值动作和静态语句块中的语句合并产生的,编译器收集的顺序由语句在源文件中出现的顺序决定。

特别注意的是,静态语句块只能访问到定义在它之前的类变量,定义在它之后的类变量只能赋值,不能访问。 例如以下代码:

public class Test {
     static { 
            i = 0; // 给变量赋值可以正常编译通过 				System.out.print(i); // 这句编译器会提示“非法向前引用” 
     }
     static int i = 1; 
}

由于父类的 <clinit>() 方法先执行,也就意味着父类中定义的静态语句块的执行要优先于子类。 例如以下代码:

static class Parent { 
	public static int A = 1; 
    static { 
    	A = 2; 
    } 
 }
static class Sub extends Parent {
	public static int B = A;
}
public static void main(String[] args) { 
	System.out.println(Sub.B); // 2 
}

如果多个线程同时初始化一个类,只会有一个线程执行这个类的 <clinit>() 方法,其它线程都会阻塞等待,直到活动线程执行<clinit>() 方法完毕。 如果在一个类的 <clinit>() 方法中有耗时的操作,就可能造成多个线程阻塞,在实际过程中此种阻塞很隐蔽。

类初始化时机

主动引用

下列五种情况必须对类进行初始化:

  1. 使用 new 关键字实例化对象的时候;读取或设置一个类的静态字段(被 final 修饰、已在编译期把结果放入常量池的静态字段除外)的时候;以及调用一个类的静态方法的时候。(以上情况会生成new、getstatic、putstatic、invokestatic字节码指令,若类没有进行初始化,则会初始化)

  2. 使用 java.lang.reflect 包的方法对类进行反射调用的时候,如果类没有进行初始化,则需要先触发其初始化。

  3. 当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。

  4. 当虚拟机启动时,用户需要指定一个要执行的主类(包含 main() 方法的那个类),虚拟机会先初始化这个主类

  5. 当使用 JDK 1.7 的动态语言支持时,如果一个java.lang.invoke.MethodHandle 实例最后的解析结果为 REF_getStatic, REF_putStatic, REF_invokeStatic 的方法句柄,并且这个方法句柄所对应的类没有进行过初始化,则需要先触发其初始化;

被动引用

以上 5 种场景中的行为称为对一个类进行主动引用。除此之外,所有引用类的方式都不会触发初始化,称为被动引用。

  1. 通过子类引用父类的静态字段,不会导致子类初始化。
System.out.println(SubClass.value); // value 字段在 SuperClass 中定义
  1. 通过数组定义来引用类,不会触发此类的初始化。该过程会对数组类进行初始化,数组类是一个由虚拟机自动生成的、直接继承自 Object 的子类,其中包含了数组的属性和方法。
SuperClass[] sca = new SuperClass[10];
  1. 常量在编译阶段会存入调用类的常量池中,本质上并没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化。
System.out.println(ConstClass.HELLOWORLD);

类与类加载器

两个类相等,需要类本身相等,并且使用同一个类加载器进行加载。这是因为每一个类加载器都拥有一个独立的类名称空间。

这里的相等,包括类的 Class 对象的 equals() 方法、isAssignableFrom() 方法、isInstance() 方法的返 回结果为 true,也包括使用 instanceof 关键字做对象所属关系判定结果为 true。

类加载器分类

从 Java 虚拟机的角度来讲,只存在以下两种不同的类加载器:

  • 启动类加载器(Bootstrap ClassLoader),使用 C++ 实现,是虚拟机自身的一部分;
  • 所有其它类的加载器,使用 Java 实现,独立于虚拟机,继承自抽象类 java.lang.ClassLoader。 从 Java 开发人员的角度看,类加载器可以划分得更细致一些:
  • 启动类加载器(Bootstrap ClassLoader)此类加载器负责将存放在 <JRE_HOME>\lib 目录中的,或者被 -Xbootclasspath 参数所指定的路径中的类库加载到虚拟机内存中。启动类加 载器无法被 Java 程序直接引用。
  • 扩展类加载器(Extension ClassLoader)它负责将系统变量所指定路径中的所有类库加载到内存中,开发者可以直接使用扩展类加载器。
  • 应用程序类加载器(Application ClassLoader)又称为系统类加载器。它负责加载用户类路径(ClassPath)上所指定的类库,开发者可以直接使用这个类加载器,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。

双亲委派模型

应用程序是由三种类加载器互相配合从而实现类加载,除此之外还可以加入自己定义的类加载器。 参考java中级程序员必会的教程,解密JVM【黑马程序员出品】