内存模型,JVM内存结构,垃圾回收

JVM 内存结构

根据JVM虚拟机规范,对内存区域做了以下划分:

方法区

方法区存着类的信息,常量和静态变量,即类被编译后的数据。这个说法其实是没问题的,只是太笼统了。更加详细一点的说法是方法区里存放着类的版本,字段,方法,接口和常量池

常量池里存储着编译期间生成的各种字面量和符号引用。符号引用包括:1.类的全限定名,2.字段名和属性,3.方法名和属性。运行时常量池相对于Class文件常量池的另外一个重要特征是具备动态性,运行期间也可以将新的常量放入池中, 这种特性被开发人员利用得比较多的便是String类的intern()方法。

运行时常量池是方法区的一部分, 自然受到方法区内存的限制, 当常量池无法再申请到内存时会抛出OutOfMemoryError异常。
 

堆 Heap:

堆也是属于线程共享的内存区域,它在虚拟机启动时创建,是Java 虚拟机所管理的内存中最大的一块,主要用于存放对象实例(引用数据类型)堆是垃圾收集器GC管理的主要区域。

所有线程共享的Java堆中可以划分出多个线程私有的分配缓冲区,以提升对象分配时的效率。

当前主流Java虚拟机的堆空间都是可扩展的,如果在Java堆中没有内存完成实例分配, 并且堆也无法再扩展时, Java虚拟机将会抛出OutOfMemoryError异常。

虚拟机栈 Stack:

虚拟机栈是线程私有的,它的生命周期与线程相同。虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,描述的是Java方法执行的内存

每个方法在执行的同时都会创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态链接、方法出口等信息。局部变量表存放用来存放方法参数,以及方法内定义的局部变量,其中包括各种基本数据类型、引用对象类型(即存放对象在堆中的地址)和 returnAddress类型(返回地址类型,指向了一条字节码指令的地址)。这些数据类型在局部变量表中的存储空间以局部变量槽(Slot) 来表示, 其中64位长度的long和double类型的数据会占用两个变量槽, 其余的数据类型只占用一个。 局部变量表所需的内存空间在编
译期间完成分配, 当进入一个方法时, 这个方法需要在栈帧中分配多大的局部变量空间是完全确定的, 在方法运行期间不会改变局部变量表的大小。

每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中 入栈到出栈的过程。

程序计数器

属于线程私有的数据区域,是一小块内存空间,主要代表当前线程所执行的字节码行号指示器。字节码解释器工作时,通过改变这个计数器的值来选取下一条需要执行的字节码指令。

本地方法栈

本地方法栈属于线程私有的数据区域,虚拟机栈为虚拟机执行Java方法(也就是字节码) 服务, 而本地方法栈则是为虚拟机使用到的本地(Native)方法服务。

对象的创建过程:

当虚拟机遇到一条字节码new指令时,会先去检查这条指令的参数能否在常量池中定位到一个类的符号引用,并检查这个类是否已被加载过,如果没有,则执行相应的类加载过程。

创建对象:

1. 在堆中给对象划分内存

2. 接下来虚拟机要对对象进行必要的设计,例如这个对象是哪个类的实例,如何才能找到类的元数据信息,对象的哈希吗,对象的GC分代年龄等信息。

3. 此时对象的所有字段值都为零,把对象按照程序员的意愿进行初始化,这样一个对象才算完全生产出来。

垃圾回收

1. 判断对象是否“已死”

1.1 引用计数算法

对象中添加一个引用计数器,如果引用计数器为0则表示没有其它地方在引用它。如果有一个地方引用就+1,引用失效时就-1。

大部分虚拟机中没有采用这种算法,出现的问题:对象间的循环引用。

1.2 可达性分析算法

通过一系列“GC Roots”根对象作为起始节点, 从这些节点开始, 根据引用关系在对象之间建立连接。如果某个对象到GC Roots不可达, 则说明此对象不可能再被使用。

2. 垃圾回收算法

在了解具体的垃圾回收算法之前,先明白堆的分代模型。

2.1 分代收集理论

分代收集质是一套符合大多数程序运行实际情况的经验法则, 它建立在两个分代假说之上:

  • 弱分代假说(Weak Generational Hypothesis) : 绝大多数对象都是朝生夕灭的。
  • 强分代假说(Strong Generational Hypothesis) : 熬过越多次垃圾收集过程的对象就越难以消亡。

基于这两个假说,将堆空间划分成了“新生代”和“老年代”。在新生代中, 每次垃圾收集时都发现有大批对象死去, 而每次回收后存活的少量对象, 将会逐步晋升到老年代中存放。

分代之后有了一个明显额困难:对象不是孤立的, 对象之间会存在跨代引用。于是有了第三条假说:

  • 跨代引用假说(Intergenerational Reference Hypothesis) : 跨代引用相对于同代引用来说仅占极少数。

