JVM 메모리 영역
우리는 프로그램을 작성하다 보면 OOM(Out of Memory), 메모리 누수(Memory Leak) 등의 문제를 자주 접하게 됩니다. 이러한 문제를 피하기 위해서는 먼저 JVM의 메모리 분할에 대한 구체적인 이해가 필요합니다. JVM은 주로 메모리를 메소드 영역, 가상 머신 스택, 로컬 메소드 스택, 힙 및 프로그램 카운터로 나눕니다. JVM 런타임 데이터 영역은 다음과 같습니다.
프로그램 카운터
프로그램 카운터는 스레드 전용 영역입니다. 각 스레드에는 현재 실행되는 명령어를 기록하는 하나의 A 카운터가 있어야 합니다. 작은 메모리 공간을 차지하며 현재 스레드에서 실행되는 바이트코드의 줄 번호 표시기로 간주될 수 있습니다. 스레드가 Java 메서드를 실행하는 경우 이 카운터는 실행 중인 가상 머신 바이트코드 명령의 주소를 기록합니다. 스레드가 기본 메서드를 실행하는 경우 이 카운터의 값은 비어 있습니다(정의되지 않음). 이 메모리 영역은 Java Virtual Machine 사양에서 OutOfMemoryError 조건을 지정하지 않는 유일한 영역입니다.
Java Virtual Machine 스택
프로그램 카운터와 마찬가지로 Java Virtual Machine 스택도 스레드 전용입니다. 수명주기는 스레드의 수명주기와 동일합니다. 가상 머신 스택을 이해하는 방법은 무엇입니까? 기본적으로 스택입니다. 내부에 저장된 요소를 스택 프레임이라고 합니다. 스택 프레임은 매우 복잡해 보이지만 실제로는 매우 간단합니다! 함수의 컨텍스트를 저장하고, 특히 실행된 함수의 일부 데이터를 저장합니다. 실행된 함수에 필요한 데이터는 지역 변수 테이블(함수 내부에 변수 저장), 피연산자 스택(엔진 계산 실행 시 필요), 메서드 종료 등에 지나지 않습니다.
실행 엔진이 함수를 호출할 때마다 이 함수에 대한 스택 프레임을 생성하고 이를 가상 머신 스택에 추가합니다. 다른 관점에서 이해하자면, 각 함수는 실제로 호출부터 실행 끝까지 스택 프레임의 푸시 및 팝에 해당합니다.
이 영역에서 발생할 수 있는 두 가지 예외에 주의하세요. 하나는 StackOverflowError입니다. 이 예외는 현재 스레드에서 요청한 스택 깊이가 가상 머신에서 허용하는 깊이보다 클 때 발생합니다. 이런 종류의 예외를 생성하는 것은 간단합니다. 함수 자체를 반복적으로 반복하면 결국 스택 오버플로 오류(StackOverflowError)가 발생합니다. 또 다른 예외는 OutOfMemoryError 예외입니다. 가상 머신 스택을 동적으로 확장할 수 있는 경우(현재 대부분의 가상 머신에서 가능), 충분한 메모리를 적용할 수 없으면 OutOfMemoryError가 발생합니다. 코드를 참조하세요. :
public void stackLeakByThread(){ while(true){ new Thread(){ public void run(){ while(true){ } } }.start() } }
이 코드는 위험하므로 운영체제가 정지될 수 있으니 주의해서 사용하세요~~
로컬 메서드 스택
로컬 메서드 스택과 가상 머신 스택이 수행하는 기능은 매우 유사합니다. 차이점은 가상 머신 스택이 Java 코드 실행을 위한 메소드를 제공하는 반면 로컬 메소드 스택은 기본 메소드를 제공한다는 것입니다. 가상 머신 스택과 마찬가지로 기본 메서드 스택도 StackOverflowError 및 OutOfMemoryError 예외를 발생시킵니다.
Java 힙
Java 힙은 가상머신에서 가장 큰 메모리라고 할 수 있습니다. 모든 스레드가 공유하는 메모리 영역으로 거의 모든 인스턴스 객체가 이 영역에 저장된다. 물론 JIT 컴파일러가 개발되면서 힙에 있는 모든 개체의 할당은 점차 덜 "절대적"이 됩니다.
Java 힙은 가비지 컬렉터가 관리하는 주요 영역입니다. 현재 수집기는 기본적으로 세대별 수집 알고리즘을 사용하므로 모든 Java 힙은 다음 세대와 이전 세대로 세분화될 수 있습니다. 세부적으로 신세대는 Eden 공간, From Survivor 공간, To Survivor 공간으로 구분됩니다. 힙을 더 이상 확장할 수 없으면 OutOfMemoryError 예외가 발생합니다.
메서드 영역
메서드 영역에는 클래스 정보, 상수, 정적 변수 등이 저장됩니다. 메소드 영역은 각 스레드가 공유하는 영역으로, Java 코드를 작성할 때 각 스레드 수준에서 동일한 클래스의 정적 변수 개체에 액세스할 수 있다는 점을 이해하기 쉽습니다. 반사 메커니즘을 사용하기 때문에 어떤 클래스 정보가 더 이상 사용되지 않는지 가상 머신이 추측하기 어렵기 때문에 이 영역을 재활용하기가 어렵습니다. 또한 이 영역은 주로 상수 풀 재활용을 위한 것입니다. JDK1.7이 상수 풀을 힙으로 이동했다는 점은 주목할 가치가 있습니다. 마찬가지로, 메서드 영역이 메모리 할당 요구 사항을 충족할 수 없으면 OutOfMemoryError가 발생합니다.
메소드 영역 메모리 오버플로 생성. 메소드 영역 오버플로는 JDK1.6 이하 버전에서 발생해야 합니다. 이유는 실행하기 전에 매개변수를 설정하여 메소드 영역의 크기를 제한할 수 있습니다. 가상 머신 -XXpermSize 및 -XX:MaxPermSize.
List list =new ArrayList(); int i =0;while(true){ list.add(String.valueOf(i).intern()); }
실행 후 java.lang.OutOfMemoryError:PermGen 공간 예외가 발생합니다.
String의 intern() 함수는 현재 문자열이 상수 풀에 존재하지 않는 경우 이를 상수 풀에 넣는 것이라고 설명합니다. 위의 코드는 상수 풀에 문자열을 지속적으로 추가하는데, 이로 인해 결국 메모리가 부족해지고 메서드 영역에 OOM이 발생하게 됩니다.
다음은 JDK1.6 이전에 위 코드를 실행해야 하는 이유를 설명합니다. 앞서 JDK 1.7 이후에는 상수 풀이 힙 공간에 배치되어 intern() 함수의 기능이 달라졌다고 언급했습니다. 구체적으로 다음 코드를 살펴보겠습니다.
String str1 =new StringBuilder("hua").append("chao").toString(); System.out.println(str1.intern()==str1); String str2=new StringBuilder("ja").append("va").toString(); System.out.println(str2.intern()==str2);
这段代码在JDK1.6和JDK1.7运行的结果不同。JDK1.6结果是:false,false ,JDK1.7结果是true, false。原因是:JDK1.6中,intern()方法会吧首次遇到的字符串实例复制到常量池中,返回的也是常量池中的字符串的引用,而StringBuilder创建的字符串实例是在堆上面,所以必然不是同一个引用,返回false。在JDK1.7中,intern不再复制实例,常量池中只保存首次出现的实例的引用,因此intern()返回的引用和由StringBuilder创建的字符串实例是同一个。为什么对str2比较返回的是false呢?这是因为,JVM中内部在加载类的时候,就已经有”java”这个字符串,不符合“首次出现”的原则,因此返回false。
垃圾回收(GC)
JVM的垃圾回收机制中,判断一个对象是否死亡,并不是根据是否还有对象对其有引用,而是通过可达性分析。对象之间的引用可以抽象成树形结构,通过树根(GC Roots)作为起点,从这些树根往下搜索,搜索走过的链称为引用链,当一个对象到GC Roots没有任何引用链相连时,则证明这个对象是不可用的,该对象会被判定为可回收的对象。
那么那些对象可作为GC Roots呢?主要有以下几种:
1.虚拟机栈(栈帧中的本地变量表)中引用的对象。
2.方法区中类静态属性引用的对象。
3.方法区中常量引用的对象
4.本地方法栈中JNI(即一般说的Native方法)引用的对象。
另外,Java还提供了软引用和弱引用,这两个引用是可以随时被虚拟机回收的对象,我们将一些比较占内存但是又可能后面用的对象,比如Bitmap对象,可以声明为软引用货弱引用。但是注意一点,每次使用这个对象时候,需要显示判断一下是否为null,以免出错。
三种常见的垃圾收集算法
1.标记-清除算法
首先,通过可达性分析将可回收的对象进行标记,标记后再统一回收所有被标记的对象,标记过程其实就是可达性分析的过程。这种方法有2个不足点:效率问题,标记和清除两个过程的效率都不高;另一个是空间问题,标记清除之后会产生大量的不连续的内存碎片。
2.复制算法
为了解决效率问题,复制算法是将内存分为大小相同的两块,每次只使用其中一块。当这块内存用完了,就将还存活的对象复制到另一块内存上面。然后再把已经使用过的内存一次清理掉。这使得每次只对半个区域进行垃圾回收,内存分配时也不用考虑内存碎片情况。
但是,这代价实在是让人无法接受,需要牺牲一般的内存空间。研究发现,大部分对象都是“朝生夕死”,所以不需要安装1:1比例划分内存空间,而是将内存分为一块较大的Eden空间和两块较小的Survivor空间,每次使用Eden空间和一块Survivor空间,默认比例为Eden:Survivor=8:1.新生代区域就是这么划分,每次实例在Eden和一块Survivor中分配,回收时,将存活的对象复制到剩下的另一块Survivor。这样只有10%的内存会被浪费,但是带来的效率却很高。当剩下的Survivor内存不足时,可以去老年代内存进行分配担保。如何理解分配担保呢,其实就是,内存不足时,去老年代内存空间分配,然后等新生代内存缓过来了之后,把内存归还给老年代,保持新生代中的Eden:Survivor=8:1.另外,两个Survivor分别有自己的名称:From Survivor、To Survivor。二者身份经常调换,即有时这块内存与Eden一起参与分配,有时是另一块。因为他们之间经常相互复制。
3.标记-整理算法
标记整理算法很简单,就是先标记需要回收的对象,然后把所有存活的对象移动到内存的一端。这样的好处是避免了内存碎片。
类加载机制
类从被加载到虚拟机内存开始,到卸载出内存为止,整个生命周期包括:加载、验证、准备、解析、初始化、使用和卸载七个阶段。
其中加载、验证、准备、初始化、和卸载这5个阶段的顺序是确定的。而解析阶段不一定:它在某些情况下可以在初始化阶段之后再开始,这是为了支持Java的运行时绑定。
关于初始化:JVM规范明确规定,有且只有5中情况必须执行对类的初始化(加载、验证、准备自然再此之前要发生):
1.遇到new、getstatic、putstatic、invokestatic,如果类没有初始化,则必须初始化,这几条指令分别是指:new新对象、读取静态变量、设置静态变量,调用静态函数。
2.使用java.lang.reflect包的方法对类进行反射调用时,如果类没初始化,则需要初始化
3.当初始化一个类时,如果发现父类没有初始化,则需要先触发父类初始化。
4.当虚拟机启动时,用户需要制定一个执行的主类(包含main函数的类),虚拟机会先初始化这个类。
5.但是用JDK1.7启的动态语言支持时,如果一个MethodHandle实例最后解析的结果是REF_getStatic、REF_putStatic、Ref_invokeStatic的方法句柄时,并且这个方法句柄所对应的类没有进行初始化,则要先触发其初始化。
另外要注意的是:通过子类来引用父类的静态字段,不会导致子类初始化:
public class SuperClass{ public static int value=123; static{ System.out.printLn("SuperClass init!"); } }public class SubClass extends SuperClass{ static{ System.out.println("SubClass init!"); } }public class Test{ public static void main(String[] args){ System.out.println(SubClass.value); } }
最后只会打印:SuperClass init!
对应静态变量,只有直接定义这个字段的类才会被初始化,因此通过子类类引用父类中定义的静态变量只会触发父类初始化而不会触发子类初始化。
通过数组定义来引用类,不会触发此类的初始化:
public class Test{ public static void main(String[] args){ SuperClass[] sca=new SuperClass[10]; } }
常量会在编译阶段存入调用者的常量池,本质上并没有直接引用到定义常量的类,因此不会触发定义常量的类初始化,示例代码如下:
public class ConstClass{ public static final String HELLO_WORLD="hello world"; static { System.out.println("ConstClass init!"); } }public class Test{ public static void main(String[] args){ System.out.print(ConstClass.HELLO_WORLD); } }
上面代码不会出现ConstClass init!
加载
加载过程主要做以下3件事
1.通过一个类的全限定名称来获取此类的二进制流
2.强这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
3.在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据访问入口。
验证
这个阶段主要是为了确保Class文件字节流中包含信息符合当前虚拟机的要求,并且不会出现危害虚拟机自身的安全。
准备
准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些变量所使用的内存都在方法区中分配。首先,这个时候分配内存仅仅包括类变量(被static修饰的变量),而不包括实例变量。实例变量会在对象实例化时随着对象一起分配在java堆中。其次这里所说的初始值“通常情况下”是数据类型的零值,假设一个类变量定义为
public static int value=123;
那变量value在准备阶段后的初始值是0,而不是123,因为还没有执行任何Java方法,而把value赋值为123是在程序编译后,存放在类构造函数()方法中。
解析
解析阶段是把虚拟机中常量池的符号引用替换为直接引用的过程。
初始化
类初始化时类加载的最后一步,前面类加载过程中,除了加载阶段用户可以通过自定义类加载器参与以外,其余动作都是虚拟机主导和控制。到了初始化阶段,才是真正执行类中定义Java程序代码。
准备阶段中,变量已经赋过一次系统要求的初始值,而在初始化阶段,根据程序员通过程序制定的主观计划初始化类变量。初始化过程其实是执行类构造器()方法的过程。
()方法是由编译器自动收集类中所有类变量的赋值动作和静态语句块中的语句合并产生的。收集的顺序是按照语句在源文件中出现的顺序。静态语句块中只能访问定义在静态语句块之前的变量,定义在它之后的变量可以赋值,但不能访问。如下所示:
public class Test{ static{ i=0;//給变量赋值,可以通过编译 System.out.print(i);//这句编译器会提示:“非法向前引用” } static int i=1; }
()方法与类构造函数(或者说实例构造器())不同,他不需要显式地调用父类构造器,虚拟机会保证子类的()方法执行之前,父类的()已经执行完毕。
相关文章: