Java JVM与内存管理

1.JVM内存区域划分(程序计数器、java堆、java虚拟机栈、本地方法栈、方法区)

  • 程序计数器
  1. 一块较小的内存空间,它的作用可以看作是当前线程所执行的字节码的行号指示器。
  2. 如果线程正在执行的是一个java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是Native方法,这个计数器值则为空。
  3. 此内存区域是唯一一个在java虚拟机规范中没有规定任何OutOfMemoryError情况的区域。
  • java堆(线程共有)
  1. 全局共享
  2. 通常是java虚拟机中最大的一块内存区域
  3. 主要作为java对象的主要存储区域
  4. JVMS明确要求该区域需要实现自动内存管理,即常说的GC,但并不限制采用那种算法和技术去实现。
  5. 可能出现OutOfMemoryError异常
  • java虚拟机栈(线程私有)
  1. 线程私有
  2. 后进先出(LIFO)栈
  3. 用于执行JAVA方法。栈帧存储局部变量表、操作数栈、动态链接、方法返回地址和一些额外的附加信息。程序执行时栈帧入栈;执行完成后栈帧出栈,支撑java方法的调用、执行和退出
  4. 肯能会出现OutOfMemoryError异常和StackOverFlowError异常
  • 本地方法栈(线程私有)
  1. 线程私有
  2. 后进先出(LIFO)栈
  3. 作用是支撑Native方法的调用、执行和退出
  4. 可能出现OutOfMemoryError异常和StackOverflowError异常
  5. 有一些虚拟机(如HotSpot)将Java虚拟机栈和本地方法栈合并实现
  • 方法区(线程共有)

(1)方法区

  1. 全局共享
  2. 作用是存储java类的结构信息。用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。但是已经被最新的 JVM 取消了。现在,被加载的类作为元数据加载到底层操作系统的本地内存区。
  3. JVMS不要求该区域实现自动内存管理,但是商用Java虚拟机都能够自动管理该区域的内存
  4. 可能出现OutOfMemoryError异常

(2)运行时常量区

  1. 全局共享
  2. 是方法区的一部分
  3. 作用是存储java类文件常量池中的符号信息
  4. 可能出现OutOfMemoryError异常

1.1JVM内存区域划分相关概念

  • 栈帧的概念和特征
  1. Java虚拟机栈中存储的内容,它被用于存储数据和部分过程结果的数据结构,同时也被用来处理动态链接、方法返回值和异常分派
  2. 一个完整的栈帧包含:局部变量表、操作数栈、动态连接信息、方法正常完成信息和方法异常完成信息
  • 局部变量表概念和特征
  1. 由若干个Slot组成,长度由编译期决定
  2. 单个Slot可以存储一个类型为boolean,byte,char,short,float,reference和returnAddress的数据,两个Slot可以存储一个类型为long或double的数据
  3. 局部变量表用于方法间参数传递,以及方法执行过程中存储基础数据类型的值和对象的引用
  • 操作数栈的概念和特征
  1. 是一个后进先出栈,由若干个Entry组成,长度由编译期决定
  2. 单个Entry即可以存储一个Java虚拟机中定义的任意数据类型的值,包括long和double类型,但是存储long和double类型的Entry深度为2,其他类型的深度为1
  3. 在方法执行过程中,栈帧用于存储计算参数和计算结果;在方法调用时,操作数栈也用来准备调用方法的参数以及接收方法返回结果
  • 直接内存
  1. 并非JVMS定义的标准java运行时内存区域
  2. 随jdk1.4中加入的nio被引入,母的是避免在java堆和native堆中来回复制数据带来的性能损耗
  3. 全局共享
  4. 能被自动管理,但是在检测手段上可能会有一些简陋
  5. 可能出现OutOfMemoryError异常

1.2常用主流java虚拟机

Oracle HotSpot

2.jvm加载class文件的原理机制

  • 什么是JVM类加载机制

在代码编译后,就会生成JVM(Java虚拟机)能够识别的二进制字节流文件(*.class)。而JVM把Class文件中的类描述数据从文件加载到内存,并对数据进行校验、转换解析、初始化,使这些数据最终成为可以被JVM直接使用的Java类型,这个说来简单但实际复杂的过程叫做JVM的类加载机制。

  • 类加载的几个步骤(加载、验证、准备、解析、初始化、使用、卸载)

(一)加载

在这个阶段,JVM主要完成三件事:

(1)通过一个类的全限定名(包名与类名)来获取定义此类的二进制字节流(Class文件)。而获取的方式,可以通过jar包、war包、网络中获取、JSP文件生成等方式。

(2)将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。这里只是转化了数据结构,并未合并数据。(方法区就是用来存放已被加载的类信息,常量,静态变量,编译后的代码的运行时内存区域)

(3)在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。这个Class对象并没有规定是在Java堆内存中,它比较特殊,虽为对象,但存放在方法区中。

(二)链接(验证、准备、解析)

类的加载过程后生成了类的java.lang.Class对象,接着会进入连接阶段,连接阶段负责将类的二进制数据合并入JRE(Java运行时环境)中。类的连接大致分三个阶段。

<1>验证:验证被加载后的类是否有正确的结构,类数据是否会符合虚拟机的要求,确保不会危害虚拟机安全。

<2>准备:为类的静态变量(static filed)在方法区分配内存,并赋默认初值(0值或null值)。如static int a = 100;

静态变量a就会在准备阶段被赋默认值0。

对于一般的成员变量是在类实例化时候,随对象一起分配在堆内存中。

另外,静态常量(static final filed)会在准备阶段赋程序设定的初值,如static final int a = 666; 静态常量a就会在准备阶段被直接赋值为666,对于静态变量,这个操作是在初始化阶段进行的。

<3>解析:将类的二进制数据中的符号引用换为直接引用。

(三)初始化

类初始化是类加载的最后一步,除了加载阶段,用户可以通过自定义的类加载器参与,其他阶段都完全由虚拟机主导和控制。到了初始化阶段才真正执行Java代码。

类的初始化的主要工作是为静态变量赋程序设定的初值。

如static int a = 100;在准备阶段,a被赋默认值0,在初始化阶段就会被赋值为100。

Java虚拟机规范中严格规定了有且只有五种情况必须对类进行初始化:

(1)使用new字节码指令创建类的实例,或者使用getstatic、putstatic读取或设置一个静态字段的值(放入常量池中的常量除外),或者调用一个静态方法的时候,对应类必须进行过初始化。

(2)通过java.lang.reflect包的方法对类进行反射调用的时候,如果类没有进行过初始化,则要首先进行初始化。

(3)当初始化一个类的时候,如果发现其父类没有进行过初始化,则首先触发父类初始化。

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

(5)使用jdk1.7的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果REF_getStatic、REF_putStatic、RE_invokeStatic的方法句柄,并且这个方法句柄对应的类没有进行初始化,则需要先触发其初始化。

注意,虚拟机规范使用了“有且只有”这个词描述,这五种情况被称为“主动引用”,除了这五种情况,所有其他的类引用方式都不会触发类初始化,被称为“被动引用”。

<1>被动引用的例子一:

通过子类引用父类的静态字段,对于父类属于“主动引用”的第一种情况,对于子类,没有符合“主动引用”的情况,故子类不会进行初始化。代码如下:

[java] view plain copy
//父类  
public class SuperClass {  
    //静态变量value  
    public static int value = 666;  
    //静态块,父类初始化时会调用  
    static{  
        System.out.println("父类初始化!");  
    }  
}  
  
//子类  
public class SubClass extends SuperClass{  
    //静态块,子类初始化时会调用  
    static{  
        System.out.println("子类初始化!");  
    }  
}  
  
//主类、测试类  
public class NotInit {  
    public static void main(String[] args){  
        System.out.println(SubClass.value);  
    }  
} 

输出结果:

父类初始化!

666

<2>被动引用的例子之二:

通过数组来引用类,不会触发类的初始化,因为是数组new,而类没有被new,所以没有触发任何“主动引用”条款,属于“被动引用”。代码如下:

[java] view plain copy
//父类  
public class SuperClass {  
    //静态变量value  
    public static int value = 666;  
    //静态块,父类初始化时会调用  
    static{  
        System.out.println("父类初始化!");  
    }  
}  
  
//主类、测试类  
public class NotInit {  
    public static void main(String[] args){  
        SuperClass[] test = new SuperClass[10];  
    }  
}  

没有任何结果输出!

<3>被动引用的例子之三:

刚刚讲解时也提到,静态常量在编译阶段就会被存入调用类的常量池中,不会引用到定义常量的类,这是一个特例,需要特别记忆,不会触发类的初始化!

[java] view plain copy
//常量类  
public class ConstClass {  
    static{  
        System.out.println("常量类初始化!");  
    }  
      
