0%

Java内存管理

Java内存管理原理

在Java运行体系结构中,存在Java程序、虚拟机和操作系统三个层次,Java程序与虚拟机交互(即JNI),虚拟机与操作系统间交互!这就保证了Java程序的平台无关性。

程序运行前,JVM向操作系统申请一定的内存空间,称为初始内存空间。

程序运行过程中,Java程序一直向Java虚拟机申请内存,当超过初始内存空间时,Java虚拟机再次向操作系统申请。当虚拟机已申请的内存超过规定的最大内存空间再申请时,将会出现内存溢出错误。

内存的分配

Java内存可以分为方法区、heap(堆)和stack(栈)。

  • 方法区,默认64M,Java虚拟机会将加载的类存入,保存类的结构,静态成员等。
  • heap(堆),默认64M,存放对象持有的数据,保持对原类的引用 。对象的属性值保存在堆中,对象调用的方法保存在方法区。通过new创建的对象都在堆中。方法中的局部变量使用final修饰后,放在堆中。
  • stack(栈), 默认为1M。
  • 基础类型保存在栈中
  • 局部变量保存在栈中
    程序进入一个方法时,会为这个方法单独分配一块私属存储空间,用于存储这个方法内部的局部变量,当这个方法结束时,分配给这个方法的栈会释放,这个栈中的变量也将随之释放。

在堆上分配内存与赋值异步性

在Java的指令中,创建对象和赋值操作是分开进行的,如instance=new Singleton();语句是分为两步进行的,但是JVM并不保证这两个操作的先后顺序,也就是有可能JVM会为Singleton实例分配空间, 然后直接赋值给 instance成员,然后再去初始化这个Singleton实例。这样就可能出错了,我们以A、B两个线程为例:

  1. A、B线程同时进入了第一个if判断
  2. A首先进入synchronized块,由于instance为null,所以它执行instance = new Singleton();
  3. 由于JVM内部的优化机制,JVM先画出了一些分配给Singleton实例的空白内存,并赋值给instance成员(注意此时JVM没有开始初始化这个实例),然后A离开了synchronized块。
  4. B进入synchronized块,由于instance此时不是null,因此它马上离开了synchronized块并将结果返回给调用该方法的程序。
  5. 此时B线程打算使用Singleton实例,却发现它没有被初始化,于是错误发生了。

基础类型

1
2
3
int a=3;
int b=3;
a=4;
  • 编译器先处理int a=3;:
    首先,在栈中创建一个变量为a的引用,然后查找有没有字面量为3的地址。没有,则开辟一个存放3这个字面量的地址,然后将a指向这个地址。

  • 再处理int b=3;:
    首先创建一个变量为b的引用,然后查找发现已经存在字面量为3的地址,将b指向这个地址。

  • 如果此时,再a=4;:
    那么,虚拟机存在是否存在4这个字面量,如果存在,直接将a指向这个地址;否则,创建4这个字面量,将a指向这个字面量的地址。

因此改变了a的值,只是a重新指向别的地方,不会影响b的值。

对象存储

方法的存储

定义如下类

1
2
3
4
5
6
7
8
public class BoxSet{
private int width=100;
private int hight=200;
public BoxSet(int x,int y){
this.width=x;
this.hight=y;
}
}

测试

1
2
BoxSet boxSet; // (1)
boxSet=new BoxSet(50,50); // (2)

两条语句:

  1. 声明变量boxSet:
  • 在栈内存中,为对象的引用变量boxSet分配内存空间。但是boxSet的值为空,称boxSet为一个空对象。空对象不能使用,因为它没有引用任何“实体”。
  1. 会做两件事:
  • 在堆内存中,为类的成员变量widthhight分配内存,并赋值为各数据类型的默认值。
  • 显式初始化: 类定义时的初始化值,即width赋值为100,hight赋值为200
  • 调用构造方法: 为widthhight分别赋值为50和50
  • 返回堆内存中对象的引用给引用变量boxSet

方法存储

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public class Dog {
Collar c;
String name;
//1. main()方法位于栈上
public static void main(String[] args) {
//2. 在栈上创建引用变量d,但Dog对象尚未存在
Dog d;
//3. 在堆上创建新的Dog对象,并将其赋予d引用变量
d = new Dog();
//4. 将引用变量的一个副本传递给go()方法
d.go(d);
}
//5. 将go()方法置于栈上,并将dog参数作为局部变量(局部变量位于栈上)
void go(Dog dog){
//6. 在堆上创建新的Collar对象,并将其赋予Dog的实例变量
c =new Collar();
}
//7.将setName()添加到栈上,并将dogName参数作为其局部变量
void setName(String dogName){
//8. name的实例对象也引用String对象
name=dogName;
}
//9. 程序执行完成后,setName()将会完成并从栈中清除,
//此时,局部变量dogName也会消失,尽管它所引用的String仍在堆上
}

包装类

2.String StringBuffered 与 StringBuilder 区别

