您的位置:首页 > 其它

JVM内存结构图解 (四)

2017-02-09 17:06 204 查看

四 数据类型占用空间分析

  操作数栈:long和double需要占用2个栈深单位(unit of depth),其它类型占用1个栈深单位。

  局部变量表:long和double需要占用2个局部变量空间(slot),其它类型占用1个局部变量空间。

  运行时常量池:byte、short和int被存储为CONSTANT_Integer_info 结构;float被存储为CONSTANT_Float_info 结构;long被存储为CONSTANT_Long_info 结构;double被存储为 CONSTANT_Double_info 结构。其中,long 和 double占用8个字节,byte、short、int和float占用4个字节。

  虽然运行时常量池中占用空间并没有进一步细分,但保存的数据结构中会标记数据类型,byte被标记为B,int 被标记为I……

  Java堆:虽然《Java虚拟机规范》中并没有明确说明基本数据类型的空间占用,但根据我对JIT编译生成的汇编代码分析,byte占用一个字节,short占用2个字节,float和int占用4个字节,long 和 double占用8个字节。

  测试方法:声明byte[],顺序写入索引0、索引1、索引2、索引3的元素。运行时开启JIT编译,查看得到的汇编代码中你会发现内存地址变化正如上面所说。

示例Java代码

byte[] array = new byte[4];

  array[0] = 0;

  array[1] = 1;

  array[2] = 2;

  array[3] = 3;

关键汇编代码:

 

  0xa726a086: jne  0xa726a07d     ;*newarray检测zf标志位:1顺序执行下一条指令;0跳转到0xa726a07d处指令

  ;eax寄存器中保存的是数组的起始内存地址。0xc(%eax):基址eax + 偏移12。

  ;32位JVM中,数组对象使用12个字节记录两项信息:数组长度4字节 + 数组对象头8字节 = 12字节(0x0 至 0xb),所以保存数据的起始地址是0xc。

  0xa726a088: movb  $0x0,0xc(%eax)  ;*bastore将0写入0xc偏移位置

  0xa726a08c: movb  $0x1,0xd(%eax)  ;*bastore将1写入0xd偏移位置

  0xa726a090: movb  $0x2,0xe(%eax)  ;*bastore将2写入0xd偏移位置
  0xa726a094: movb  $0x3,0xf(%eax)  ;*bastore将3写入0xd偏移位置

五 递归优化

㈠ 栈溢出

  根据第三节图例,JVM每执行每一个方法都会创建一层新的栈帧,当方法结束,那么栈帧就会销毁。

  方法1调用方法2,方法2调用方法3……方法i-1调用方法i,因为每一个方法都没结束,那么最后会创建i层栈帧。

  JVM中的虚拟机栈的空间大小可以通过参数配置,但如果方法嵌套调用链过长导致栈空间耗尽,那么就会发生栈溢出(StackOverflowError)。

㈡ 递归注意事项

  正常程序一般不会导致栈溢出,但递归方法需要特别注意。

  因为递归方法本身既是调用者又是被调用者,每一次方法执行时被调用者又会成为调用者而没有结束,所以栈帧不会被销毁,而是会一层一层累加。

  虽然如此,很多时候依然会倾向于使用递归,但使用递归方法应注意以下几点:

  1、一定要设定退出条件(无需递归即可直接求解的基准情况)。

  2、避免在递归中反复求解。

  3、避免在递归方法中嵌套递归方法。

  4、避免在递归中创建大对象。

㈢ 错误示例及优化

错误示例1(无退出条件):

public static void getAndSet(){

  Object obj = get();

  set(obj);

  getAndSet();

}

正确方式:

public static void getAndSet(){

  Object obj = get();

  if(null != obj){

    set(obj);

    getAndSet();

  }

}

如果实在没办法判断退出条件,可以这样:

public static void getAndSet(){

  for( ; ; ){

    Object obj = get();

    set(obj);

  }

}

错误示例2(反复求解):

/**计算斐波那契数列

 * 0,1,1,2,3,5,8,13

 * 为了计算第7个数,必须先计算第6个;为了计算第6个,先得计算第5个……因为每一步计算的结果都没有存储,所以相同的计算结果反复计算。

 * 每一次方法调用都是两个f(n)的计算,所以第3个数开始,每次的计算都是前面两个数的计算次数之和。这是一个非常非常非常缓慢的算法!!!

 * 相当于每增加1,计算次数就要乘以1.618。

 * 当计算第30个数字的值时,方法调用达到1664079次,栈帧数量等同。

 */

public static int f(int n){

  if(n == 0){

    return 0;

  }

  if(n <= 2){

    return 1;

  }

  return f(n-1) + f(n-2);

}

正确方式:

public static int f(int n){

  int lastlast = 0;

  int last = 1;

  int sum = 1;

  for(int i=2; i<=n; i++){

    sum = last + lastlast;

    lastlast = last;

    last = sum;

  }

  return sum;

}

错误示例3(递归中嵌套递归):

public static void getAndSet(){

  Object obj = get();

  if(null != obj){

    set(obj);

    getAndSet();

  }

}

public static void set(Object obj){

  obj.value = 10;

  obj = obj.next;

  if(null != obj){

    set(obj);

  }

}

正确方式:

public static void getAndSet(){

  Object obj = get();

  while(null != obj){

    set(obj);

    obj = get();

  }

}

public static void set(Object obj){

  while(null != obj){

    obj.value = 10;

    obj = obj.next;

  }

}

错误方式4(递归方法中创建大数据对象):

public static void build(){

    int[] array = new int[1024 * 1024 * 1024];

    build();

}

㈣ 总结

  从以上示例可知,简单的尾递归都可以转化成循环。

  从汇编语言的角度来看,比较、赋值和跳转构成了所有的语法结构,并没有递归,也没有循环。因此其实所有的递归,无论多复杂都可以转化成循环语句。

  大部分情况下,递归并不需要转化成循环。譬如树搜索等使用递归会使得程序结构简单明了,且因其特殊的数据结构也使得递归层次并不会太深。

  现代JVM会对大部分的尾递归方法进行优化,也就是转化成循环结构。但JVM并不保证对所有的尾递归都会进行转换。因此当存在递归深度过深的风险、递归方法中包含大对象等可能导致栈溢出的情况,手动转化成循环结构应该是更好的选择。

六 后记

JVM的知识结构体系庞大而复杂,牵涉到很多其它学科的知识,譬如计算机体系结构、操作系统、编译原理、离散数学、汇编语言、C、C++……

而且JVM中的每一个知识点几乎都可以写几本厚厚的书,譬如垃圾回收算法、性能调优……

本文目的只是让java coder对JVM有一个直观的认识,因此尽量用简单明了的语言和图例来描述比较抽象的概念,如果能帮助大伙在进一步学习时建立一点基本常识则非常欢喜了。

另,如有错误之处欢迎指正。谢谢!
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: