深入理解Java虚拟机(一)

本文介绍了Java虚拟机的发展历程,包括Sun公司的Classic/ExactVM和HotSpotVM,以及BEA的JRockit和IBM的J9VM。文章详细阐述了Java内存的运行时数据区域,如程序计数器、Java虚拟机栈、本地方法栈、Java堆、方法区和直接内存,以及各区域可能出现的内存溢出异常。同时,讨论了对象的创建、内存布局和访问定位,并给出了引发内存溢出的示例代码。
摘要由CSDN通过智能技术生成

一、走进Java 

Java虚拟机发展史

Sun公司的Classic / Exact VM

  • 1996年,sun公司发布JDK1.0时,所使用的虚拟机就是Classic VM。这款虚拟机只能使用纯解释器方式来执行Java代码,如果要使用JIT编译器,就必须进行外挂。但如果外挂,JIT编译器就完全接管了虚拟机的执行系统,解释器就不再工作了。
  • 由于解释器和编译器不能配合工作,如果要使用编译器执行,编译器就不得不对每一个方法、每一行代码都进行编译,无论是否有编译的价值。为了迎合程序响应时间,这些编译器放弃使用会编译耗时较高的优化技术,导致Java执行效率和传统的C/C++程序有很大的差距,所以“Java语言很慢”的形象被树立了起来
  • 为了解决这个问题,JDK1.2时发布了Exact VM虚拟机,具备了现在高性能虚拟机的雏形:两级即时编译、编译器与解释器混合工作等。这种虚拟机使用了准确式内存管理:可以判断内存中的32位的整数123456,是一个引用类型指向123456的内存地址,还是一个数值为123456的整数,这样GC的时候就可以判断堆上的数据是否还可能被使用
  • Classic VM在JDK1.2之前是JDK中唯一的虚拟机,在JDK1.2时,与HotSpot VM并存,但默认使用的是Classic VM,而JDK1.3时,HotSpot VM为默认的虚拟机,JDK1.4时,Classic VM才完全退出商用虚拟机的历史舞台

Sun公司的HotSpot VM

  • 并不是由Sun公司开发,由一家名为“Longview Technologies”的小公司开发,后被Sun收购,是Sun JDK和OpenJDK所使用的虚拟机,也是目前使用范围最广的Java虚拟机
  • 具有热点代码探测技术:可以通过执行计数器找出最具有编译价值的代码,然后通知JIT编译器以方法为单位进行编译。通过编译器和解释器恰当的协同,可以在最优程序响应和最佳执行性能中取得平衡。

BEA公司的JRockit

       JRockit虚拟机的垃圾收集器和MissionControl服务套件的实现,在众多Java虚拟机中处于领先水平,后被Oracle收购,Oracle公司宣布将完成HotSpot和JRockit虚拟机的整合工作,使之优势互补

IBM公司的J9 VM

       是IBM公司非唯一但是主力开发虚拟机,开发目的是作为IBM公司各种Java产品的执行平台

上述提到的是比较著名的虚拟机实现,还有很多种虚拟机曾经涌现、湮灭,就不一一赘述了

自己编译JDK

想要一探JDK内部的实现机制,最便捷的路径就是自己编译一套JDK,通过阅读和跟踪调试JDK源码去了解Java技术体系的原理

二、Java内存区域和内存溢出异常

        对于从事C、C++的开发人员来说,在内存管理领域,既拥有每一个对象的“所有权”,又担负着每个对象生命开始到终结的维护责任,而对于Java程序员来说,在虚拟机自动内存管理机制帮助下,不再需要为每一个对象写对应的delete/free代码,不容易出现内存泄漏和内存溢出问题,由虚拟机管理内存,也正是因为如此,一旦出现内存泄漏和溢出问题,不了解虚拟机是如何使用内存的,那么排查错误将变得十分困难

运行时数据区域

        在执行Java程序时会把所管理的内存划分为不同的数据区域,这些区域有着不同的用途。

1、程序计数器

       可以当作是当前线程所执行的字节码的行号指示器。字节码解释器工作时就是改变此计数器来确定执行的字节码指令是什么,比如分支、循环、跳转、异常处理、线程恢复等功能都要依赖此计数器完成。而且Java的多线程时通过多个线程轮流切换来实现的,所以为了线程切换后每个程序能恢复到正确的执行位置,每条线程都有一个独立的程序计数器,各个线程互不影响,独立存储,称之为“线程私有”的内存

       如果线程执行的是Java方法,计数器记录的是正在执行的虚拟机字节码指令的地址;如果执行的是Native方法,则计数器值为空(Undefined)。此内存区域是唯一一个在Java虚拟机没有规定任何内存溢出情况的区域。

