JVM内存结构图解 (四)

四 数据类型占用空间分析


  操作数栈: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有一个直观的认识,因此尽量用简单明了的语言和图例来描述比较抽象的概念,如果能帮助大伙在进一步学习时建立一点基本常识则非常欢喜了。
另,如有错误之处欢迎指正。谢谢!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值