写点什么

对象的内存分配一定都是在堆空间吗?

  • 2022 年 7 月 13 日
  • 本文字数:3879 字

    阅读完需:约 13 分钟

对象的内存分配一定都是在堆空间吗?

一、背景

想通过这篇文章 介绍一些常见的代码优化的方法。

首先引入两个问题

1、对象的内存分配一定都是在堆空间上么?

2、OOM 到底是如何发生的,是什么样原因会导致内存不足?

开始之前我们先来看下 JVM 的整体结构,想必大家都不陌生。如下图:


JVM 结构很丰富今天主要展开分析其中的一块“堆”

二、认识堆

• ⼀个 JVM 实例只存在⼀个堆内存

• 堆在 jvm 启动的时候被创建,⼤⼩(可调节)也就确定了,堆是 JVM 管理的最⼤(最重要)的⼀块内存空间

• 所有的线程都是共享堆,但是还可以划分线程私有的缓冲区(TLAB) 后⾯会说到

•《Java 虚拟机规范》中对 java 堆的描述是:所有的对象实例以及数组都应当在运⾏时分配在堆上

• 数组和对象可能???永远不会存储在栈上,因为栈帧中保存引⽤,这个引⽤指向对象或者数据在堆中的位置

如下图 是⼀个对象在 JVM 中的⼀个粗略分布:

  • 堆内存细分(分代收集):

Java 7 及之前 新⽣代+⽼年代+永久代 

Java 8 及之后 新⽣代+⽼年代+元空间


三、设置堆内存大小

-Xms 起始内存(年轻代+⽼年代) 等价于 -XX:InitialHeapSize  

-X 是 jvm 的运⾏参数  

ms 是 memory start  

-Xmx 最⼤内存 (年轻代+⽼年代) 等价于 -XX:MaxHeapSize


https://docs.oracle.com/javase/8/docs/technotes/tools/unix/java.html

有了堆内存的大小 也就是同时会有 OOM 的产生,下面举一个比较简单的 OOM 的例子:



public class OOMTest { public static void main(String[] args) { ArrayList<OOM> list = new ArrayList<>(); while (true) { list.add(new OOM(new Random().nextInt(1024 * 1024))); } }}
class OOM { private byte[] array;
public OOM(int length) { this.array = new byte[length]; }}
复制代码

四、堆内存的内部结构

其主要分为年轻代(eden,s0,s1)和老年代,如下图:

简单介绍下堆内存中一些参数设置:

五、对象在对内存的分配过程

1. new 的对象先放到 eden 区

2. 当 eden 区满的时候,JVM 会对 eden 区进⾏垃圾回收(minor GC)将 eden 中不再被对象所引⽤的对象销毁

(可达性分析算法算法)在加载新的对象到 eden

3. 然后将 eden 中存活下来的对象转移到 s0 区

4. 如果再次触发垃圾回收 上次放到 s0 的对象依然没有被回收,就会放到 s1 区

5. 再次垃圾回收 此时会重新放回 s0 接着再去 s1

6. 什么时候去⽼年代 ?可以设置次数,默认是 15 次(这个其实就是 对象的年龄)

-XX:MaxTenuringThreshold=15 进⾏设置

7. ⽼年代当内存不⾜时 再次触发 GC : MajorGC

8. 如果⽼年代执⾏MajorGC 之后还是没办法保存新的对象,就是产⽣OOM

详细可看下图:


六、分配策略

其实从上图也⼤概能分析出⼤概的策略

1.优先分配到 Eden

2.⼤对象直接分配到⽼年代(朝⽣⼣死的⼤对象会导致频繁的 Full GC)

3.⻓期存活的对象分配到⽼年代

4.特殊分配策略-TLAB,到底什么是 TLAB??

•我们程序中的线程 JVM 会为每个线程分配⼀个私有的缓存区域,包含在 Eden 空间内

•多线程同时分配内存时 会使⽤TLAB 来避免⼀系列的⾮线程安全问题,同时提升内存分配的吞吐量,这种分配内存的⽅式叫快速分配策略

•OpenJDK 衍⽣出来的 jvm 都提供了 TLAB 的设计

•可以通过-XX:+UseTLAB 设置开启 TLAB 空间

•默认情况下 TLAB 空间的内存⾮常⼩,仅占有整个 Eden 的 %1,可以通过选项-

XX:TLABWasteTargetPercent 设置 TLAB 空间所占⽤Eden 空间的百分⽐⼤⼩

下图是说明 TLAB 分配策略是如何⼯作的:

七、分配时的特别情况

7.1 逃逸分析

•当⼀个对象在⽅法中被定义后,对象只在⽅法内部使⽤,则认为没有发⽣逃逸(栈上分配)

•当⼀个对象在⽅法中被定义后,他被外部⽅法所引⽤,则认为发⽣逃逸

//发生了逃逸public static StringBuffer createStringBuffer(String s1, String s2) {    StringBuffer sb = new StringBuffer();    sb.append(s1);    sb.append(s2);    return sb;}
复制代码

上面代码如果要 StringBuffer sb 不逃出方法 可以这样写



public static String createStringBuffer(String s1, String s2) { StringBuffer sb = new StringBuffer(); sb.append(s1); sb.append(s2); return sb.toString();}
复制代码

逃逸分析示例

/** * 如果快速判断是否发生逃逸分析 * 1、new的对象实体是否有可能在方法外被调用 */public class EscapeAnalysis {
public EscapeAnalysis obj;
/** * 方法返回EscapeAnalysis对象, 发生逃逸 * * @return */ public EscapeAnalysis getInstance() { return obj == null ? new EscapeAnalysis() : obj; }
/** * 为成员属性赋值,发生逃逸 */ public void setObj() { this.obj = new EscapeAnalysis(); }
/** * 对象的作用域仅在当前方法中有效,没有发生逃逸 */ public void useEscapeAnalysis() { EscapeAnalysis es = new EscapeAnalysis(); }
/** * 引用成员变了的值 发生逃逸 */ public void useEscapeAnalysis1() { EscapeAnalysis es = getInstance(); }
}
复制代码