2、Java虚拟机栈

        Java虚拟机栈也是线程私有的,其描述的是Java方法执行的内存模型:每个方法在执行的同时都会创建一个栈帧用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每个方法从调用到执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。

        经常会有人将Java内存区分为堆内存和栈内存,这种划分颗粒度很大,其实的Java内存区域远比这复杂,只能说明与对象内存分配关系最密切的内存区域就是这两块。“堆”暂时不表,“栈”就是指现在的虚拟机栈,或者说是虚拟机栈中局部变量表的部分。

        局部变量表存放了编译器可知的八种基本数据类型和对象引用。其中64位长度的long和double类型的数据会占用2个局部变量空间,其余数据类型只占用1个。局部变量表所需的内存空间在编译期完成分配,当进入一个方法时,这个方法需要在帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小。

        在Java虚拟机规范中,对这个区域规定了两种异常状况:如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常;如果虚拟机栈可以动态扩展,当扩展时无法申请到足够的内存,就会抛出OutOfMemoryError异常。

3、本地方法栈

其作用和虚拟机栈所发挥的作用相似,之间的区别不过是虚拟机栈为虚拟机执行Java方法(字节码)而服务,本地方法栈则为虚拟机使用到的Native方法服务。

4、Java堆

对大多数应用来说,Java堆是Java虚拟机所管理的内存中最大的一块,是被所有线程共享的一块内存区域,在虚拟机启动时创建。Java堆还可以细分为:新生代和老年代。再细致一点有Eden空间、From Survivor空间、To Survivor空间等。唯一目的是用于存储对象实例,几乎所有的对象实例都在这里分配内存。但随着JIT编译器和优化技术的发展,所有的对象都分配在堆上也渐渐变得不是那么“绝对”了。

Java堆也是垃圾收集器管理的主要区域,因此也被称为“GC堆”。

  • 从内存回收角度来看,现在收集器基本都采用分代收集算法,所以Java堆中还可以细分为:新生代和老生代
  • 从内存分配角度来看,线程共享的Java堆可能划分出多个线程私有的分配缓冲区

无论如何划分,目的是为了更好地回收内存,或者更快地分配内存。如果在堆中没有内存完成实例分配,并且堆也无法再扩展时,将会抛出OutOfMemoryError异常

5、方法区

与Java堆一样,是各个线程共享的内存区域,用于存储已被虚拟机加载的类信息、常亮、静态变量、即时编译器编译后的代码等数据。在Java虚拟机规范中,将方法区描述为堆的一部分,实际上应该是与Java堆分开。方法区不等于“永久代”,因为只有HotSpot虚拟机设计团队才使用永久代来实现方法区,可以让GC像管理Java堆一样管理方法区,省去专门为方法区编写代码,其他虚拟机(BEA的JRockit、IBM的J9 VM)都没有这个说法。根据Java虚拟机规范的规定,当方法区无法满足内存分配需求时,将抛出OutOfMemoryError异常

6、运行时常量池

是方法区的一部分。Class文件中除了类的版本、字段、方法、接口等描述信息,还有一项信息是常量池,这部分将在类加载后进入方法区的运行时常量池中存放。具有动态性的特点,运行期间也可能有新的常亮放入池中,因为运行时常量池是方法区的一部分,所以也收到方法区内存的限制,如果无法申请到内存会抛出OutOfMemoryError异常

7、直接内存

并不是虚拟机运行时数据区的一部分,也不是Java虚拟机规范中定义的内存区域。在JDK1.4之后,加入了NIO,它可以使用Native函数库直接分配堆外内存,然后通过存储在Java堆中的DirectByteBuffer对象作为这块内存的引用进行操作。直接内存的分配不会受到Java堆大小的限制,但是还是会受到本地总内存的限制,服务器管理员在配置虚拟机参数时,会根据实际内存设计-Xmx等参数信息,但经常容易忽略直接内存,使各个内存区域总和大于物理内存,导致动态扩展时抛出OutOfMemoryError异常。

HotSpot虚拟机在Java堆中对象分配、布局和访问

1、对象的创建

       虚拟机遇到一条new指令,首先检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,那必须先执行相应的类加载过程。在类加载检查通过后,接下来虚拟机将为新对象分配内存。对象所需的内存大小在类加载完成后可确定。