这条假说可根据前两条假说逻辑推理得出的隐含推论: 存在互相引用关系的两个对象, 是应该倾向于同时生存或者同时消亡的。

垃圾回收分类:

新生代收集(Minor GC/Young GC):只是新生代的垃圾收集
老年代收集(Major GC/Old GC ):只是老年代的垃圾收集
整堆收集(Full GC):收集整个java堆和方法区的垃圾收集。

2.2 垃圾回收算法

2.2.1 标记-清除算法

标记-清除算法分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,标记完成后统一回收所有被标记的对象。这种算法的不足主要体现在效率和空间,从效率的角度讲,标记和清除两个过程的效率都不高;从空间的角度讲,标记清除后会产生大量不连续的内存碎片, 内存碎片太多可能会导致以后程序运行过程中在需要分配较大对象时,无法找到足够的连续内存而不得不提前触发一次垃圾收集动作。

2.2.2 标记-复制算法:

也称为复制算法。复制算法是为了解决效率问题而出现的,它将可用的内存分为两块,每次只用其中一块,当这一块内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已经使用过的内存空间一次性清理掉。这样每次只需要对整个半区进行内存回收,内存分配时也不需要考虑内存碎片等复杂情况,只需要移动指针,按照顺序分配即可。

针对具备“朝生夕灭”特点的对象, 提出了一种更优化的半区复制分代策略, 现在称为“Appel式回收”。

Appel式回收的具体做法是把新生代分为一块较大的Eden空间和两块较小的Survivor空间, 每次分配内存只使用Eden和其中一块Survivor。 发生垃圾搜集时, 将Eden和Survivor中仍然存活的对象一次性复制到另外一块Survivor空间上, 然后直接清理掉Eden和已用过的那块Survivor空间(Eden和Survivor的大小比例是8∶1)。

如果Survivor空间已满,会有老年代 分配担保,放不下的对象会被放入老年代。

如果反复复制多次之后对象仍然存活,则该对象将会被移至老年代。

回收垃圾的流程:

1、新建的对象,大部分存储在Eden中
2、发生垃圾收集时,就进行Minor GC释放掉不活跃对象;然后将部分活跃对象复制到Survivor中(如Survivor1),同时清空Eden区.
3、再次发生垃圾收集时,将Survivor1中不能清除的对象存放到另一个Survivor中(如Survivor0),同时将Eden区中的不能清空的对象,复制到Survivor1,清空Eden区。
4、重复多次(默认15次):Survivor中没有被清理的对象就会复制到老年区(Old)
5、当Old达到一定比例,则会触发Major GC释放老年代
6、当Old区满了,则触发一个一次完整的垃圾回收(Full GC)
7、如果内存还是不够,JVM会抛出内存不足,发生oom,内存泄漏。

2.2.3 标记-整理算法

是一种针对老年代的回收算法。

标记-复制算法在对象存活率较高时就要进行较多的复制操作, 效率将会降低。 更关键的是, 如果不想浪费50%的空间, 就需要有额外的空间进行分配担保, 以应对被使用的内存中所有对象都100%存活的极端情况, 所以在老年代一般不能直接选用这种算法。

标记-整理算法和标记-清除算法类似,不过不是直接对可回收对象进行清理,而是让所有存活对象都向一端移动,然后直接清理掉边界以外的内存。

3. 常见的垃圾收集器

3.1 CMS收集器

CMS基于标记-清除算法,暂时容忍内存碎片的存在, 直到内存空间的碎片化程度已经大到影响对象分配时, 再采用标记-整理算法收集一次, 以获得规整的内存空间。
CMS是一种并发收集器,让垃圾收集线程与用户线程同时运行。

CMS只会回收老年代和永久代(1.8开始为元数据区),不会收集年轻代;年轻代只能配合Parallel New或Serial回收器;

3.2 G1收集器

G1,即 Garbage-First。基于标记-复制算法,原理是将整个堆空间划分为一块块空间(Region),对各个Region的回收价值和成本进行排序,根据用户所期望的GC停顿时间来制定回收计划。可以理解为,排序后,哪块Region的垃圾多,就优先清除哪块。缩小了回收垃圾时的停顿时间。

虽然G1可以不需要其他收集器配合就能独立管理整个GC堆,但是还是保留了分代的概念。

Java 内存模型概述

Java内存模型(即Java Memory Model,简称JMM)本身是一种抽象的概念,并不真实存在,它描述的是一组规则或规范。

计算机在执行 Java 程序时,所有指令都是在 JVM 中被执行,JVM执行指令也可以看作是 cpu 处理数据。