Java中的基本变量都存在包装类,如下表:

| 0 | 基本变量 | 包装类 |
|—- | —- | —- |
|1| int | Integer |
|2| long | Long |
|3| short | Short |
|4| float | Float |
|5| double | Double |
|6| boolean | Boolean |
|7| byte | Byte |
|8| char | Character |

String

1
String s="abc";
  1. 在栈内存中为对象的引用变量s分配内存
  2. 查找字面量"abc",如果存在则将该地址赋值给s; 否则,新建字面量"abc"并将赋值给s。

继续看下面的例子:

1
String s=new String("abc"); //(2)

方式2:

  1. 在栈内存中为对象的引用变量s分配内存
  2. 在堆中新建String对象,并将地址赋值给栈中的s
  3. 在栈内存中,查找字面量”abc”, 如果存在则将字面量地址作为参数传递给赋值给栈中s指向的String对象;否则,新建字面量”abc”.
    因此,在这个过程中共产生了两个对象。
1
2
3
String str1 = "abc";
String str2 = "abc";
System.out.println(str1==str2); //true

  注意,我们这里并不用str1.equals(str2);的方式,因为这将比较两个字符串的值是否相等。==号,根据JDK的说明,只有在两个引用都指向了同一个对象时才返回真值。而我们在这里要看的是,str1与str2是否都指向了同一个对象。
结果说明,JVM创建了两个引用str1和str2,但只创建了一个对象,而且两个引用都指向了这个对象。

String可以用String str = new String("abc");的形式来创建,也可以用String str = "abc";的形式来创建,这种表达式是可以的!前者是规范的类的创建过程,即在Java中,一切都是对象,而对象是类的实例,全部通过new()的形式来创建。那为什么在String str = "abc";中,并没有通过new()来创建实例,是不是违反了上述原则?其实没有。

我们再来更进一步,将以上代码改成:

1
2
3
4
5
String str1 = "abc";
String str2 = "abc";
str1 = "bcd";
System.out.println(str1==str2); //false
System.out.println(str1 + "," + str2); //bcd, abc

  这就是说,赋值的变化导致了类对象引用的变化,str1指向了另外一个新对象!而str2仍旧指向原来的对象。上例中,当我们将str1的值改为”bcd”时,JVM发现在栈中没有存放该值的地址,便开辟了这个地址,并创建了一个新的对象,其字符串的值指向这个地址。
  事实上,String类被设计成为不可改变(immutable)的类。如果你要改变其值,可以,但JVM在运行时根据新值悄悄创建了一个新对象,然后将这个对象的地址返回给原来类的引用。这个创建过程虽说是完全自动进行的,但它毕竟占用了更多的时间。在对时间要求比较敏感的环境中,会带有一定的不良影响。

  再修改原来代码:

1
2
3
4
5
6
7
8
9
10
  String str1 = "abc";
  String str2 = "abc";

  str1 = "bcd";

  String str3 = str1;
  System.out.println(str3); //bcd

  String str4 = "bcd";
  System.out.println(str1 == str4); //true

  str3 这个对象的引用直接指向str1所指向的对象(注意,str3并没有创建新对象)。当str1改完其值后,再创建一个String的引用str4,并指向因str1修改值而创建的新的对象。可以发现,这回str4也没有创建新的对象,从而再次实现栈中数据的共享。

  我们再接着看以下的代码。

1
2
3
  String str1 = new String("abc");
  String str2 = "abc";
  System.out.println(str1==str2); //false

  创建了两个引用。创建了两个对象。两个引用分别指向不同的两个对象。

1
2
3
  String str1 = "abc";
  String str2 = new String("abc");
  System.out.println(str1==str2); //false

  创建了两个引用。创建了两个对象。两个引用分别指向不同的两个对象。

  以上两段代码说明,只要是用new()来新建对象的,都会在堆中创建,而且其字符串是单独存值的,即使与栈中的数据相同,也不会与栈中的数据共享。

  6). 数据类型包装类的值不可修改。不仅仅是String类的值不可修改,所有的数据类型包装类都不能更改其内部的值。

volatile关键字

Volatile修饰的成员变量在每次被线程访问时,都强迫从共享内存中重读该成员变量的值。而且,当成员变量发生变化时,强迫线程将变化值回写到共享内存。这样在任何时刻,两个不同的线程总是看到某个成员变量的同一个值。

首先, 应该明白计算机内部都做什么了。比如做了一个i++操作,计算机内部做了三次处理:读取-修改-写入。
同样,对于一个long型数据,做了个赋值操作,在32系统下需要经过两步才能完成,先修改低32位,然后修改高32位。
假想一下,当将以上的操作放到一个多线程环境下操作时候,有可能出现的问题,是这些步骤执行了一部分,而另外一个线程就已经引用了变量值,这样就导致了读取脏数据的问题。
通过这个设想,就不难理解volatile关键字了。