内存的分配方式

  • 指针碰撞

假设Java堆中内存是规整的,已使用和未使用的内存分放两侧,中间有一个指针代表其分界线,当内存分配时,就相当于将指针向未分配那侧移动了一小部分,其移动大小和具体分配内存的大小相同。

  • 空闲列表

假设Java堆中内存不是规整的,已使用和未使用的内存相互交错,这种情况下就无法使用“指针碰撞”了,虚拟机就必须维护一个列表,记录哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给新对象,并更新列表记录。

选择哪种分配方式是看Java堆中的内存是否规整决定的,而Java堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定的。因此,在使用Serial、ParNew等带Compact过程的收集器时,系统采用的分配算法是指针碰撞,而使用CMS这种基于Mark-Sweep算法的收集时,通常采用空闲列表。

在并发情况下创建对象并不是线程安全的,可能正在给对象A分配内存,指针还没来得及修改,对象B又同时使用原来的指针分配内存。解决方案:1、对分配内存空间进行CAS处理,保证更新操作的原子性;2、把内存分配按照线程划分不同空间进行,比如每个线程在Java堆中预先分配一小块内存,称为本地线程分配缓冲(TLAB)。内存分配完后,虚拟机将分配的内存空间赋零值,然后对对象进行必要的设置(属于哪个类的实例、如何才能找到类的元数据信息、对象哈希码、对象GC分代年龄)。做完以上工作,从虚拟机视角来看,一个新对象已经产生。但从Java程序视角来看,对象创建才刚刚开始,init方法还没有执行,一般来说,执行new关键字后,init方法执行,将对象按照程序员意愿进行初始化,这样对象才算真正创建。

2、对象的内存布局

在HotSpot虚拟机中,内存布局分为对象头、实例数据和对齐填充。

2.1、对象头

包含两部分数据:

  • 存储对象自身的运行时数据,如哈希码、GC分代年龄、锁状态标志、线程持有锁、偏向线程ID、偏向时间戳等
  • 类型指针,虚拟机可以通过指针确定这个对象是哪个类的实例。

注:如果对象是一个Java数组,那在对象头还存在一块用户记录数组长度的区域。

2.2、实例数据

用于存储对象的有效信息,存储程序代码中所定义的各种类型的字段,不论是从父类继承还是子类定义的都记录,这部分的存储顺序会受到虚拟机分配策略参数和字段在Java源码中定义顺序的影响。

HotSpot虚拟机默认的分配策略为longs/doubles、ints、shorts/chars、bytes/booleans、oops,由此可知,相同宽度的字段会被分配到一起,在此前提下,父类定义的变量会出现在子类之前。

2.3、对齐填充

非必然存在,也没有特定的含义,仅仅起着占位符的作用。当对象实例数据部分没有对齐时,就需要通过对齐填充来补全。

3、对象的访问定位

在Java中,一般是通过栈上的引用数据来操作堆上的具体对象。但是由于引用类型在Java虚拟机规范中只规定了一个指向对象的引用,没有定义这个引用应该如何定位、访问堆中的对象的具体位置,所以对象的访问方式也是取决于虚拟机实现。目前主流的有两种方式:句柄直接指针

句柄

        在Java堆中划分出一块内存作为句柄池,引用中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息

直接指针

        需要在Java堆对象的布局中如何放置访问类型数据的相关信息,而引用中存储的就是对象地址。

两种访问方式的优势

  • 使用句柄来访问的最大好处就是引用中存储的稳定的句柄地址,在对象被移动时(垃圾收集时移动对象是非常普遍的行为)只会改变句柄中的实例数据指针,而引用本身不需要修改
  • 使用直接指针访问方式最大的好处就是速度更快,节省了一次指针定位的时间开销。HotSpot虚拟机就是使用直接指针方式进行对象访问的,而从整个软件开发来看,也有很多语言和框架使用的是句柄访问

4、实战OutOfMemoryError异常

在Java虚拟机规范的描述中,除了程序计数器外,内存中的其他几个运行时区域都可能发生OutOfMemoryError异常,接下来是几个异常发生的场景实例及最基本的虚拟机参数

4.1、Java堆溢出

Java堆用于存储对象实例,只要不断的创建对象,并且保证GCRoots回收清除这些对象,那么在对象到达最大堆容量时就会抛出内存溢出异常。

