JVM内存模型
介绍下内存模型
根据JDK8的规范,我们的JVM内存模型可以拆分为:程序计数器、Java虚拟机栈、堆、元空间、本地方法栈,还有一部分叫直接内存,属于操作系统的本地内存,也是可以直接操作的。
详细解释一下
- 程序计数器:可以看作是当前线程所执行的字节码的行号指示器,用于存储当前线程正在执行的Java方法的JVM指令地址。
- Java虚拟机栈:每个线程都有一个独立的Java虚拟机栈,生命周期和线程相同。每个方法在执行时都会创建一个栈帧,用于存储局部变量表,操作数栈,动态链接,方法出口等,可能抛出OOM和StackOverflowError异常。
- 本地方法栈:与Java虚拟机栈类型,主要为虚拟机使用到的Native方法服务,在HotSpot虚拟机中和Java虚拟机栈合二为一。本地方法执行时也会创建栈帧,同样也可以抛出OOM和StackOverflowError异常。
- 方法区(元空间):在JDK8以后的版本中,方法区被元空间取代,使用本地内存。用于存储已被虚拟机加载的类信息、常量、静态变量等数据。虽然方法区被描述为堆堆逻辑部分。方法区可以选择不进行垃圾回收。同样可以抛出OOM和StackOverflowError异常。
- Java堆:在JVM最大的一个部分,被所有线程所共享,当虚拟机启动时,用于存放所有的对象实例。从垃圾回收的角度,分为新生代和老年代,新生代分为伊甸区(Eden)和幸存区(Survivor分为From Survivor和To Survivor),如果在堆中没有内存完成实例分配,并且堆也无法扩展时会OOM。
- 运行时常量池:是方法的一部分,用于存放编译期间生成的各种字面量和符号引用,具有动态性。
- 直接内存:不属于JVM运行时数据区的一部分,通过NIO引入,是一种堆外内存,可以显著提高i/o性能。
JVM内存模型中堆和栈的区别
- 用途:栈主要用于存储局部变量、方法参数的调用、方法返回地址以及一些临时数据。每当一个方法被调用,就会创建一个栈帧,用于存储方法的信息,方法执行完毕,栈帧也会被移除。堆用于存储对象的实例,当你使用new去创建一个对象时,对象实例就会在上面分配空间。
- 生命周期:栈中的数据具有确定的生命周期,当一个方法结束调用时,其对应的帧栈就会被移除,栈中存储的局部变量也就会消失。堆中的对象没有固定的生命周期,闲置对象会在垃圾回收机制下被回收。
- 存取速度:栈的存取速度比堆快,因为栈遵循先进后出FIFO的原则,操作简单快速。堆的存取速度较慢,因为对象在对上分配和回收需要更多的时间,垃圾回收机制也会影响性能。
- 存储空间:栈的空间相对较小,且较为固定,由操作系统管理。当栈溢出时,通常因为递归过深或者局部变量过大。堆的空间较大,动态扩展,由JVM管理,堆溢出通常由于创建了太多的大对象未及时收回。
- 可见性:栈中的数据对线程是私有的,每个线程有自己的栈空间。堆中的数据对线程是共享的,所有线程都可以访问堆上的对象。
栈中存储的到底是指针还是对象
在JVM内存模型中,栈主要用于管理线程的局部变量和方法的上下文调用,而堆是粗处所有类的实例和数组。
当我们在讨论存储时,实际上栈中存储的是方法执行时的基本数据类型和对象的引用,这里注意是对象的引用,不是对象本体,指向堆中对象的实例。
堆分为哪几部分呢
- 新生代:新生代分为Eden伊甸区和幸存区。在伊甸区中,大多数新创建的对象都会放在这里,Eden区相对较少,当Eden区满时,会触发一个Minor GC(轻GC)。在幸存区中,通常分为两个大小相等的部分,每次Minor GC时,存活下来的对象会被移动到其中的一个幸存区,以继续他们的生命周期。
- 老年代:存放过一次或多次Minor GC仍存活的对象会被移动到老年代。老年代的生命周期较长,因此Full GC发生频率较低,但是执行时间比Minor GC长,老年代空间比新生代长。
- 元空间:从Java8开始,永久代被元空间取代,用于存储类的元信息,如类的结构信息等。元空间不在堆红,而是使用了本地内存,解决了永久代OOM的问题。
- 大对象区:在某些JVM实现中引入了大对象区,指需要大量连续的内存空间的对象,如大数组,这类对象直接放在老年代,避免年轻代的晋升而导致内存碎片化。
方法区的执行过程
- 解析方法调用:JVM会根据方法的符号引用找到实际方法地址。
- 栈帧创建:在调用一个方法前,JVM会在当前线程的Java虚拟机栈中为该方法分配一个新的栈帧,用于存储局部变量表、操作数栈、动态链接、方法出口信息。
- 执行方法:执行方法内的字节码命令,涉及的操作可能包括局部变量的读写等操作。
- 返回处理:方法执行完毕后,可能会返回一个结果给调用者,并清理当前栈帧,恢复调用者的执行环境。
String保存在那里?
String是包存在字符串常量池中,不同于其他对象,他的值是不可变的,可以被多个引用共享。
引用类型有哪些?有什么区别
- 强引用类型:是代码中普遍的赋值方式,比如A a = new A();这样的发过誓。强引用关联的对象,永远不会被垃圾回收器回收。
- 软引用类型:可以用SoftReference来描述,指的是那些有用但是不是必须要的对象。系统在发生内存溢出前会针对这样的对象进行回收。
- 弱引用:可以用WeakReference来描述,他的强度比软引用低,弱引用的对象下一次GC的时候一定会被回收,不管内存是否足够。
- 虚引用:幻影引用,是最弱的引用关系,他必须和ReferenceQueue一起使用,当GC发生时,虚引用也会被回收。可以用虚引用来管理堆外内存。
弱引用了解吗?举例说明
Java中的弱引用是一种引用类型,他不会阻止一个对象被垃圾回收。
- 缓存系统:弱引用常用来实现缓存,特别是当希望缓存项能够在内存压力下自动释放时。如果缓存的大小不受控制,可能会导致内存溢出。使用弱引用来维护缓存,可以让JVM在需要更多内存的时候自动清理这些对象。
- 对象池:在对象池中,弱引用可以用来管理那些暂时不用的对象。当对象不再被强引用时,他可以被垃圾回收,释放内存。
- 避免内存泄漏:当一个对象不应该被长期引用时,使用弱引用可以防止该对象被意外的保留,避免的潜在的内存泄漏。
内存泄漏和内存溢出的理解
内存泄露:内存泄露通常是在程序运行中不再使用的对象仍然被引用,从而无法被垃圾回收器回收,从而导致可用内存逐渐变少,虽然在Java中,垃圾回收机制会自动回收不再引用的对象,但是仍有对象不会被回收,最终导致程序内存不断增加。
导致内存泄漏的原因
- 静态集合:使用静态的数据结构存储对象,且没有清理。
- 事件监听:未取消对事件的监听,导致对象被持续引用
- 线程:未停止的线程可能持有对象的引用,无法被回收
内存溢出:内存溢出指的是Java虚拟机在申请内存时,无法找到足够的内存,最终引发OOM。
内存溢出主要原因
- 大量对象创建:程序中不断创建大量对象,超出JVM堆的限制。
- 持久引用:大型数据结构长时间持有对象的引用,导致内存积累
- 递归调用:深度递归导致栈溢出
JVM内存结构中有哪几种内存溢出的情况
- 堆内存溢出:当出现OOM时,就是堆内存溢出了,原因是代码中可能存在大对象分配。或者发生了内存泄漏,导致多次GC之后,仍无法找到一个合适的空间存放当前对象。
- 栈内存溢出:如果我们写一个程序不断的递归调用,而且没有退出条件,就回导致不断的进行压栈。类似于这种情况会JVM会抛出:SOF。如果JVM试图扩展栈空间失败,则直接报出OOM。
- 元空间溢出:出现这个异常是系统的代码非常多或者引用了过多的第三方包或者通过动态代码加载类的操作,导致元空间内存被压满
- 直接内存溢出:在使用ByteBuffer会使用到,很多JavaNIO的框架(Neety和Vert.x)被封装为其他的方法会抛出OOM。
类初始化和加载
创建对象的过程
- 类加载检查:虚拟机收到一条new指令时,首先将去检查这个指令的参数能否在常量池中定位到一个类的符号引用。并检查这个符号引用代表的类是否被加载、解析和初始化过。如果没有则必须先进行相应的类加载工程。
- 分配内存:在类加载检查过程后,接下来虚拟机将为新生代分配内存。对象所需的内存大小在类加载后方可确定,将对象所需要的空间从Java堆中划分出来。
- 初始化零值:内存分配完成后,虚拟机需要将分配到的内存空间初始化为0值,这一步保证了对象的实例字段在Java中可以不赋值就可以直接使用,程序能够能访问到这些字段的数据类型所对应的零值。
- 进行必要设置(对象头):初始化零值完成之后,虚拟机需要对对象进行必要的设置。也就是初始化对象头。
- 执行init方法:在上面工作完成后,在JVM看来,一个对象已经执行完毕了。但是在程序看来,才刚刚开始。需要根据程序设计者的思路来初始化对象。
对象生命周期
- 创建:对象通过关键字new在堆内存中被初始化,构造函数被调用,对象内存空间被分配。
- 使用:对象被引用并执行相应操作。
- 销毁:当对象部呗引用时,通过垃圾回收机制自动回收对象所占用的内存空间。
类加载器都有哪些
- 启动类加载器:这是最顶层的加载器,负责加载Java的核心库,是使用C++编写的,是JVM的一部分,启动类加载器无法被Java程序直接引用。
- 扩展类加载器:他是Java语言实现的,继承自ClassLoader类,负责加载Java扩展目录下的jar包和类库,扩展类加载器由启动类加载器加载,并且父加载器就是启动类加载器。
- 系统类加载器:这也是Java语言实现的,负责加载用户类路径上的指定类库,是我们平时编写Java默认使用的加载器。
- 自定义类加载器:用户定制的类加载器。
这些类加载器之间的关系形成了双亲委派模型,核心思想是当一个类加载器收到类加载请求时,首先不去自己尝试加载这个类,而是把这个请求委派给副加载器去完成,每一层都是如此,因此所加载请求最终都应该传送到顶层加载器中。
双亲委派到作用
- 保证类的唯一性:通过委托机制,保证每一个加载请求都会传递到启动类加载器,避免不同的加载器加载相同的类,保证了Java核心库的一致性
- 保证安全性:防止不可信的类假冒核心类。因为都会被委托到顶层的启动类加载器。
- 隔离和层次划分:双亲委派模型支持不同层次的类加载器服务于不用的类的加载请求。
- 简化加载流程:通过委派,大部分类能够被正确的类加载器加载,减少了每个加载器需要处理类的数量,简化加载过程。
讲一下类加载的过程
- 加载:通过类的全限定名获取到类的.class文件的二进制字节流,将二进制字节流所代表的静态存储结构转化为方法区运行时的数据结构,在内存中生成一个代表该类的Java.lang.Class对象,作为方法区这个类的各种入口。
- 连接:
- 验证:验证这个类的字节流内所包含的信息是否符合当前虚拟机要求。
- 准备:将静态资源赋0值
- 解析:将符号引用直接替换为直接引用
- 初始化:初始化是整个类加载过程中的最后一个阶段,初始化阶段简单来说执行构造器方法们要注意这个构造器不是开发者写的,是编译器生成的。
- 使用类或者对象
- 卸载:当一个类的所有实例都被回收,也就是Java堆中不存在该类的任何实例,加载类的ClassLoader已经被回收。类对应的Java.lang.Class对象没有任何地方被引用,无法在任何地方通过反射获取对象。