但是随着cpu的发展,内存的读写速度也远远赶不上cpu。因此cpu厂商在每颗cpu上加上高速缓存,用于缓解这种情况。在cpu处理数据之前,先将要处理的数据加载到工作内存中,再对数据进行处理。

Java 内存模型中涉及到的概念有:(主内存和工作内存是逻辑上的概念,如果硬要往物理方面靠,工作内存就相当于cpu的高速缓存,主内存就相当于内存条)

  • 主内存:java虚拟机规定所有的变量都必须在主内存中产生。主内存是整个机器的内存,而虚拟机的内存是主内存中的一部分。
  • 工作内存:java虚拟机中每个线程都有自己的工作内存,该内存是线程私有的。虚拟机规定,线程对主内存变量的修改必须在线程的工作内存中进行,不能直接读写主内存中的变量。不同的线程之间也不能相互访问对方的工作内存。如果线程之间需要传递变量的值,必须通过主内存来作为中介进行传递。

 

存在的问题:

cpu上加入了高速缓存这样做解决了处理器和内存的矛盾(一快一慢),但是引来的新的问题 —— 缓存一致性。

当多个cpu上的线程对主存中的同一个共享变量进行读取时,这个变量就会被缓存到每个线程的 工作内存中,变量被修改后,什么时候写入主存是不确定的,可能其他线程去主存中读取这个变量时,还是原来的旧值。

解决方法:

  • 给变量加锁 (阻碍高并发,程序效率低)
  • 使用volatile修饰变量

volatile 修饰的变量

  • valatile类型的变量保证对所有线程的可见性

可见性指的是当一个线程A修改了某个共享变量的值,线程B能够马上得知这个修改的值,即使这个变量已经被线程B加载到了自己的工作内存中。对于串行程序来说,可见性是不存在的,因为我们在任何一个操作中修改了某个变量的值,后续的操作中都能读取这个变量值,并且是修改过的新值。但在多线程环境中可就不一定了,由于线程对共享变量的操作都是线程拷贝到各自的工作内存进行操作后才写回到主内存中的,这就可能存在一个线程A修改了共享变量x的值,还未写回主内存时,另外一个线程B又对主内存中同一个共享变量x进行操作,但此时A线程工作内存中共享变量x对线程B来说并不可见,这种工作内存与主内存同步延迟现象就造成了可见性问题

volatile保证数据的可见性,不保证原子性;synchronized保证数据的可见性和原子性;

注意:valatile 只是保证它的值一旦被修改,其他线程就能立马读取到(对volatile变量的所有写操作总是能立刻反应到其他线程中)。

public class Test {
    public static volatile int count = 0;
    public static void main(String[] args) {
        for(int i =0;i<10;i++){
            new Thread(new Runnable() {
                @Override
                public void run() {
                    for(int j=0;j<10000;j++){
                        count++;
                        System.out.println(count);
                    }
                }
            }).start();
        }
    }
}

程序运行结果为:(最后输出的数字 不为100000)

上述例子中,count变量的任何改变都会立马反应到其他线程中,但是如此存在多条线程同时进行,就会出现线程安全问题,毕竟count++;操作并不具备原子性,该操作是先读取值,对值进行自增,然后写回一个新值,分三步完成,如果第二个线程在第一个线程读取旧值和写回新值期间读取i的域值,那么第二个线程就会与第一个线程一起看到同一个值,并执行相同值的加1操作,这也就造成了线程安全问题。因此对于这种情况必须使用synchronized修饰,以便保证线程安全。

对变量的修改,或者对变量的运算,却不能保证是原子性的。即一次操作分解为多个子操作 有可能存在覆盖的情况。
 

  • volatile变量禁止指令重排序优化

为了提高执行效率,编译器和 JVM 会优化和调整语句的执行顺序。

在单线程内部,我们看到的执行结果和代码顺序是一致的,但在多线程中,就可能出现代码的执行顺序和代码顺序不一致的情况。

public static volatile boolean flag = false;
    public static int number = 0;
    public static void main(String[] args) {
        // 线程1
        new Thread(new Runnable() {
            @Override
            public void run() {
                number++;
                flag = true;
            }
        }).start();
        // 线程2
        new Thread(new Runnable()){
            @Override
            public void run() {
                if(flag){
                    config(number);
                }
            }
        }.start();
    }
        

如果 flag 是普通变量,则在线程1有可能在程序重排序后,flag会先被赋值 true ,再执行线程中的其他程序。而线程2在判断 flag为true后,会对number进行一些配置。这就导致了一个问题:线程 2 配置时,其中的参数可能还未初始化。

(这个简单的程序用于举例说明重排序是怎么回事,因为程序指令太少,跑起来后不会真正重排序)

 

 

 

已标记关键词 清除标记
©️2020 CSDN 皮肤主题: 深蓝海洋 设计师:CSDN官方博客 返回首页