/**
 * 虚拟机参数:
 * -Xms20m 限制堆内存最小为20M
 * -Xmx20m 限制堆内存最大为20M(当最大值和最小值设置一样时,可避免堆自动扩展内存)
 * -XX:+HeapDumpOnOutOfMemoryError 当出现异常时,抛出当前内存堆转储快照,以便分析
 *
 * @description 堆内存
 * @date 2019/9/18 16:05
 */
public class HeapOOM {
    static class OOMObject{
    }
    public static void main(String[] args) {
        List<OOMObject> list = new ArrayList<>();
        while (true){
            list.add(new OOMObject());
        }
    }
}

后续会详细说明,目前只是初步了解一下

安装Jprofiler工具

  • 下载客户端,关联idea
  • 安装Jprofiler的idea插件,并且关联上Jprofiler客户端
  • 重启idea,会看到启动按钮旁,多出了一个蓝色的小按钮,点击运行,就会直接启动Jprofiler客户端并且启动项目

4.2、虚拟机栈和本地方法栈溢出

HotSpot虚拟机并不区分虚拟机栈和本地方法栈,因此对于HotSpot来说,虽然-Xoss参数(用于设置本地方法栈大小)存在,但实际是无效的,栈容量只由-Xss参数设定。

在Java虚拟机规范中描述了两种异常

  • 如果线程请求的栈深度大于虚拟机所允许的最大深度,将抛出StackOverflowError异常
  • 如果虚拟机在扩展栈时无法申请到足够的内存空间,将抛出OutOfMemoryError异常

看似是两种情况,其本质还是对同一件事的两种描述而已

/**
 * 虚拟机参数:-Xss160k
 * @author wcj
 * @date 2019/9/19 15:50
 */
public class JavaVMStackSOF {
    private int stackLength = 1;
    /**
     * 使用递归,将本地变量不断增加,造成栈溢出
     */
    public void stackLeak() {
        stackLength++;
        stackLeak();
    }
    public static void main(String[] args) {
        JavaVMStackSOF javaVMStackSOF = new JavaVMStackSOF();
        javaVMStackSOF.stackLeak();
    }
}
//会抛出StackOverflowError异常
Exception in thread "main" java.lang.StackOverflowError
	at jvm.JavaVMStackSOF.stackLeak(JavaVMStackSOF.java:17)

注意:栈大小无法设置为128K,最小也得要160K,否则就会抛出上图异常信息

通过不断地建立线程的方式可以产生内存造成内存溢出异常,操作系统分给每个进程的内存时有限制的,比如2GB的内存,虚拟机可以通过参数控制Java堆和方法区这两部分的内存,再不考虑微乎其微的程序计数器消耗的内存外,计算方式如下:2GB(操作系统限制)-Xmx(最大堆容量)-MaxPermSize(最大方法区容量)=虚拟机栈和本地方法栈内存。每个线程分配的栈容量越大,可以建立的线程数量自然就少,且越容易将剩下的内存耗尽。

4.3、方法区和运行时常量池溢出

因为运行时常量池是方法区的一部分,所以将两个区域的案例放在一起进行

String,intern()是一个Native方法,其作用是,如果字符串常量池中已经包含了一个等于此String对象的字符串,则返回代表池中这个字符串的String对象;否则,将此String对象包含的字符串添加到常量池中,并且返回此String对象的引用

public static void main(String[] args) {
    String str1 = new StringBuilder("计算机").append("软件").toString();
    System.out.println(str1.intern() == str1);

    String str2 = new StringBuilder("ja").append("va").toString();
    System.out.println(str2.intern() == str2);
}
输出的结果是
true,false

由上面说过的String.intern()特性可知,字符串是否在字符串常量池中已经有它的引用了,如果有了,相比自然就是false了,那么java在什么时候存放进字符串常亮池中的呢,我们翻找System类的源码,找到initialzeSystemClass(),发现在Version类中进行了init(),进行Version类,就发现有一些字符串常量被声明了,其中就有java,所以java在源码中就已经进行了声明创建,并不是由程序员去进行创建的,源码如下

方法区用于存放Class的相关信息,如类名、访问修饰符、常量池、字段描述、方法描述等。所以将方法区溢出的思路就是运行时产生大量的类去填满,直到方法区溢出。可以通过动态代理来完成操作

4.4、本机直接内存溢出

直接内存容量可以通过-XX:MaxDirectMemorySize指定,如果不指定,就默认与Java堆最大值一样(-Xmx指令)。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

芦蒿炒香干

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值