什么是JVM?
- JVM是Java Virtual Machine(Java虚拟机)的缩写,JVM是一种用于计算设备的规范,它是一个虚构出来的计算机,是通过在实际的计算机上仿真模拟各种计算机功能来实现的。
JVM的位置
- JVM位于操作系统之上。
JVM的体系结构
执行引擎中包括解释器,及时编译器(JIT),GC收集器。
栈,本地方法栈,程序计数器中没有垃圾回收,因为栈中的方法一用就会弹出去,如果栈中有垃圾,main方法就不会结束,程序就会陷死。所以垃圾回收都在方法区和堆的区域,并且大部分时间在进行堆优化。
类加载器
负责加载class文件,将class文件字节码内容加载到内存中,并将这些内容转换成方法区中运行时数据结构并且ClassLoader只负责文件的加载。至于是否能执行,则由执行引擎(Execution engine)决定。
双亲委派机制
在了解双亲委派机制前,首先要了解类加载器总共有几种:
-
启动类加载器:这个类加载器负责放在\lib目录中的,或者被-Xbootclasspath参数所指定的路径中的,并且是虚拟机识别的类库。用户无法直接使用。
-
扩展类加载器:这个类加载器由sun.misc.Launcher$AppClassLoader实现。它负责\lib\ext目录中的,或者被java.ext.dirs系统变量所指定的路径中的所有类库。用户可以直接使用。
-
应用程序类加载器:这个类由sun.misc.Launcher$AppClassLoader实现。是ClassLoader中getSystemClassLoader()方法的返回值。它负责用户路径(ClassPath)所指定的类库。用户可以直接使用。如果用户没有自己定义类加载器,默认使用这个。
-
自定义加载器:用户自己定义的类加载器.
-
从下到上依次为自定义类加载器,应用程序类加载器,扩展类加载器,启动类加载器。
-
如果一个类加载器收到了类加载的强求,它首先不会自己尝试加载这个类,而是抛给它的父类加载器去完成,每一层都是如此,直到启动类加载器。因此所有的类加载请求都会传给顶层的启动类加载器,只有当父加载器反馈自己无法加载此类的时候,子加载器才会尝试加载,逐级下沉,直到最底层。如果这个时候还是无法加载,那么就会抛出一个ClassNotFoundException异常。
沙箱安全机制
- 组成沙箱的基本组件:
- 字节码校验器:确保java类文件遵循java语言规范,帮助java程序实现内存保护,但并不是所有的类都要经过字节码校验器,比如核心类。
- 类装载器:在3个方面对java沙箱起作用
- 防止恶意代码去干涉善意代码 //双亲委派机制
- 守护了被信任的类库边界
- 将代码归入保护域,确定了代码可以进行哪些操作
运行时数据区
PC寄存器(程序计数器)
PC寄存器是用来存储指向下一条指令的地址,也是即将要执行的指令代码。字节码解释器工作时通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支,循环,跳转,异常处理,线程恢复等功能都需要依赖这个计数器来完成。
程序计数器:Program Counter Register
- 每个线程都有它自己的程序计数器(为了线程切换后能恢复到正确的执行位置),是线程私有的,就是一个指针,指向方法区中的方法字节码,在执行引擎读取下一条指令,是一个非常小的内存空间,几乎可以忽略不计。
- 任何时间一个线程都只有一个方法在执行,也就是所谓的当前方法。程序计数器会存储当前线程正在执行的java方法的JVM指令地址;或者,如果是在执行native方法,则是未指定值(undefined),因为程序计数器不负责本地方法栈。
注意:程序计数器是唯一一个不会出现 OutOfMemoryError
的内存区域。它的生命周期随线程的创建而创建,随线程的结束而死亡。
方法区
方法区是被所有线程共享的,所有字段和方法字节码,以及一些特殊方法,如构造函数,接口代码也在此定义。也就是说,所有定义的方法的信息都保存在该域中。
静态变量,常量,类信息(构造方法,接口代码),运行时的常量池存在方法区中,但是实例变量存放在堆内存中。
永久代
永久区也称为持久带,主要存放类定义,字节码和常量等很少会发生改变的信息。不存在垃圾回收,当虚拟机关闭的时候就会释放这个区域的内存。
方法区与永久代的关系
《Java虚拟机规范》只是定义了有方法区这个个概念和它的作用,并没有规定如何去实现它。那么,在不同的JVM上方法区的实现肯定是不同的了。方法区和永久代的关系就很像java中接口和类的关系,类实现类接口,而永久代就是HotSpot虚拟机对虚拟机规范中方法区的一种实现。也就是说,永久代是HotSpot的概念,方法区是《java虚拟机规范》中的定义,是一种规范,而永久代是一种实现,一个是标准,一个是实现。其他的虚拟机并没有永久代这一说法。
常用参数
JDK1.8之前永久代还没被彻底移除的时候通常通过下面这些参数来调节方法区的大小:
-XX:PermSize=N //方法区(永久代)初始化大小
-XX:MaxPermSize=N //方法区(永久代)最大大小,超过这个值将会抛出OutOfMemoryError
JDK1.8的时候,方法区(HotSpot的永久代)就被彻底移除了,取而代之的是元空间,元空间使用的是直接内存
下面是一个常用参数:
-XX:MetaspaceSize=N //设置MetaSpace的初始(和最小大小)
-XX:MaxMetaspaceSize=N //设置MetaSpace的最大大小
为什么要将永久代(Perm Gen)替换为元空间(MetaSpace)呢?
- 整个永久代有一个JVM本身设置的固定大小上限,无法进行调整,而元空间使用的是直接内存,受本机可用内存的限制,虽然元空间仍可能溢出,但是相比原来出现的几率会更小。元空间的最大大小默认是unlimited,意味着它只受系统内存的限制,也可以自己进行设置元空间最大内存大小。
- 永久代和元空间本身都是用来存储类的信息的,也就是类的元信息,元空间的最大空间受限于系统内存,相较于永久代的最大空间会更大,所有加载的类也会更多
直接内存
直接内存并不是Java虚拟机运行时数据区的部分,也不是虚拟机规范中定义的内存区域,但是这部分内存也被频繁的使用,也可能导致OutOfMemoryError错误出现。
JDK1.4 中新加入的 NIO(New Input/Output) 类,引入了一种基于通道(Channel) 与 缓存区(Buffer) 的 I/O 方式,它可以直接使用 Native 函数库直接分配堆外内存,然后通过一个存储在 Java 堆中的 DirectByteBuffer 对象作为这块内存的引用进行操作。这样就能在一些场景中显著提高性能,因为避免了在 Java 堆和 Native 堆之间来回复制数据。(通过Java 堆中的 DirectByteBuffer 对象操作这块堆外内存,避免了在 Java 堆和 Native 堆之间来回复制数据)
本机直接内存的分配不会受到Java堆的限制,但是,既然是内存就会受到本机总内存大小以及处理器寻址空间的限制。
java 中变量存储位置
1.寄存器:最快的存储区, 由编译器根据需求进行分配,我们在程序中无法控制.
2. 栈:存放基本类型的变量数据和对象的引用,但对象本身不存放在栈中,而是存放在堆(new 出来的对象)或者常量池中(字符串常量对象存放在常量池中。)
3. 堆:存放所有new出来的对象。
4. 静态域:存放静态成员(static定义的)
5. 常量池:存放字符串常量和基本类型常量(public static final)。
6. 非RAM存储:硬盘等永久存储空间
Java虚拟机栈
Java虚拟机栈是由一个个栈帧组成的,而每个栈帧中都拥有:局部变量表,操作数栈,动态链接,方法出口信息。
局部变量表主要存放了编译期可知的各种数据类型(boolean,byte,char,short,int,long,float,double)以及对象引用(reference类型,它不同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此对象相关的位置)
- 每个线程都包含一个栈区,栈中只保存基本数据类型的值和对象的引用以及基础数据的引用。
- 每个栈中的数据都是私有的,其他栈无法访问。
- 栈分为三个部分:基本类型变量区、执行环境上下文、操作指令区(存放操作指令)。
java虚拟机栈会出现的两种错误
- StackOverFlowError : 若java虚拟机的内存大小不允许动态扩展,那么当线程请求栈的深度超过当前java虚拟机栈的最大深度的时候,就会抛出StackOverFlowError错误。
- OutOfMemoryError:java虚拟机的内存可以动态扩展,如果虚拟机在扩展的时无法申请到足够的空间,则抛出OutOfMemoryError错误。
java方法有两种返回方式:
- return语句
- 抛出异常
无论是那种返回方式都会导致栈帧被弹出。
本地方法栈
和虚拟机栈不同的是,虚拟机栈会为了虚拟机执行java方法(也就是字节码)服务。而本地方法栈则为虚拟机使用到Native方法服务。在HotSpot虚拟机中和java虚拟机合二为一。
本地方法被执行的时候,在本地方法栈会创建一个栈帧,用于存放该本地方法的局部变量表,操作数栈,动态链接,出口信息。
方法执行完毕后相应的栈帧也会出栈并释放内存空间,也会出现StackOverFlowError和OutOfMemoryError两种错误。
Native
凡是带了native关键字的,说明java的作用范围达不到了,会去调用底层c语言的库。
会进入本地方法栈,调用本地方法接口(JNI),进而调用本地方法库。
JNI作用:扩展java的使用,融合不同的编程语言为java使用!
堆(Heap)
java堆是所有线程共享的一块内存区域,在虚拟机启动时创建,此内存对象的唯一目的就是存放对象实例,几乎所有的对象实例以及数组都会在这里分配内存。
java中几乎所有的对象都在堆中分配,但随着JIT编译期的发展与逃逸技分析技术的逐渐成熟,栈上分配,标量替换优化技术将会导致一些变化。从JDK1.7开始已经默认开启逃逸分析,如果说某些方法中的对象引用没有被返回或者未被方法外界调用(也就是为逃逸出去),那么对象也可以直接在栈上分配内存。
java堆是垃圾收集器管理的主要区域,因此也被称为GC堆,从垃圾回收的角度,由于现在收集器基本都采用分代收集算法,所有堆又分为新生代和老年代,再细致一点可以分为:Eden空间,From Survivor,To Survivor空间等。进一步划分是为了更好的回收内存,或者更快的分配内存,提高内存的利用效率。
在JDK7版本及之前,堆内存通常被分为下面三个部分:
- 新生代内存(Young Generation)
- 老年代内存(Old Generation)
- 永久代 (Permanent Generation)
JDK8版本之后方法区(HotSpot的永久代)被彻底了(JDK1.7就已经开始了),取而代之的是元空间,元空间使用的是直接内存。
新生区,幸存区,老年区
新生区: 新生区主要存放新创建的对象,内存大小会相对比较小,清理的次数也相对较多。采用的垃圾回收算法是复制算法。
幸存区: 当新生区存满之后,会触发一次轻GC,将不用的对象清理,并将有用的对象存入幸存区,幸存区有两个区,from区和to区,当进行垃圾回收后,对象会从from区复制到to区,然后下一次垃圾回收的时候,from区和to区就会发生对调。关于from区和to区的区别,记住一句话:谁空谁是to
老年区: 老年区主要存放JVM中生命周期比较长的对象,也就是在新生区和幸存区经过几次垃圾回收仍然存留下来的对象会进入老年区,垃圾回收也没有那么频繁。老年区主要采用标记清除(先标记后清除)和标记清除压缩(清除之后会有残留的碎片,将碎片放在一块)的垃圾回收算法来避免内存碎片过多。
堆内存调优
OOM解决措施:
- 尝试扩大内存看结果
- 分析内存,设置参数,使用专业工具Jprofiler查看是否有垃圾代码和死循环的代码占用空间。
关于常用JVM参数设置:
- -Xms(初始堆大小):后面跟要设置的内存大小
- -Xmx(最大堆大小):后面跟要设置的内存大小
- -XX:+PrintGCDetails:输出GC细节
- …
GC(垃圾回收机制)
GC分为两种:轻GC和重GC,轻GC主要是当新生区满的时候对新生区进行一次清理,偶尔清理一下幸存区,重GC是当新生区和老年区满之后,进行一次垃圾清理
常用垃圾回收算法:
-
标记清楚法:算法分为“标记”和“清楚”两个阶段,首先标记出所有需要回收的对象,在标记完成之后统一回收掉所有被标记的对象。
缺点:- 效率问题:标记和清楚的效率都不高
- 空间问题:标记清楚后悔产生大量不连续的内存碎片,内存碎片太多会导致无法分配到足够的连续内存,从而不得不触发GC。
-
复制算法:将可用内存按容量划分为大小相等的两块,每次只使用其中的一块,当这一块的内存用完了,就将还存活着的对象复制到另一边,然后把已使用过的内存空间一次清理掉。
缺点:- 效率问题:当对象存活率较高时,复制操作次数多,效率降低。
- 空间问题:内存缩小了一半,需要额外空间做分配担保(老年区)。
-
标记整理算法:过程与标记清除算法步骤基本相同,只不过清除操作替换为整理操作,将所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。
-
分代收集算法:将JVM堆分为新生区和老年区,这样就可以根据各个区域的特点利用最适当的收集算法。
- 在新生区中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选择复制算法,只需付出少量存活对象的复制成本,就可以完成收集。
- 在老年区中,由于对象存活率高,没有额外的空间对它们进行分配担保,就必须使用“标记-清除”或“标记整理”算法进行回收。
- 幸存区中的0区和1区就是采用复制算法来进行清理。
HotSpot虚拟机对象探索
对象的创建
-
类加载检查
当虚拟机收到一条new指令时,首先会去检查这个指令的参数能否在常量池中定位到这个类的符号引用,并且检查这个符号引用代表的类是否已经被加载过,解析过和初始化过。如果没有就必须执行相应的类加载过程。 -
分配内存
在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需的内存大小在类加载完成后就可以确定。为对象分配内存的任务等同于将Java堆中一块确定大小的内存划分出来。分配内存的方式有两种,指针碰撞和空闲列表,选择哪种方式取决于Java堆是否规整,而Java堆是否规整又由所采用的的垃圾收集器是否具有压缩整理功能决定。内存分配的两种方式(需要掌握):
内存分配并发问题(需要掌握)在创建对象的时候有一个很重要的问题,就是线程安全,因为在实际开发中,创建对象是很频繁的事 情,作为虚拟机来说,必须要保证线程是安全的,通常来讲:虚拟机采用两种方式来保证线程安全:
- CAS+失败重试:CAS是乐观锁的一种实现方式。所谓乐观锁就是,每次不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,知道成功为止。虚拟机采用CAS加失败重试的方式保证更新操作的原子性。
- TLAB:为每一个线程与现在Eden区分配一块内存,JVM在给线程中的对象分配内存时,首先在TLAB分配,当对象大于TLAB中的剩余内存或者TLAB的内存已经用尽时,再采用上述的CAS进行内存分配。
-
初始化零值
内存分配完之后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),这一步操作保证了对象的实例字段在Java代码中可以不赋初值就直接使用,程序能访问到这些字段的数据类型所对应的零值。 -
设置对象头
初始化零值完成后,虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例,如何才能找到类的元数据信息,对象的哈希码,对象的GC分带年龄等信息。这些信息存放在对象头中。另外,根据虚拟机当前运行状态的不同,如是否采用偏向锁,对象头会有不同的设置方式。 -
执行init方法
在上面的操作都完成之后,从虚拟机的角度来看,一个新的对象已经产生了,但从java程序的角度来看,对象创建才刚刚开始,<init>方法还没有执行,所有的字段都还为0。所以,一般来说执行new指令后接着汇之星<init>方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全创建出来。
对象的内存布局
在HotSpot虚拟机中,对象内存可以分为三个区域:对象头,实例数据,对象填充。
对象头包括两部分信息:第一部分用于存储对象自身的运行时数据(哈希码,GC分代年龄,锁状态标志等等),另一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。
实例数据部分是对象真正存储的有效信息,也是程序中所定义的各种类型的字段内容。
对齐填充部分不是必然存在的,也没有什么特别的含义,仅仅起占位作用。因为HotSpot虚拟机的自动内存管理系统要求对象起始地址必须是8字节的整数倍,换句话说就是对象的大小必须是8字节的整数倍。而对象头部分正好是8字节的倍数(1倍或2倍),因此,当对象实例数据部分没有对齐时,就需要通过对齐填充来解决。
对象的访问定位
我们Java通过栈上reference数据来操作堆上的具体对象。对象的访问方式由虚拟机实现而定,目前主流的访问方式有两种:①使用句柄 ②直接指针
-
句柄:如果使用句柄,那么Java堆中就会分配出一块内存用来作为句柄池,reference数据中存放的就是该对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息。
-
直接指针:如果使用直接指针访问,那么Java堆对象的布局中就必须考虑如何放置访问类型数据的相关信息,而reference中存储的直接就是对象的地址。
两种方法各有优势,使用句柄的最大好处就是reference中存储的是稳定的句柄地址,在对象被移动时只会改变句柄中的实例数据指针,而reference本身不需要修改。使用直接指针访问的方式最大好处就是速度快,它节省了一次指针定位的时间开销。
JMM
什么是JMM?
JMM即Java内存模型(Java memory model),在JSR133里指出了JMM是用来定义一个一致的、跨平台的内存模型,是缓存一致性协议,用来定义数据读写的规则。
简单说明一下:在java中,不同线程拥有各自私有的工作内存,当线程需要读取或者修改某个变量的时候,不能直接去操作主内存中的变量,而是需要将这个变量读取到线程的工作内存的变量副本中,当该线程改变其变量的值后,其他线程并不能立刻读取到新值,需要将修改后的数据刷新到主内存中,其他线程才能从主内存中读取到修改后的值。
JMM中的重要点:volatile,synchronized
volatile
原理:转自
- 规定线程每次修改变量副本后立刻同步到主内存中,用于保证其它线程可以看到自己对变量的修改
- 规定线程每次使用变量前,先从主内存中刷新最新的值到工作内存,用于保证能看见其它线程对变量修改的最新值
- 为了实现可见性内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来防止指令重排序。
注意:
- volatile只能保证基本类型变量的内存可见性,对于引用类型,无法保证引用所指向的实际对象内部数据的内存可见性。关于引用变量类型详见:Java的数据类型。
- volilate只能保证共享对象的可见性,不能保证原子性:假设两个线程同时在做x++,在线程A修改共享变量从0到1的同时,线程B已经正在使用值为0的变量,所以这时候可见性已经无法发挥作用,线程B将其修改为1,所以最后结果是1而不是2。