一、JVM 主要组成部分:
JVM的主要包含两个组件和两个子系统,分别为:
(1)本地库接口(Native Interface):与native lib(本地方法库)交互,融合其他编程语言为Java所用,是与其它编程语言交互的接口
(2)运行时数据区(Runtime data area():即常说的JVM内存
(3)类加载子系统(Class loader):根据全限定类名装载class文件到运行时数据区的方法区中
(4)执行引擎子系统(Execution engine):也叫解释器,负责解释class指令,再提交给操作系统执行
通过上面的图,我们可以大致知道Java代码是如何运行的:首先通过编译器将Java源代码转换成字节码,接着类加载系统把字节码加载到运行时数据区的方法区内,再使用执行引擎将字节码翻译成底层系统指令,最后交由 CPU 去执行,而这个过程中可能需要调用其他语言的本地库接口来实现整个程序的功能。
字节码只是 一套 JVM 指令集规范,并不能直接交给底层操作系统去执行,因此需要通过执行引擎将字节码翻译成底层系统指令
二、JVM 内存:
JVM 在执行 Java 程序时,会将内存划分为若干个不同的数据区域,不同的区域用途不同,创建和销毁时间也不相同。但在 JDK1.8 版本之后对运行时数据区域做了些修改,下面我们分别来看看修改前后的内存区域是怎么样的。
1、JDK1.8之前的JVM内存区域:
2、JDK8之后的JVM内存区域 :
3、各区域的的作用:
1.虚拟机栈(或叫线程栈)
官方叫法虚拟机栈,更好理解的叫法可以叫线程栈,为什么叫线程栈,因为每个线程只要开始运行,JAVA虚拟机就会给线程挖一块内存出来当做栈,用来存放线程的局部变量等,而栈中的操作是由一个个的栈帧组成,
以上面这个类为例,运行时首先main()方法作为第一个栈帧入栈,在栈帧中调用了computer()方法,那么就会将computer()这个方法进行入栈,后面执行到其他方法依次类推。而一个方法执行完后那么就出栈销毁。
而一个栈帧中还有局部变量表、操作数栈、动态链接、方法返回地址。
**局部变量表:**用于存储方法参数和方法内部定义的局部变量。
**操作数栈:**操作数栈主要用于执行方法时进行数据操作和计算。
**动态链接:**用于在运行时解析方法调用。
方法返回地址:,用于保存方法调用结束后的返回地址。当方法执行完成时,程序需要跳转回方法调用点继续执行。
当我们编译好一个java类后,会生成对应的.class文件,即字节码文件。
可以通过jvm -p命令对该文件进行反汇编
可以看到java代码int a=1这行代买对应的jvm指令,iconst_1 : 将int类型常量1压入操作数栈 ,istore_1:将int类型值存入局部变量
之后执行到int c=a+b这行代码
那么对应的指令码部分就是如图所示。
iload_1 :从局部变量1中装载int类型值。iload_2 :从局部变量2中装载int类型值。在此处即表示将变量a和变量b的值压入**操作数栈。**如下图所示
'
然后执行**iadd指令,**i代表int类型,add代表加法操作。那么就会从操作数栈中栈顶弹出两个对应的数,执行加法操作后,将数值写入操作数栈。
bipush 10就是将10压入操作数栈。imul和iadd同理
i++ 和 ++i 的区别:
- i++:从局部变量表取出 i 并压入操作栈(load memory),然后对局部变量表中的 i 自增 1(add&store memory),将操作栈栈顶值取出使用,如此线程从操作栈读到的是自增之前的值。
- ++i:先对局部变量表的 i 自增 1(load memory&add&store memory),然后取出并压入操作栈(load memory),再将操作栈栈顶值取出使用,线程从操作栈读到的是自增之后的值。
之前之所以说 i++ 不是原子操作,即使使用 volatile 修饰也不是线程安全,就是因为,可能 i 被从局部变量表(内存)取出,压入操作栈(寄存器),操作栈中自增,使用栈顶值更新局部变量表(寄存器更新写入内存),其中分为 3 步,volatile 保证可见性,保证每次从局部变量表读取的都是最新的值,但可能这 3 步可能被另一个线程的 3 步打断,产生数据互相覆盖问题,从而导致 i 的值比预期的小。
4.Java堆
用于存储对象实例,是占用内存最大的区域,可划分为新生代和老年代,新生代又可细分为 Eden区(伊甸园区)、From Survivor区(S0)、To Survivor区(S1)。
在 HotSpot 虚拟机中,对象在堆内存布局分成三部分:对象头,实例数据,对齐填充。
① 对象头:包括两部分的信息:
运行时数据:存储对象自身的运行数据,如哈希码,GC代年龄,锁状态、线程持有的锁、偏向线程ID等。
类型指针:即对象指向它的类型数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。如果对象是一个Java数组,那对象头中还必须有一块用于记录数组长度的数据。
② 实例数据:是对象真正存储的有效信息,是在程序代码中所定义的各种类型的字段内容,相同宽度的字段会被分配到一起。
③ 对齐填充:并不是必然存在的,仅起着占位符的作用。
当一个对象被new出来后,那么就放入了伊甸园区,当创建的对象越来越多,伊甸园区的内存不够时,那么就会触发垃圾回收 :GC( Garbage Collection ), 字节码执行引擎就会启动垃圾收集线程 ,执行Minor gc,当一个对象经过Minor gc后会被清除掉,或者是进入S0区和S1区,每经理一次GC对象头中的GC代年龄都会+1,超过15进入老年代,当老年代中内存不够时,就会触发Full GC,当年轻代和老年代中的内存都满了,且GC没有可回收的对象时,那么就会发生OOM(内存溢出)
5.元空间(方法区)
该区域被所有线程共享 ,用于存储已被虚拟机加载的类信息、常量、静态变量 、即时编译后的代码(即class文件)等数据,同时,元空间中有一个运行时常量池 ,用于存放静态编译产生的字面量和符号引用。该区域不需要连续的内存,并且可以动态扩展,动态扩展失败会抛出 OOM 异常,对该区域进行垃圾回收的主要目标是对常量池的回收和对类型的卸载,但是一般比较难实现。
如上图,常量testData,和User对象(也是存放的User对象的地址,指向堆中的User对象实例)
方法区是一个 JVM 规范,永久代与元空间都是其一种实现方式。JDK8 之前,Hotspot 中方法区的实现是永久代(Perm),JDK8 开始使用元空间(Metaspace**),** 以前永久代的静态变量和常量池 移至堆内存,其他内容移至元空间,元空间直接在本地内存分配。💡注:此处常量池指的是字符串常量池--常量池详解
那为什么要使用元空间取代永久代的实现?主要是为了方便管理方法区:
① 永久代与堆所使用的物理内存是连续的。对于永久代,由于类和方法等信息比较难确定大小,所以指定永久代的大小比较困难,太小容易出现永久代溢出,太大则容易导致老年代溢出,并且每次 Full GC 之后永久代的大小都会改变,如果动态生成很多 class 的话,就很可能出现 OOM,毕竟永久代的空间配置有限。
② JDK8之后,方法区存在于元空间,物理内存不再与堆内存连续,而是直接存在于本地内存中,理论上机器内存有多大,元空间就有多大。
③ 字符串存在永久代中,容易出现性能问题和内存溢出。
④ 永久代会为 GC 带来不必要的复杂度,回收效率偏低,因为方法区中类静态属性引用的对象、常量引用的对象都是 GC Roots 对象
直接内存
直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,也不是 Java 虚拟机规范中定义的内存区域。
在 JDK 1.4 中新加入了 NIO,引入了一种基于通道(Channel)与缓冲区(Buffer)的 I/O 方式,它可以使用 Native 函数库直接分配堆外内存,然后通过一个存储在 Java 堆中的 DirectByteBuffer 对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在 Java 堆和 Native 堆中来回复制数据。
显然,本机直接内存的分配不会受到 Java 堆大小的限制,但是,既然是内存,肯定还是会受到本机总内存(包括 RAM 以及 SWAP 区或者分页文件)大小以及处理器寻址空间的限制。服务器管理员在配置虚拟机参数时,会根据实际内存设置 -Xmx 等参数信息,但经常忽略直接内存,使得各个内存区域总和大于物理内存限制(包括物理的和操作系统级的限制),从而导致动态扩展时出现 OutOfMemoryError 异常。