文章目录
- 开端
- 通过引用创建对象
- Java的数据存储方式
- 基本类型
- 包装类和高精度数字
- 操作符
- 自动递增和自动递减
- 老生常谈的问题:==和equals()
- 如何重写equals方法?
- 短路
- 字面量
- 科学计数法
- 位运算
- 类型转换
- 初始化和清理
- 方法的重载
- 方法的重写
- 无参构造器
- this与构造器
- 垃圾收集器
- 资源清理
- 数组
- 你一定要知道的细节
- 声明与初始化
- 数组内存分析
- Arrays工具类的使用
本文适合有一定基础的Java学习者,可以加深你对Java的理解,助你应对考试或者面试!
开端
通过引用创建对象
在Java中我们总是听见过一句话叫"万事万物皆对象",但实际上我们在程序中操作的其实是对象的引用(reference)
。那么究竟什么是引用呢?
我们有时候也会将
对象的引用
叫做对象的句柄
举个例子,我们通常会用下面这一行代码来创建一个对象:
Person person = new Person("张三");
有人会说,这里的person是一个对象,是Person类的一个实例。
也有人会说,这里的person并不是真正的对象,而是指向所创建的对象的引用。
到底哪种说法是对的?我们先不急着纠结哪种说法是对的,再看两行代码:
Person person;
person = new Person("张三");
这两行代码实现的功能和上面的一行代码是完全一样的。大家都知道,在Java中new是用来在堆上创建对象用的,如果person是一个对象的话,那么第二行为何还要通过new来创建对象呢?由此可见,person并不是所创建的对象,是什么?
“操纵的标识符实际是指向一个对象的引用” --《Java编程思想》
也就是说person是一个引用,是指向一个可以指向Person类的对象的引用。真正创建对象的语句是右边的new Person(“张三”);
我们再说白一点,看下面的图:
我们操作的person处在左边的堆中,它就是我们对象的引用,而栈中的才是我们真正的对象。
引用的本质是内存地址
Java的数据存储方式
当程序运行时,能够可视化其内容的排布方式是十分有帮助的,对于内存管理来说尤其如此。我们平常所熟知的数据存储方式就是栈和堆,在这里我们来一个系统的了解:
- 寄存器:这是速度最快的数据存储方式,因为它保存数据的位置在中央处理器CPU里。寄存器的数量是有限的,所以只能按需分配。此外我们不能直接控制寄存器的分配。
- 栈:位于RAM当中,通过栈指针可以从处理器获得直接支持。栈指针向下移动,则分配新的内存;向上移动,则释放那些内存。这种存储方式速度仅次于寄存器。
- 常用于存放对象引用和基本数据类型,而不用于存储对象
- Java系统在创建应用程序的就明确栈上的所有对象的生命周期,这种限制约束了程序的灵活性,但是提高了效率
- 堆:一种通用的内存池,也位于RAM当中,用于存放所有Java对象。其中存放的数据由JVM自动进行管理。
- 编译器不需要知道存储的数据在堆里存活多长。当需要一个对象时,使用new写一行代码,当执行这行代码时,会自动在堆里进行存储分配。同时,因为以上原因,用堆进行数据的存储分配和清理,需要花费更多的时间。(一句话说就是提高了灵活性,但是效率会降低)
- 常量存储,我们一般也叫做常量池。常量(字符串常量和基本类型常量、static final 关键字修饰的变量值)通常直接存储在程序代码内部(常量池)。这样做是安全的,因为它们的值在初始化时就已经被确定,并不会被改变。常量池在java用于保存在编译期已确定的,已编译的class文件中的一份数据。它包括了关于类,方法,接口等中的常量。
- 为了提供重用性、节约内存空间,JVM会将所有的字符串存放在常量池中,如果使用的字符串在常量池已存在则会直接返回,不需要重复创建。
- 为了提供重用性、节约内存空间,JVM会将所有的字符串存放在常量池中,如果使用的字符串在常量池已存在则会直接返回,不需要重复创建。
- 非RAM存储:如果数据完全存活于程序之外,那么它可以不受程序的任何控制,在程序没有运行时也可以存在。其中两个基本的例子是:序列化对象和持久化对象。
- 序列化对象,它指的是可以转化为字节流并可以发送至其他机器的对象
- 持久化对象,它指的是保存在磁盘上的对象
- 特点:将对象转化为其他形式来保存在其他媒介中。然后在需要的时候重新转化回常规的RAM对象。
为了便于理解我们放上一段代码,然后画出它的数据存储示意图:
public class number{
final static int i1 =1;
int i2=2;
int i3=2;
String str1= "abc";
String str2= "abc";
String str3= "abc";
String str4= new String("abc");
String str5= new String("abc");
}
顺便提一嘴RAM和ROM,很多人把他们弄混,我们可以这么去理解:
- RAM就是我们平时所说的CPU缓存、电脑和手机的(运行)内存
- ROM就是固态硬盘、U盘,还有我们平时买手机说的32G、64G的存储空间
基本类型
Java分为以下8种基本数据类型:
-
整数型:byte,short,int,long;
-
浮点型:float,double
-
布尔型:boolean
-
字符型:char
既然是基本数据类型,那也就意味着我们不需要通过new来创建,而是直接创建一个"自动变量",注意不是引用。也就是说变量会直接在栈上保存它的值,因此运行效率也非常高。
在Java中定义了每一种基本类型所占用的空间大小。要知道Java是一种跨平台的语言,即使是在不同的机器上这些基本类型所占用的空间也是保持一致的。这种一致性让Java的可移植性比其它语言更好。
注意:
boolean类型的空间大小没有明确标出,其对象只能被赋值为true和false
最后我们还要关注一下基本类型的默认值:
注意:
- 当基本数据类型被声明为非类变量时,JVM不会为相应的数据类型提供默认值,此时需要显示设置初始值才能正常使用基本类型,否则会提示
The local variable * may not have been initialized
- 2.Java将数据类型分为基本数据类型和引用类型,在作为类变量使用时,未显式初始化的引用类型类变量也会被初始化为默认值(nul)
- 3.Java为8中基本数据类型都提供了相应的包装类(Byte、Short、nteger、 Long、 Float、Double以及Character、Boolean),并且基本数据类型对应的包装类为引用类型,JVM会为其赋值默认值(null)。
一定要注意:
不是任何时候变量都会被赋予默认值,是只有作为成员变量的时候
包装类和高精度数字
各数据类型对应的包装类:
包装类设计出来的意义在于:
- Java是一个面向对象的语言,基本类型并不具有对象的性质。将基本类型包装成对象以后,扩大了基本类型所具有的操作,更是JAVA面向对象的体现
- 在泛型中传入的参数只能是类类型,不能使用基本数据类型
- 包装类中有的方法是静态方法,不能通过对象去调用,需要通过类来访问。
说到包装类我们就不得不提到自动拆箱
与自动装箱
,他们的概念我们都知道,但这里我说说它们的本质:
- 自动拆箱的本质:调用对应类的XXValue()方法
- 自动装箱的本质:调用对应类的XX.valueOf()方法
在包装类方面还有一个注意点:包装类的缓存设计(也叫做享元设计)
就是创建一个缓存区(有限定大小的)来将重复使用的数据放进去,从而达到少占用内存空间的效果。在区域之内的直接拿来用,一旦超过缓存空间,则系统会在堆内存中new一个新的对象出来。
(面试爱考,喜欢在这些重用对象的地址身上出题)
- Byte (缓存范围:[-128,127])
- Short (缓存范围:[-128,127])
- Long (缓存范围:[-128,127])
- Integer (缓存范围:[-128,127])
- Character (缓存范围:[0,127])
- Boolean (全部缓存)
- Float (没有缓存)
- Doulbe (没有缓存)
Java提供了两个支持高精度计算的类,分别是:
- BigInteger
- 可以支持任意精度的整数,不用担心丢失精度
- BigDecimal
- 用于任意精度的定点数,例如你可以用于货币计算
虽然这两个类大致也可以归为包装类,但是他们其实并没有对应的基本类型。
这两个类都提供了一些方法来模拟基本类型的各种操作。也就是说,你能对int和float做什么,就能对BigInteger和BigDecimal做什么,区别只是你用方法代替了运算符而已。此外,由于涉及更多的计算量,导致的结果就是相关操作的效率有所降低。也就是"速度换精度"。
相关方法参考JDK文档
操作符
自动递增和自动递减
- 递减操作符:
--
,表示减少一个单位 - 递增操作符:
++
,表示增加一个单位
注意这两个操作符都有前缀式和后缀式:
- 前缀式表示程序会先执行运算,然后返回生成的结果
- 后缀式表示程序会先返回变量的值,然后再执行计算
接下来我们看几个例子体会一下:
public class AutoInc {
public static void main(String[] args) {
int i = 1;
System.out.println("i: " + i);
System.out.println("++i: " + ++i); // Pre-increment
System.out.println("i++: " + i++); // Post-increment
System.out.println("i: " + i);
System.out.println("--i: " + --i); // Pre-decrement
System.out.println("i--: " + i--); // Post-decrement
System.out.println("i: " + i);
}
}
/* Output:
i: 1
++i: 2
i++: 2
i: 3
--i: 2
i--: 2
i: 1
*/
老生常谈的问题:==和equals()
之所以在这里还要提一嘴,实在是因为这个问题太过经典,经典到基本学过java的人都会被提醒到这是一个坑以后要注意。
当创建一个新类的时候,它会自动继承Object类。如果该类没有重写equals方法,那么它使用的就是Object中的equals方法,而这个方法其实默认比较的就是内存地址也就是和==
的作用一样。
大多数标准库会重写equals方法
如何重写equals方法?
一个适当的equals方法必须满足以下五个条件:
自反性
:对于任何非空引用值 x,x.equals(x) 都应返回 true。对称性
:对于任何非空引用值 x 和 y,当且仅当 y.equals(x) 返回 true 时,x.equals(y) 才应返回 true。传递性
:对于任何非空引用值 x、y 和 z,如果 x.equals(y) 返回 true,并且 y.equals(z) 返回 true,那么 x.equals(z) 应返回 true。一致性
:对于任何非空引用值 x 和 y,多次调用 x.equals(y) 始终返回 true 或始终返回 false,前提是对象上 equals 比较中所用的信息没有被修改。- 对于任何非空引用值 x,x.equals(null) 都应返回 false。
通过上面这几点我们可以总结出以下的重写思路:
我们将要比较的对象命名为a
- 如果a为null,则两个对象不相等
- 如果a为this对象(即自己与自己比较),则两个对象相等
- 如果a不是this对象所属的类或子类,则两个对象不相等
- 接下来我们再来比较两个对象的实际值
我们再来看看String源码中equals是怎么写的
public boolean equals(Object anObject) {
if (this == anObject) {
return true;
}
if (anObject instanceof String) {
String anotherString = (String)anObject;
int n = value.length;
if (n == anotherString.value.length) {
char v1[] = value;
char v2[] = anotherString.value;
int i = 0;
while (n-- != 0) {
if (v1[i] != v2[i])
return false;
i++;
}
return true;
}
}
return false;
}
我们可以看到在String中equals的逻辑如下:
- 先看内存地址是否相同
- 在看是否为同类或子类
- 如果是,则强转之后比较长度
- 如果长度还一样,则比较相应位置的字符是否一样
关于String的equals方法源码,面试有时候也会考到
短路
逻辑操作符支持一种称为"短路"的现象:一旦表达式当前部分的计算结果能够明确无误的确定整个表达式的值,表达式余下部分就不会执行了。
我们可以看看下面的例子:
public class ShortCircuit {
static boolean test1(int val) {
System.out.println("test1(" + val + ")");
System.out.println("result: " + (val < 1));
return val < 1;
}
static boolean test2(int val) {
System.out.println("test2(" + val + ")");
System.out.println("result: " + (val < 2));
return val < 2;
}
static boolean test3(int val) {
System.out.println("test3(" + val + ")");
System.out.println("result: " + (val < 3));
return val < 3;
}
public static void main(String[] args) {
boolean b = test1(0) && test2(2) && test3(2);
System.out.println("expression is " + b);
}
}
/* Output:
test1(0)
result: true
test2(2)
result: false
expression is false
*/
当然我们把
&&
换成&
可以避免这种短路情况。
字面量
一般来说,如果程序里使用了一个字面量 (literal value )
,则编译器能准确地知道它是什么类型的。不过当类型模棱两可的时候,你就必须使用与该字面量相关的一些字符以此添加额外信息来引导编译器。
下面这段代码展示了这些字符:
public class Literals {
public static void main(String[] args) {
int i1 = 0x2f; // 十六进制 (小写)
System.out.println(
"i1: " + Integer.toBinaryString(i1));
int i2 = 0X2F; // 十六进制 (大写)
System.out.println(
"i2: " + Integer.toBinaryString(i2));
int i3 = 0177; // 八进制 (前置0)
System.out.println(
"i3: " + Integer.toBinaryString(i3));
char c = 0xffff; // char类型的最大十六进制值
System.out.println(
"c: " + Integer.toBinaryString(c));
byte b = 0x7f; // byte类型的最大十六进制值
System.out.println(
"b: " + Integer.toBinaryString(b));
short s = 0x7fff; // short类型的最大十六进制值
System.out.println(
"s: " + Integer.toBinaryString(s));
long n1 = 200L; // long类型后缀(大写)
long n2 = 200l; // long类型后缀(小写)
long n3 = 200;
// Java 7 的二进制字面量
byte blb = (byte)0b00110101;
System.out.println(
"blb: " + Integer.toBinaryString(blb));
short bls = (short)0B0010111110101111;
System.out.println(
"bls: " + Integer.toBinaryString(bls));
int bli = 0b00101111101011111010111110101111;
System.out.println(
"bli: " + Integer.toBinaryString(bli));
long bll = 0b00101111101011111010111110101111;
System.out.println(
"bll: " + Long.toBinaryString(bll));
float f1 = 1;
float f2 = 1F; // float类型后缀(大写)
float f3 = 1f; // float类型后缀(小写)
double d1 = 1d; // double类型后缀(大写)
double d2 = 1D; // double类型后缀(小写)
// (十六进制和八进制也能作为long类型使用)
}
}
/* Output:
i1: 101111
i2: 101111
i3: 1111111
c: 1111111111111111
b: 1111111
s: 111111111111111
blb: 110101
bls: 10111110101111
bli: 101111101011111010111110101111
bll: 101111101011111010111110101111
*/
注意:
- 这里的十六进制数、八进制数、二进制数适用于所有整数类型
- 我们在前面已经说过char、byte和short所能表示的最大十六进制值。如果超出范围,编译器会自动将其转换成int型并告诉你这次赋值需要进行“窄化转型”
字面量里的下划线
Java 7 中有一个十分有用的新增功能:可以在数字字面量里使用下划线,这样更易于阅读。这对在大数值里分组数字特别有帮助
科学计数法
在java中e
代表10的幂次,并且这个e
大写小写均可
注意:
编译器一般会把指数当作double类型处理
位运算
在位运算中有两套操作符:
- 按位操作符
- 移位操作符
按位操作符
用来操作整数基本数据类型中的单个二进制位。按位操作符会对两个参数中对应的二进制位执行布尔代数运算。
&
:与操作符|
:或操作符^
:异或操作符~
:非操作符
前三个都是二元操作符,最后一个是一元操作符。所以前三个都可以和等号联合使用:&=
、|=
、^=
你可以对布尔类型使用与、或、异或运算,但不能使用非运算(大概是为了避免与逻辑操作符
!
混淆)
看一段易错代码:
int a = 3;
System.out.println(Integer.toBinaryString(a));
System.out.println(Integer.toBinaryString(~a));
结果:
移位操作符
移位操作符也操作二进制位,他们只能用来处理基本类型里的整数类型。
<<
:左移位,低位补0,num <<1,相当于num乘以2>>
:有符号的右移位操作符,num >>1,相当于num除以2- 符号为正,高位插0
- 符号为负,高位差1
>>>
:无符号的右移位操作符- 无论符号正负都在高位插入0
移位操作符可以与等号结合使用
示例代码:
//Integer.toBinaryString()是将数字用二进制格式显示
int i = -10;
System.out.println(Integer.toBinaryString(i));
//左移两位
int j = -10<<2;
System.out.println(Integer.toBinaryString(j));
//右移两位
int m = -10>>2;
System.out.println(Integer.toBinaryString(m));
//无符号右移
int n = -10>>>2;
System.out.println(Integer.toBinaryString(n));
结果:
11111111111111111111111111110110
11111111111111111111111111011000
11111111111111111111111111111101
111111111111111111111111111101(省略了首位两个0)
计算机中数字以补码存储,首位为符号位
类型转换
Java中有两种数据转换:
- 宽化转型:不必显式的进行类型转换,因为新类型可以容纳比原来类型更多的信息,而不会造成任何信息的丢失。(当然在有的时候可能需要显式的类型转换才能正常编译)
- 窄化转型:将容纳更多信息的数据类型转化为无法容纳那么多信息的数据类型,有可能面临信息丢失的危险。
Java可以把任何基本类型转化成别的基本类型,但boolean除外,他不允许进行任何类型的转换
在执行窄化转型的时候,要注意截尾与舍入的问题:
我们将float或者double转化为整型值时,总是对该数值进行截尾,而不是四舍五入(要用round方法)。
说到这里就不得不提类型提升
:
如果对小于 int 类型的基本数据类型(即char、byte 或者 shot)执行算术运算或按位运算,运算执行前这些值就会被自动提升为 int,结果也是 int 类型。如果要把结果赋值给较小的类型,就必须使用强制类型转换(由于把值赋给了较小的类型,可能会出现信息丢失)。
通常,表达式里出现的最大的数据类型决定了表达式最终结果的数据类型。如果将一个 float 类型的值与一个 double 类型的值相乘,结果就是 double 类型。如果将-个int 值和一个 long值相加,则结果为 long 类型。
初始化和清理
方法的重载
方法重载指同一个类中定义的多个方法之间的关系,满足下列条件的多个方法相互构成重载:
- 多个方法在同一个类中
- 多个方法具有相同的方法名
- 多个方法的参数不相同,类型不同或者数量不同
不能通过返回值确定方法是否重载!
注意:
参数即使名字相同、类型相同、数量相同,但是如果顺序不同也可以构成重载:
public class OverloadingOrder {
static void f(String s, int i) {
System.out.println("String: " + s + ", int: " + i);
}
static void f(int i, String s) {
System.out.println("int: " + i + ", String: " + s);
}
public static void main(String[] args) {
f("String first", 11);
f(99, "Int first");
}
}
/* Output:
String: String first, int: 11
int: 99, String: Int first
*/
方法的重写
说到重载那就不得不提到重写
方法重写的要求:
-
子类重写的方法
必须
和父类被重写的方法具有相同的方法名称
、参数列表
。 -
子类重写的方法的返回值类型
不能大于
父类被重写的方法的返回值类型。(例如:Student < Person)。注意:如果返回值类型是基本数据类型和void,那么必须是相同
-
子类重写的方法使用的访问权限
不能小于
父类被重写的方法的访问权限。(public > protected > 缺省 > private)注意:① 父类私有方法不能重写 ② 跨包的父类缺省的方法也不能重写
-
子类方法抛出的异常不能大于父类被重写方法的异常
此外,子类与父类中同名同参数的方法必须同时声明为非static的(即为重写),或者同时声明为static的(不是重写)。因为static方法是属于类的,子类无法覆盖父类的方法。
无参构造器
这是一个小的注意点知道即可:
当我们新建一个对象,没有创建无参构造器,编译器时会自动帮你创建的。但是只要你自己写了构造器(假设是一个有参数的构造器),编译器就不会再帮你创建构造器,如果你此时再去通过无参构造器创建对象,编译器会提示找不到相应的构造器!
this与构造器
this表示对当前对象的引用,也就是我们常说的"这个对象"和"当前对象"。
this关键字只能在非静态方法中使用
我们在java中很少使用到this,因为编译器会自动帮我们添加,当然你自己写也是可以的。
当需要明确指出当前对象的引用时,才使用this关键字。下面我列举出三种使用this的场景:
①当你的方法要返回的是当前对象的引用
increment方法通过this关键字返回了当前对象的引用,所以可以很容易的对同一个对象执行多次操作。
②用来将当前对象传递给另外一个方法
class Person {
public void eat(Apple apple) {
Apple peeled = apple.getPeeled();
System.out.println("Yummy");
}
}
class Peeler {
static Apple peel(Apple apple) {
// ... 削皮
return apple; // 削皮后的
}
}
class Apple {
Apple getPeeled() {
return Peeler.peel(this);
}
}
public class PassingThis {
public static void main(String[] args) {
new Person().eat(new Apple());
}
}
/* Output:
Yummy
*/
也就是在当前类中,我们要将对象传递给一个外部方法的时候可以用this
③在构造器中调用构造器
当一个类中有多个构造器时,有时会希望一个构造器里调用另外一个构造器,以避免重复的代码。这里就可以使用this。
在构造器中,如果在this后面加了参数列表,那么就有了不同的含义,他会显示调用与该参数列表匹配的构造器。
注意:
不能同时用this调用两个构造器,并且构造器调用必须出现在方法的最开始部分
④当参数与成员变量的名字相同而产生冲突的时候
我们把第三点和第四点结合起来看一个例子:
public class Flower {
int petalCount = 0;
String s = "initial value";
Flower(int petals) {
petalCount = petals;
System.out.println(
"Constructor w/ int arg only, petalCount= "
+ petalCount);
}
Flower(String ss) {
System.out.println(
"Constructor w/ String arg only, s = " + ss);
s = ss;
}
Flower(String s, int petals) {
this(petals);
//- this(s); // 不能同时调用两个构造器
this.s = s; // 成员变量与参数冲突
System.out.println("String & int args");
}
Flower() {
this("hi", 47);
System.out.println("Zero-argument constructor");
}
void printPetalCount() {
//- this(11); // 不能在非构造器里
System.out.println(
"petalCount = " + petalCount + " s = "+ s);
}
}
垃圾收集器
垃圾收集
是Java虚拟机(JVM)垃圾收集器(Garbage Collector)提供的一种用于在空闲时间不定时回收无任何对象引用的对象占据的内存空间的一种机制。
在java中垃圾收集器具有以下四个主要特征:
- 停止-复制(stop-and-copy)
- 标记-清除(mark-and-sweep)
- 分代(generation)
- 自适应(adaption)
停止-复制算法
程序首先停止,然后将所有存活对象从一个堆复制到另一个堆,剩下的就都是垃圾。当一个对象从一个地方移动到另一个地方时,所有指向该对象的引用都必须修改。
该算法的问题:
- 你需要有两个堆,然后在这两个独立的堆之间来回复制内存,这比实际需要多了一倍内存。
- 一旦程序变得稳定,它可能很少产生垃圾,甚至没有。尽管如此,复制收集器仍会将所有内存从一个地方复制到另一个地方,这是一种浪费。
标记-清除算法
该算法从栈和静态存储开始,遍历所有引用以查找存活对象。每当它找到一个存活对象,就会给该对象设置一个标志。此时尚未开始收集,只有在标记过程完成后才会进行清除。在清除过程中,没有标记的对象被释放,但不会发生复制。
该算法的特征:
- 对于一般用途,“标记 – 清除”算法相当慢;在垃圾很少或没有的时候,它的速度就很快了。
分代
为了解决这两个算法各有利弊的情况,所以就有了垃圾收集器的第三个特点:分代
在老年代中的对象变成垃圾的可能性更大,新生代中的对象因为刚刚被分配所以变成垃圾的可能性小很多。
- 创建一个对象的时候,总是在Eden区操作,当这个区满了,那么就会触发一次Young GC,也就是年轻代的垃圾回收。
- 当Eden区再次被用完,就再触发一次Young GC,此时会将Eden区与From区还在被使用的对象复制到To区。
- 在下一次Young GC的时候,则是将Eden区与To区中的还在被使用的对象复制到From区。
- 若干次Young GC后,有些对象在From与To之间来回游荡,一旦超出阈值,就将它们复制到老年代。如果老年代被用完,则执行Full GC。
这里的Young GC就用的是标记-清除算法,Full GC用的是停止-复制算法
其策略就是让执行效率高的多执行,让执行效率低的少执行。
自适应
JVM会监控垃圾收集的效率,如果所有对象都很稳定,垃圾收集器效率很低的话,它会切换到“标记 – 清除”算法。同样,JVM会跟踪标记和清除的效果,如果堆里开始出现很多碎片,它会切换回“停止 – 复制”算法。
资源清理
C++ vs. Java
垃圾回收不是C++中的析构. 两者不是对应关系, 因为垃圾回收的发生是不确定的, 而C++中析构函数是由程序员控制(delete) 或者离开器作用域时自动调用发生, 是在确定的时间对对象进行销毁并释放其所占用的内存.
那么我们就有了一个疑问:Java已经有了垃圾收集器(Garbage Collector),还需要额外针对创建的资源做清理工作吗?
答案是需要。因为假设你的对象在不使用 new 的情况下分配了一块“特殊”内存。垃圾收集器只知道如何释放由 new 分配的内存,所以它不知道如何释放对象的这块“特殊”内存。
未使用 Java 的new分配内存(比如文件操作的句柄, 数据库的连接等等),即采用了类似 C 语言的机制,在Java中,就是使用本地方法来实现,例如通过调用 C 的 malloc() 系列函数来分配存储空间。此时,应该在finalize()方法中调用C的free()函数。
Java 允许定义这样的方法,它在对象被垃圾收集器析构(回收)之前调用,这个方法叫做 finalize( )
,它用来清除回收对象。
finalize()方法在java中是由Object类提供的:
使用@Deprecated注解表示他从JDK9之后就被作废了
finalize()虽然看起来用于清理资源,但它不能等同于C++的析构函数,它通常会被GC自动调用,由于垃圾收集不一定会执行,因此finalize()方法也不一定会执行。所以finalize() 的使用仅限于一种特殊情况:对象以某种方式分配存储空间,而不是通过创建对象来分配。(事实上这也是一种无奈之举,因为确实是不能保障他能被执行)
垃圾收集不一定会执行是因为垃圾收集的一个策略:
能不回收就不会回收.只要程序的内存没有达到即将用完的地步, 对象占用的空间就不会被释放.因为如果程序正常结束了,而且垃圾回收器没有释放申请的内存, 那么随着程序的正常退出, 申请的内存会自动交还给操作系统; 而且垃圾回收本身就需要付出代价, 是有一定开销的, 如果不使用,就不会存在这一部分的开销.
《 Effective Java 》的作者——Joshua Bloch 认为:“Java语言规范不仅不保证finalize()方法会被及时地执行,而且根本就不保证它们会被执行。”
因此,普遍的建议是“永远不要直接调用 finalize()方法”!
为什么?——假设用finalize()方法关闭已经打开的文件,由于finalize()方法有可能永远不执行,也可能被延迟执行,就可能导致该文件迟迟未能关闭,甚至永远没有关闭,最终导致大量的文件会保留在打开状态。积累到一定程度,程序就可能再也无法打开文件,导致运行失败!
我们总结一下:
- 在Java中始终使用new创建对象,此时,垃圾收集器会自动释放存储空间
- 如果在Java中通过其他机制创建了本地对象,则使用finalize()管理内存空间的释放,但它仍然通过垃圾收集器自动调用
- 如果需要终止对象封装的资源(如文件或线程),请提供一个显式的终止方法,如显式定义close()方法
这里讲述的只是一点皮毛,如果想要更加详细的了解垃圾收集器以及资源清理的相关内容,可以参考我的另一篇文章:
JVM从跨平台到跨专业 Ⅱ-- 垃圾回收
数组
你一定要知道的细节
- 数组本身是
引用数据类型
,而数组中的元素可以是任何数据类型
,包括基本数据类型和引用数据类型。 - 创建数组对象会在内存中开辟一整块
连续的空间
。占据的空间的大小,取决于数组的长度和数组中元素的类型。 - 数组中的元素在内存中是依次紧密排列的,有序的。
- 数组,一旦初始化完成,其长度就是确定的。数组的
长度一旦确定,就不能修改
。 - 我们可以直接通过下标(或索引)的方式调用指定位置的元素,速度很快。
- 数组名中引用的是这块连续空间的首地址。
声明与初始化
数组的声明,需要明确:
-
数组的维度
:在Java中数组的符号是[],[]表示一维,[][]表示二维。 -
数组的元素类型
:即创建的数组容器可以存储什么数据类型的数据。元素的类型可以是任意的Java的数据类型。例如:int、String、Student等。 -
数组名
:就是代表某个数组的标识符,数组名其实也是变量名,按照变量的命名规范来命名。数组名是个引用数据类型的变量,因为它代表一组数据。
int[] arr;
int arr1[];
double[] arr2;
String[] arr3; //引用类型变量数组
注意:
Java语言中声明数组时不能指定其长度(数组中元素的个数)。 例如: int a[5]; //非法
而初始化分为两种:
- 如果数组变量的初始化和数组元素的赋值操作同时进行,那就称为
静态初始化
。静态初始化,本质是用静态数据(编译时已知)为数组初始化。此时数组的长度由静态数据的个数决定。数据类型[] 数组名 = new 数据类型[]{元素1,元素2,元素3,...}; 数据类型[] 数组名 = {元素1,元素2,元素3...};//必须在一个语句中完成,不能分成两个语句写
- 数组变量的初始化和数组元素的赋值操作分开进行,即为
动态初始化
。动态初始化中,只确定了元素的个数(即数组的长度),而元素值此时只是默认值,还并未真正赋自己期望的值。真正期望的数据需要后续单独一个一个赋值。数组存储的元素的数据类型[] 数组名字 = new 数组存储的元素的数据类型[长度];
我们来看看动态初始化里面的默认值:
-
对于基本数据类型而言,默认初始化值各有不同。
-
对于引用数据类型而言,默认初始化值为null(注意与0不同!)
还要提一下二维数组的初始化。静态初始化与前面基本一样,而动态初始化要注意,其使用场景是:如果二维数组的每一个数据,甚至是每一行的列数,需要后期单独确定。
这里就要分为两种情况;
- 规则二维表:每一行的列数是相同的
//(1)确定行数和列数 元素的数据类型[][] 二维数组名 = new 元素的数据类型[m][n]; //其中,m:表示这个二维数组有多少个一维数组。或者说一共二维表有几行 //其中,n:表示每一个一维数组的元素有多少个。或者说每一行共有一个单元格 //此时创建完数组,行数、列数确定,而且元素也都有默认值 //(2)再为元素赋新值 二维数组名[行下标][列下标] = 值;
- 不规则二维表:每一行的列数不一样
//(1)先确定总行数 元素的数据类型[][] 二维数组名 = new 元素的数据类型[总行数][]; //此时只是确定了总行数,每一行里面现在是null //(2)再确定每一行的列数,创建每一行的一维数组 二维数组名[行下标] = new 元素的数据类型[该行的总列数]; //此时已经new完的行的元素就有默认值了,没有new的行还是null //(3)再为元素赋值 二维数组名[行下标][列下标] = 值;
数组内存分析
先浅谈一下java虚拟机:
为了提高运算效率,就对空间进行了不同区域的划分,因为每一片区域都有特定的处理数据方式和内存管理方式。
区域名称 | 作用 |
---|---|
虚拟机栈 | 用于存储正在执行的每个Java方法的局部变量表等。局部变量表存放了编译期可知长度 的各种基本数据类型、对象引用,方法执行完,自动释放。 |
堆内存 | 存储对象(包括数组对象),new来创建的,都存储在堆内存。 |
方法区 | 存储已被虚拟机加载的类信息、常量、(静态变量)、即时编译器编译后的代码等数据。 |
本地方法栈 | 当程序中调用了native的本地方法时,本地方法执行期间的内存区域 |
程序计数器 | 程序计数器是CPU中的寄存器,它包含每一个线程下一条要执行的指令的地址 |
一个一维数组内存图
public static void main(String[] args) {
int[] arr = new int[3];
System.out.println(arr);//[I@5f150435
}
注意:
我们上面打印出来的地址[I@5f150435
是一个虚拟地址并不是数据真实存在的地址,Java并不像C/C++一样存在指针,而是对其进行了隐藏。我们下面的图中举出的例子使用的是真实地址进行演示。
数组下标为什么是0开始
因为第一个元素距离数组首地址间隔0个单元格。
两个一维数组内存图
两个数组独立
public static void main(String[] args) {
int[] arr = new int[3];
int[] arr2 = new int[2];
System.out.println(arr);
System.out.println(arr2);
}
两个变量指向一个一维数组
两个数组变量本质上代表同一个数组。
public static void main(String[] args) {
// 定义数组,存储3个元素
int[] arr = new int[3];
//数组索引进行赋值
arr[0] = 5;
arr[1] = 6;
arr[2] = 7;
//输出3个索引上的元素值
System.out.println(arr[0]);
System.out.println(arr[1]);
System.out.println(arr[2]);
//定义数组变量arr2,将arr的地址赋值给arr2
int[] arr2 = arr;
arr2[1] = 9;
System.out.println(arr[1]);
}
我们再来看看二维数组的内存分析:
二维数组本质上是元素类型是一维数组的一维数组。
int[][] arr = {
{1},
{2,2},
{3,3,3},
{4,4,4,4},
{5,5,5,5,5}
};
//1、声明二维数组,并确定行数和列数
int[][] arr = new int[4][5];
//2、确定元素的值
for (int i = 0; i < arr.length; i++) {
for (int j = 0; j < arr.length; j++) {
arr[i][j] = i + 1;
}
}
//1、声明一个二维数组,并且确定行数
//因为每一行的列数不同,这里无法直接确定列数
int[][] arr = new int[5][];
//2、确定每一行的列数
for(int i=0; i<arr.length; i++){
/*
arr[0] 的列数是1
arr[1] 的列数是2
arr[2] 的列数是3
arr[3] 的列数是4
arr[4] 的列数是5
*/
arr[i] = new int[i+1];
}
//3、确定元素的值
for(int i=0; i<arr.length; i++){
for(int j=0; j<arr[i].length; j++){
arr[i][j] = i+1;
}
}
Arrays工具类的使用
java.util.Arrays类即为操作数组的工具类,包含了用来操作数组(比如排序和搜索)的各种方法。 比如:
数组元素拼接
- static String toString(int[] a) :字符串表示形式由数组的元素列表组成,括在方括号(“[]”)中。相邻元素用字符 ", "(逗号加空格)分隔。形式为:[元素1,元素2,元素3。。。]
- static String toString(Object[] a) :字符串表示形式由数组的元素列表组成,括在方括号(“[]”)中。相邻元素用字符 ", "(逗号加空格)分隔。元素将自动调用自己从Object继承的toString方法将对象转为字符串进行拼接,如果没有重写,则返回类型@hash值,如果重写则按重写返回的字符串进行拼接。
数组排序
- static void sort(int[] a) :将a数组按照从小到大进行排序
- static void sort(int[] a, int fromIndex, int toIndex) :将a数组的[fromIndex, toIndex)部分按照升序排列
- static void sort(Object[] a) :根据元素的自然顺序对指定对象数组按升序进行排序。
- static void sort(T[] a, Comparator<? super T> c) :根据指定比较器产生的顺序对指定对象数组进行排序。
数组元素的二分查找
- static int binarySearch(int[] a, int key) 、static int binarySearch(Object[] a, Object key) :要求数组有序,在数组中查找key是否存在,如果存在返回第一次找到的下标,不存在返回负数。
数组的复制
- static int[] copyOf(int[] original, int newLength) :根据original原数组复制一个长度为newLength的新数组,并返回新数组
- static T[] copyOf(T[] original,int newLength):根据original原数组复制一个长度为newLength的新数组,并返回新数组
- static int[] copyOfRange(int[] original, int from, int to) :复制original原数组的[from,to)构成新数组,并返回新数组
- static T[] copyOfRange(T[] original,int from,int to):复制original原数组的[from,to)构成新数组,并返回新数组
比较两个数组是否相等
- static boolean equals(int[] a, int[] a2) :比较两个数组的长度、元素是否完全相同
- static boolean equals(Object[] a,Object[] a2):比较两个数组的长度、元素是否完全相同
填充数组
- static void fill(int[] a, int val) :用val值填充整个a数组
- static void fill(Object[] a,Object val):用val对象填充整个a数组
- static void fill(int[] a, int fromIndex, int toIndex, int val):将a数组[fromIndex,toIndex)部分填充为val值
- static void fill(Object[] a, int fromIndex, int toIndex, Object val) :将a数组[fromIndex,toIndex)部分填充为val对象