•JDK7 及以后版本 jvm 默认开启了逃逸分析

•可以通过-XX:+DoEscapeAnalysis 显⽰开启逃逸分析

•通过-XX:+PrintEscapeAnalysis 查看逃逸分析的筛选结果


八、代码优化
8.1 栈上分配


基于逃逸分析


如果一个对象没有逃逸出方法就可能被优化成栈上分配, 最后线程结束,栈空间被回收,局部变量对象也被回收,这样可以减少垃圾回收

•常⻅的栈上分配场景

逃逸分析

•栈上分配测试

这⾥我们做⼀个测试 ,下⾯同⼀段代码我们通过开启和关闭逃逸分析之后来看下执⾏效率

/** * -Xms1G -Xmx1G -XX:-DoEscapeAnalysis  -XX:+PrintGCDetails */public class StackAllocation {    public static void main(String[] args) {        long start = System.currentTimeMillis();        for (int i = 0; i < 10000000; i++) {            alloc();        }        long end = System.currentTimeMillis();        System.out.println(end - start);        try {            Thread.sleep(1000000);        } catch (InterruptedException e) {            e.printStackTrace();        }    }
private static void alloc() { User user = new User();//未发生逃逸 }
static class User {
}}
复制代码


先使用参数:-Xms256M -Xmx256M -XX:-DoEscapeAnalysis  -XX:+PrintGCDetails 运行效果如下耗时 54ms 并且发生了两次 GC:

下⾯使⽤参数:-Xms256M -Xmx256M -XX:+DoEscapeAnalysis -XX:+PrintGCDetails 运⾏效果如下

耗时 5ms, 并且没有发⽣GC


8.2 同步省略

如果同⼀个对象被发现只能从⼀个线程被访问到,那么对于这个对象的操作可以不考虑同步。


JIT 编译器可以借助逃逸分析来判断同步块所使⽤的锁对象是否只能被⼀个线程访问⼆没有被发布到其他 线程。如果没有,那么 JIT 编译器在编译这个同步块的时候就会取消对这部分代码的同步,从⽽提⾼并发性和 性能,这个取消同步的过程叫同步省略,也叫锁消除。


例如:


public void f() { Object o = new Object(); synchronized (o) { System.out.println(o); }}
复制代码

代码中队 o 进行加锁,但是 o 对象的生命周期只在 f 方法中,并不会被其他线程锁访问到,所以会被编译器优化成:


public void f() { Object o = new Object(); System.out.println(o);}
复制代码
8.3 分离对象或标量替换

有的对象可能不需要作为一个连续的内存结构存在也可以被访问到,那么对象的部分(或全部)可以不存储在内存,而是存储在 CPU 寄存器中

在 jIT 阶段,如果经过逃逸分析,发现一个对象不会被外界访问的话, 那么经过 JIT 优化,就会把这个对象拆解成若干个其中包含的若干个成员变量来代替,这个过程就是变量替换



public static void main(String[] args) { } private static void alloc(){ Point point= new Point(1,2); System.out.println(point.x;point.y); }}class Point{ private int x; private int y;}
复制代码

替换之后


private static void alloc() {int x =1;;int y =2; System.out.println(x;y);}
复制代码



九、结语

通过上⾯分析结束 简单回答下开始的问题

1、对象的内存分配不⼀定都是在堆上,未发⽣逃逸的对象内存其实是分配在栈上的。

2、OOM 的根本原因 可以通过内存分配策略看出 其实总结就是堆内存空间不够,但是细分的话还是有很多情。

况, 可通过这些具体的情况来优化代码 尽量减少堆内存的占⽤ 防⽌OOM 的情况发⽣。

其实堆内存远不⽌这点东西,加上垃圾回收的话 还有很多,开发的空间还很⼤,欢迎⼤家指点批评。


关于领创集团(Advance Intelligence Group)

领创集团成立于 2016 年,致力于通过科技创新的本地化应用,改造和重塑金融和零售行业,以多元化的业务布局打造一个服务于消费者、企业和商户的生态圈。集团旗下包含企业业务和消费者业务两大板块,企业业务包含 ADVANCE.AI 和 Ginee,分别为银行、金融、金融科技、零售和电商行业客户提供基于 AI 技术的数字身份验证、风险管理产品和全渠道电商服务解决方案;消费者业务 Atome Financial 包括亚洲领先的先享后付平台 Atome 和数字金融服务。2021 年 9 月,领创集团宣布完成超 4 亿美元 D 轮融资,融资完成后领创集团估值已超 20 亿美元,成为新加坡最大的独立科技创业公司之一。


往期回顾 BREAK AWAY

Spring data JPA 实践和原理浅析

如何解决海量数据更新场景下的 Mysql 死锁问题

企业级 APIs 安全实践指南 (建议初中级工程师收藏)

Cypress UI 自动化测试框架

serverless让我们的运维更轻松


发布于: 刚刚阅读数: 4
用户头像

智慧领创美好生活 2021.08.12 加入

AI技术驱动的科技集团,致力于以技术赋能为核心,通过科技创新的本地化应用,改造和重塑金融和零售行业,以多元化的业务布局打造一个服务于消费者、企业和商户的生态圈,带来个性化、陪伴式的产品服务和优质体验。

评论

发布
暂无评论
对象的内存分配一定都是在堆空间吗?_代码优化_领创集团Advance Intelligence Group_InfoQ写作社区