volatile可以用在任何变量前面,但不能用于final变量前面,因为final型的变量是禁止修改的。也不存在线程安全的问题。

内存释放

垃圾回收

垃圾回收是一种动态存储管理技术,它自动地释放不再被程序引用的对象,按照特定的垃圾收集算法来实现资源自动回收的功能。当一个对象不再被引用的时候,内存回收它占领的空间,以便空间被后来的新对象使用,以免造成内存泄露。

一般是在CPU空闲或空间不足时自动进行垃圾回收,而程序员无法精确控制垃圾回收的时机和顺序等。
当没有任何获得线程能访问一个对象时,该对象就符合垃圾回收条件。
垃圾回收器如发现一个对象不能被任何活线程访问时,他将认为该对象符合删除条件,就将其加入回收队列,但不是立即销毁对象,何时销毁并释放内存是无法预知的。垃圾回收不能强制执行,然而Java提供了一些方法(如:System.gc()方法),允许你请求JVM执行垃圾回收,而不是要求,虚拟机会尽其所能满足请求,但是不能保证JVM从内存中删除所有不用的对象。

符合垃圾回收的条件

  1. 空引用
  2. 重新为引用变量赋值:可以通过设置引用变量引用另一个对象来解除该引用变量与一个对象间的引用关系。
  3. 方法内创建的对象:所创建的局部变量仅在该方法的作用期间内存在。一旦该方法返回,在这个方法内创建的对象就符合垃圾收集条件。有一种明显的例外情况,就是方法的返回对象。
  4. 隔离引用:这种情况中,被回收的对象仍具有引用,这种情况称作隔离岛。若存在这两个实例,他们互相引用,并且这两个对象的所有其他引用都删除,其他任何线程无法访问这两个对象中的任意一个。也可以符合垃圾回收条件。

强制垃圾回收

finalize()

java提供了一种机制,使你能够在对象刚要被垃圾回收之前运行一些代码。这段代码位于名为finalize()的方法内,所有类从Object类继承这个方法。由于不能保证垃圾回收器会删除某个对象。因此放在finalize()中的代码无法保证运行。因此建议不要重写finalize();

内存泄露

内存泄漏就是存在一些被分配的对象,这些对象有下面两个特点:
首先,这些对象是有被引用的,即在有向树形图中,存在树枝通路可以与其相连;
其次,这些对象是无用的,即程序以后不会再使用这些对象。
如果对象满足这两个条件,这些对象就可以判定为Java中的内存泄漏,这些对象不会被GC所回收,然而它却占用内存。

1
2
3
4
5
6
7
Vector v = new Vector(10);
for (int i = 1; i < 100; i++)
{
Object o = new Object();
v.add(o);
o = null;
}

//此时,所有的Object对象都没有被释放,因为变量v引用这些对象。 实际上无用,而还被引用的对象,GC就无能为力了(事实上GC认为它还有用),这一点是导致内存泄漏最重要的原因。

优化编程

(1)不要显式调用System.gc()

  此函数建议JVM进行主GC,虽然只是建议而非一定,但很多情况下它会触发主GC,从而增加主GC的频率,也即增加了间歇性停顿的次数。

(2)尽量减少临时对象的使用

  临时对象在跳出函数调用后,会成为垃圾,少用临时变量就相当于减少了垃圾的产生,从而延长了出现上述第二个触发条件出现的时间,减少了主GC的机会。

(3)对象不用时最好显式置为Null

  一般而言,为Null的对象都会被作为垃圾处理,所以将不用的对象显式地设为Null,有利于GC收集器判定垃圾,从而提高了GC的效率。

(4)尽量使用StringBuffer,而不用String来累加字符串

由于String是固定长的字符串对象,累加String对象时,并非在一个String对象中扩增,而是重新创建新的String对象,如Str5=Str1+Str2+Str3+Str4,这条语句执行过程中会产生多个垃圾对象,因为对次作“+”操作时都必须创建新的String对象,但这些过渡对象对系统来说是没有实际意义的,只会增加更多的垃圾。避免这种情况可以改用StringBuffer来累加字符串,因StringBuffer是可变长的,它在原有基础上进行扩增,不会产生中间对象。

(5)能用基本类型如Int,Long,就不用Integer,Long对象

  基本类型变量占用的内存资源比相应对象占用的少得多,如果没有必要,最好使用基本变量。

(6)尽量少用静态对象变量

  静态变量属于全局变量,不会被GC回收,它们会一直占用内存。

(7)分散对象创建或删除的时间

 集中在短时间内大量创建新对象,特别是大对象,会导致突然需要大量内存,JVM在面临这种情况时,只能进行主GC,以回收内存或整合内存碎片,从而增加主GC的频率。集中删除对象,道理也是一样的。它使得突然出现了大量的垃圾对象,空闲空间必然减少,从而大大增加了下一次创建新对象时强制主GC的机会。


参考文献

  1. Java内存模型与volatile关键字