    public static final String HELLOWORLD = "hello world!";  
}  
  
//主类、测试类  
public class NotInit {  
    public static void main(String[] args){  
        System.out.println(ConstClass.HELLOWORLD);  
    }  
}  

输出结果:

hello world!

(四)使用

(五)卸载

  • 类及类加载器分类

(1)系统类:Bootstrap Loader -负责加载系统类(jar/lib/rt.jar)

(2)应用类:ExtClassLoader -负责加载扩展类(jar/lib/ext/*.jar)

(3)自定义类:AppClassLoader -负责加载应用类(classpath指定的目录或jar中的类)

3.内存中的栈(stack)、堆(heap)和静态区(static area)的用法

通常我们定义一个基本数据类型的变量,一个对象的引用,还有就是函数调用的现场保存都使用内存中的栈空间;而通过new关键字和构造器创建的对象放在堆空间;程序中的字面量(literal)如直接书写的100、”hello”和常量都是放在静态区中。栈空间操作起来最快但是栈很小,通常大量的对象都是放在堆空间,理论上整个内存没有被其他进程使用的空间甚至硬盘上的虚拟内存都可以被当成堆空间来使用。

4.java中堆和栈的区别

栈内存 :栈内存首先是一片内存区域,存储的都是局部变量,凡是定义在方法中的都是局部变量(方法外的是全局变量),for循环内部定义的也是局部变量,是先加载函数才能进行局部变量的定义,所以方法先进栈,然后再定义变量,变量有自己的作用域,一旦离开作用域,变量就会被释放。栈内存的更新速度很快,因为局部变量的生命周期都很短。

堆内存 :存储的是数组和对象(其实数组就是对象),凡是new建立的都是在堆中,堆中存放的都是实体(对象),实体用于封装数据,而且是封装多个(实体的多个属性),如果一个数据消失,这个实体也没有消失,还可以用,所以堆是不会随时释放的,但是栈不一样,栈里存放的都是单个变量,变量被释放了,那就没有了。堆里的实体虽然不会被释放,但是会被当成垃圾,Java有垃圾回收机制不定时的收取。

JVM是基于堆栈的虚拟机.JVM为每个新创建的线程都分配一个堆栈.也就是说,对于一个Java程序来说,它的运行就是通过对堆栈的操作来完成的。堆栈以帧为单位保存线程的状态。JVM对堆栈只进行两种操作:以帧为单位的压栈和出栈操作。

堆与栈的区别

1.栈内存存储的是局部变量而堆内存存储的是实体;

2.栈内存的更新速度要快于堆内存,因为局部变量的生命周期很短;

3.栈内存存放的变量生命周期一旦结束就会被释放,而堆内存存放的实体会被垃圾回收机制不定时的回收。

4.堆是先进先出,后进后出。栈是后进先出,先进后出

5.java中是否存在内存泄漏

  • 5.1 什么是内存泄漏、溢出

1、内存泄漏 memory leak:对象可达但不可用;是指程序在申请内存后,无法释放已申请的内存空间,一次内存泄露危害可以忽略,但内存泄露堆积后果很严重,无论多少内存,迟早会被占光。

2、内存溢出 out of memory:内存大小不够;是指程序在申请内存时,没有足够的内存空间供其使用,出现out of memory;比如申请了一个integer,但给它存了long才能存下的数,那就是内存溢出。

  • 5.2 如何避免内存泄漏、溢出
  1. 尽早释放无用对象的引用

好的办法是使用临时变量的时候,让引用变量在推出活动域后自动设置为null,暗示垃圾收集器来收集该对象,防止发生内存泄漏。
2. 程序进行字符串处理时,尽量避免使用String,而应该使用StringBuffer。

因为String类是不可变的,每一个String对象都会独立占用内存一块区域。
3. 尽量少用静态变量

因为静态变量是全局的,存在方法区,GC不会回收。(用永久代实现的方法区,垃圾回收行为在这个区域是比较少出现的,垃圾回收器的主要目标是针对常量池和类型的卸载)
4. 避免集中创建对象,尤其是大对象,如果可以的话尽量使用流操作

JVM会突然需要大量neicun,这时会出发GC优化系统内存环境
5. 尽量运用对象池技术以提高系统性能

生命周期长的对象拥有生命周期短的对象时容易引发内存泄漏,例如大集合对象拥有大数据量的业务对象的时候,可以考虑分块进行处理,然后解决一块释放一块的策略。
6. 不要在经常调用的方法中创建对象,尤其忌讳在循环中创建对象

可以适当的使用hashtable,vector创建一组对象容器,然后从容器中去取这些对象,而不用每次new之后又丢弃。
7. 优化配置

  • 5.3 内存溢出的解决方案是什么

1、从代码层面进行优化完善,尽量避免该情况发生;

2、调整优化服务器配置:

1)设置-Xms、-Xmx等

2)设置NewSize、MaxNewSize相等

3)设置 Heap size,PermGen space

6.JVM GC机制与内存优化

  • 6.1 java堆

GC主要发生在堆内存中,所以此处会会对堆内存进行比较详细的描述。

堆内存是由存活和死亡的对象组成的。存活的对象是应用可以访问的,不会被垃圾回收。死亡的对象是应用不可访问尚且还没有被垃圾收集器回收掉的对象。一直到垃圾收集器把这些对象回收掉之前,他们会一直占据堆内存空间。堆是应用程序在运行期请求操作系统分配给自己的向高地址扩展的数据结构,是不连续的内存区域。用一句话总结堆的作用: 程序运行时动态申请某个大小的内存空间

层次结构 如下:

(一)堆内存

(1)新生代

  1. Eden(占比80%)

  2. 两块survivor space(占比20%)

(2)老年代

(二)非堆内存

新生代 :刚刚新建的对象在Eden中,经历一次Minor GC,Eden中的存活对象就会被移动到第一块survivor space S0,Eden被清空;等Eden区再满了,就再触发一次Minor GC,Eden和S0中的存活对象又会被复制送入第二块survivor space S1。S0和Eden被清空,然后下一轮S0与S1交换角色,如此循环往复。如果对象的复制次数达到16次,该对象就会被送到老年代中。

老年代 :如果某个对象经历了几次垃圾回收之后还存活,就会被存放到老年代中。老年代的空间一般比新生代大。

Minor GC 发生在新生代,频率高,速度快(大部分对象活不过一次Minor GC)

Major GC 发生在老年代,速度慢

Full GC 清理整个堆空间

不过实际运行中,Major GC会伴随至少一次 Minor GC,因此也不必过多纠结于到底是哪种GC(在有些资料中看到把full GC和Minor GC等价的说法)。

那么, 当我们创建一个对象后,它会被放在堆内存的哪个部分呢?

当我们创建一个对象首先会在Eden初始化内存,如果Eden足够则申请结束,如果Eden不足则释放Eden中不活跃对象,如果释放完以后足够了则申请结束,如果释放完以后还是不够则将部分Eden活跃对象放入survivor,然后放入老年代中,如果老年代空间不够则保留在survivor,在老年代进行Major.GC然后放入老年代中,Major GC之后还是老年代不足,那就没办法了,JVM会抛出内存不足的异常。

  • 6.2 垃圾回收机制
    JAVA 并没有给我们提供明确的代码来标注一块内存并将其回收。或许你会说,我们可以将相关对象设为 null 或者用 System.gc()。然而,后者将会严重影响代码的性能,因为一般每一次显式的调用 system.gc() 都会停止所有响应,去检查内存中是否有可回收的对象。这会对程序的正常运行造成极大的威胁。另外,调用该方法并不能保证 JVM 立即进行垃圾回收,仅仅是通知 JVM 要进行垃圾回收了,具体回收与否完全由 JVM 决定。这样做是费力不讨好。

垃圾回收器是利用有向图来记录和管理内存中的所有对象,通过该有向图,就可以识别哪些对象“可达”,哪些对象“不可达”,“不可达”的对象就是可以被回收的。这里举一个很简单的例子来说明这个原理:

public class Test{
  public static void main(String[] a){
     Integer n1=new Integer(9);
     Integer n2=new Integer(3);
     n2=n1;
     // other codes
  }
}
  • 6.3 垃圾回收算法概述
  1. 引用计数算法

给对象中添加一个引用计数器,每当有一个地方引用它时,计数器的值加1;当引用失效时,计数器值就减1;任何时刻计算器为0的对象就是不可能再被使用的。

  1. 追踪回收算法(tracing collector)

从根结点开始遍历对象的应用图。同时标记遍历到的对象。遍历完成后,没有被标记的对象就是目前未被引用,可以被回收。

  1. 压缩回收算法(Compacting Collector)

把堆中活动的对象集中移动到堆的一端,就会在堆的另一端流出很大的空闲区域。这种处理简化了消除碎片的工作,但可能带来性能的损失。

  1. 复制回收算法(Coping Collector)

把堆均分成两个大小相同的区域,只使用其中的一个区域,直到该区域消耗完。此时垃圾回收器终端程序的执行,通过遍历把所有活动的对象复制到另一个区域,复制过程中它们是紧挨着布置的,这样也可以达到消除内存碎片的目的。复制结束后程序会继续运行,直到该区域被用完。
但是,这种方法有两个缺陷:

(1)对于指定大小的堆,需要两倍大小的内存空间,

(2)需要中断正在执行的程序,降低了执行效率

  1. 按代回收算法(Generational Collector)
    为什么要按代进行回收?这是因为不同对象生命周期不同,每次回收都要遍历所有存活对象,对于整个堆内存进行回收无疑浪费了大量时间,对症下药可以提高垃圾回收的效率。主要思路是:把堆分成若搞个子堆,每个子堆视为一代,算法在运行的过程中优先收集“年幼”的对象,如果某个对象经过多次回收仍然“存活”,就移动到高一级的堆,减少对其扫描次数。
  • 6.4 垃圾回收器
  1. 串行回收器(serial collector)

客户端模式的默认回收器,所谓的串行,指的就是单线程回收,回收时将会暂停所有应用线程的执行

  1. 并行回收器

服务器模式的默认回收器,利用多个线程进行垃圾回收,充分利用CPU,回收期间暂停所有应用线程

  1. CMS回收器

停顿时间最短,分为以下步骤:1初始标记;2并发标记;3重新标记;4并发清除。优点是停顿时间短,并发回收,缺点是无法处理浮动垃圾,而且会导致空间碎片产生

  1. G1回收器

新技术,将堆内存划分为多个等大的区域,按照每个区域进行回收。工作过程是1初始标记;2并发标记;3最终标记;4筛选回收。特点是并行并发,分代收集,不会导致空间碎片,也可以由编程者自主确定停顿时间上限

  • 6.5 java性能优化

大多说针对内存的调优,都是针对于特定情况的。但是实际中,调优很难与JAVA运行动态特性的实际情况和工作负载保持一致。也就是说,几乎不可能通过单纯的调优来达到消除GC的目的。

真正影响JAVA程序性能的,就是碎片化。碎片是JAVA堆内存中的空闲空间,可能是TLAB剩余空间,也可能是被释放掉的具有较长生命周期的小对象占用的空间。

下面是一些在实际写程序的过程中应该注意的点,养成这些习惯可以在一定程度上减少内存的无谓消耗,进一步就可以减少因为内存不足导致GC不断。类似的这种经验可以多积累交流:

  1. 减少new对象。每次new对象之后,都要开辟新的内存空间。这些对象不被引用之后,还要回收掉。因此,如果最大限度地合理重用对象,或者使用基本数据类型替代对象,都有助于节省内存;
  2. 多使用局部变量,减少使用静态变量。局部变量被创建在栈中,存取速度快。静态变量则是在堆内存;
  3. 避免使用finalize,该方法会给GC增添很大的负担;
  4. 如果是单线程,尽量使用非多线程安全的,因为线程安全来自于同步机制,同步机制会降低性能。例如,单线程程序,能使用HashMap,就不要用HashTable。同理,尽量减少使用synchronized
  5. 用移位符号替代乘除号。eg:a*8应该写作a<<3
  6. 对于经常反复使用的对象使用缓存;
  7. 尽量使用基本类型而不是包装类型,尽量使用一维数组而不是二维数组;
  8. 尽量使用final修饰符,final表示不可修改,访问效率高
  9. 单线程情况下(或者是针对于局部变量),字符串尽量使用StringBuilder,比StringBuffer要快;
  10. String为什么慢?因为String 是不可变的对象, 因此在每次对 String 类型进行改变的时候其实都等同于生成了一个新的 String 对象,然后将指针指向新的 String 对象。如果不能保证线程安全,尽量使用StringBuffer来连接字符串。这里需要注意的是,StringBuffer的默认缓存容量是16个字符,如果超过16,apend方法调用私有的expandCapacity()方法,来保证足够的缓存容量。因此,如果可以预设StringBuffer的容量,避免append再去扩展容量。如果可以保证线程安全,就是用StringBuilder。

猜你喜欢

转载自blog.csdn.net/u013507760/article/details/113144405
今日推荐