总结自下面链接,转载请使用大佬的博客地址
https://blog.csdn.net/qq_29966203/article/details/105455615?spm=1001.2014.3001.5502

第一章 面向对象

  • 面向对象三大特性?
  • 对this和super的认识?
  • 谈谈权限修饰符的特性?
  • 对java多态的理解 /Java中实现多态的机制是什么?
  • 静态属性和静态方法能被继承吗?静态方法又是否能被重写呢?

面向对象三大特性?

面向对象的三大特性包括:封装、继承、多态。

  • 封装
    封装是给对象提供了隐藏内部属性和行为的能力。通过public,protected,private修饰符来给其他的位于同一个包或者不同包下面对象赋予了不同的访问权限。它可以通过隐藏对象的属性来保护对象内部的状态,并且可以防止对象之间的不良交互,提高安全性。
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
26
27
28
29
30
31
32
33
34
35
36
37
// 对成员变量设置私有访问权限,对信息进行隐藏
// 为每个成员变量提供对外公共方法访问(getter/setter)
public class Person{



private String name;
private int age;

public int getAge(){



return age;
}

public String getName(){



return name;
}

public void setAge(int age){



this.age = age;
}

public void setName(String name){



this.name = name;
}
}
  • 继承
    继承就是子类继承父类的特征和行为,使得子类对象具有父类的属性和方法。子类可以通过继承父类的属性和方法,从而避免存在重复的代码,代码更加简洁,并提高代码的维护性、复用性。Java中使用extends和implements实现继承,
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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// 动物类(父类)
public class Animal {



private String name;
private int id;
public Animal(String myName, int myid) {



name = myName;
id = myid;
}
public void eat(){



System.out.println(name+"正在吃");
}
public void sleep(){



System.out.println(name+"正在睡");
}
}
// 企鹅类(子类)
public class Penguin extends Animal {



public Penguin(String myName, int myid) {



super(myName, myid);
}
}
  • 多态
    多态是同一个行为具有多个不同表现形式或形态的能力。多态就是同一个接口,使用不同的对象而执行不同操作。多态分为编译时多态和运行时多态:编译时多态指方法的重载;运行时多态指程序中定义的对象引用所指向的具体类型在运行期间才确定。运行时多态的三个必要条件:(1)继承(2)重写(3)父类引用指向子类对象。多态可以使程序有良好的扩展,并可以对所有类的对象进行通用处理。
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
26
27
28
29
30
31
32
abstract class Animal {



abstract void eat();
}

class Cat extends Animal {



public void eat() {



System.out.println("吃鱼");
}
public void work() {



System.out.println("抓老鼠");
}
}

public static void main(String[] args) {



Animal a = new Cat(); // 向上转型
a.eat(); // 调用的是 Cat 的 eat
}

重写与重载的区别?
方法的重写(Overriding)和重载(Overloading)是java多态性的不同表现,重写是父类与子类之间多态性的一种表现,重载可以理解成多态的具体表现形式。

重写(overriding) 重载(overloading)
定义 子类根据需要定义新特性,可对父类的方法进行扩展 让类以统一的方式处理不同类型参数的一种手段,通过传递参数的个数和类型选择具体调用哪种方法
范围 继承类 同一类
区别 方法名称、返回值、参数等均相同 方法名称相同,参数列表不同(每个重载方法都有独一无二的参数列表)

对this和super的认识?

this:就是类中指向对象本身的一个特殊引用。
super:向自己超(父)类对象的一个指针,而这个超类指的是离自己最近的一个父类。

谈谈权限修饰符的特性?

权限修饰符 特点 本类 子类 同包类 其他类
public 公共访问
protected 保护(继承+包)访问 ×
protected 私有(无法)访问 × × ×

对java多态的理解 /Java中实现多态的机制是什么?

  • 多态是什么?
    多态是同一个行为具有多个不同表现形式或形态的能力。多态就是同一个接口,使用不同的对象而执行不同操作。
  • 多态分类?
    本质上多态分为两种:
  1. 编译时多态(静态绑定),如重载方法。
  2. 运行时多态(动态绑定),如重写方法。

编译时多态即在程序执行前方法已经被绑定,发生在编译阶段,由编译器实现,绑定的是类信息。java中的方法,只有final,static,private,重载方法和构造方法是静态绑定;所有的变量都是静态绑定。
运行时多态时编译时不确定具体调用哪个具体方法,直到运行时,根据传入的对象信息,才进行绑定相应的方法。发生在运行阶段,绑定的是对象信息。运行时多态通常有两种实现方法:1、子类继承父类(extends)2、类实现接口(implements)。使用多态应该遵循的原则是:声明和定义方法参数时总是优先使用父类类型或接口类型,创建的是实际类型。这样做可以减少代码的耦合,提高代码的可维护性。
运行时多态的必要条件有:继承、重写、父类引用指向子类对象。

  • 多态动态绑定机制?
    类对象方法的调用必须在运行过程中采用动态绑定机制。动态绑定的解题思路:
    首先,根据对象的声明类型(对象引用的类型)找到“合适”的方法。具体步骤如下:
  1. 编译器检查对象的声明类型和方法名称(p.method())
    若p的声明类型为Child类,则编译器会列举出Child类中所有名为method方法和父类继承的method方法,如果子类重写父类方法,则被覆盖;
    若p的声明类型为Parent类,并指向子类对象,则编译器会列举出Parent类中所有名为method方法,如果子类重写父类方法,则被覆盖;
  2. 如果能在声明类型中匹配到方法签名完全一样(参数类型一致)的方法,那么这个方法是最合适的。
  3. 在上一条不能满足的情况下,寻找可以“凑合”的方法。标准就是通过将参数类型进行自动转型之后再进行匹配。

自动转型会匹配最精确的一个参数列表,当一个方法可以接受传递给另一个方法的任何参数,则第二个方法比第一个方法更加精确。

  1. 如果仍然在声明类型中找不到“合适”的方法,则编译阶段就无法通过。
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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
class A {



public String show(D obj){



// 方法一
return ("A and D");
}
public String show(A obj){



// 方法二
return ("A and A");
}
}

class B extends A{



public String show(B obj){



// 方法三
return ("B and B");
}
public String show(A obj){



// 方法四
return ("B and A");
}
}

class C extends B{



}

class D extends
B{



}

public class JavaStudy {



public static void main(String[] args) {



A a1 = new A(); // 创建A类对象,故a1拥有调用方法一和方法二的能力
A a2 = new B(); // 创建A类引用,指向B类对象,故a2拥有调用方法一和方法四的能力;但向上转型不能调用子类中有而父类中没有的方法,故不能调用方法三
B b = new B(); // 创建B类对象(继承A),方法四重写方法二,故b拥有调用方法一、方法三和方法四的能力
C c = new C();
D d = new D();
System.out.println("1--" + a1.show(b)); // 1--A and A 调用方法一
System.out.println("2--" + a1.show(c)); // 2--A and A 调用方法一
System.out.println("3--" + a1.show(d)); // 3--A and D 调用方法二
System.out.println("4--" + a2.show(b)); // 4--B and A 调用方法四
System.out.println("5--" + a2.show(c)); // 5--B and A 调用方法四
System.out.println("6--" + a2.show(d)); // 6--A and D 调用方法一
System.out.println("7--" + b.show(b)); // 7--B and B 调用方法三
System.out.println("8--" + b.show(c)); // 8--B and B 调用方法三
System.out.println("9--" + b.show(d)); // 9--A and D 调用方法一
}

静态属性和静态方法能被继承吗?静态方法又是否能被重写呢?

静态属性、静态方法和非静态的属性都可以被继承和隐藏而不能被重写,因此不能实现多态,不能实现父类的引用可以指向不同子类的对象。
非静态方法可以被继承和重写,因此可以实现多态。
例子:

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
26
27
28
29
30
31
32
33
34
35
36
class A { //父类
public static String staticStr = "A静态属性";
public String nonStaticStr = "A非静态属性";
public static void staticMethod(){
System.out.println("A静态方法");
}
public void nonStaticMethod(){
System.out.println("A非静态方法");
}
}

class B extends A{//子类B
public static String staticStr = "B改写后的静态属性";
public String nonStaticStr = "B改写后的非静态属性";
public static void staticMethod(){
System.out.println("B改写后的静态方法");
}
public void nonStaticMethod(){
System.out.println("B改写后的非静态方法");
}

public class JavaStudy {
public static void main(String[] args) {
A a = new B();
B b = new B();
System.out.println(a.nonStaticStr); // A非静态属性
System.out.println(a.staticStr); // A静态属性
a.staticMethod(); // A静态方法
a.nonStaticMethod(); // B改写后的非静态方法

System.out.println(b.nonStaticStr); // B改写后的非静态属性
System.out.println(b.staticStr); // B改写后的静态属性
b.staticMethod(); // B改写后的静态方法
b.nonStaticMethod(); // B改写后的非静态方法
}
}

第二章 字符串String & 数组 & 数据类型

  • 说说Java中的8大基本类型 & 内存中占有的字节 & 初始值?
  • 知道float和double类型为什么会出现精度丢失的情况吗?
  • JAVA基本数据类型与封装类型的区别?
  • 什么是拆箱 & 装箱,能给我举栗子吗?
  • 能说说多维数组在内存上是怎么存储的吗?
  • 你对数组二次封装过吗?说说封装了什么?
  • String
    • 原理 & 不可变性
    • String && StringBuilder && StringBuffer
    • 内存中存储
    • 字符串拼接方式 & 比较
    • String a = “a”+”b”+”c”;在内存中创建了几个对象?

说说Java中的8大基本类型 & 内存中占有的字节 & 初始值?

bit(位):表示信息的最小单位,是二进制数的一位包含的信息;
byte(字节):用来计量存储容量的一种计量单位;
1 byte = 8 bit(1个字节等于8位);

基本类型 占据空间大小 取值范围 默认值
布尔型——boolean 不确定 true/false false
字节型——byte 1个字节 -128~127 0
整型——int 4个字节 -2^31 ~ 2^31-1(-2147483648~2147483647) 0
短整型——short 2个字节 -2^15 ~ 2^15-1(-32768~32767) 0
长整型——long 8个字节 -2^63 ~ 2^63-1(-9223372036854775808 ~ 9223372036854775807) 0
字符型——char 2个字节 0~2^16-1(0 ~ 65535无符号) \u0000
单精度浮点型——float 4个字节 -2^128 ~ 2^128 0.0F
双精度浮点型——double 8个字节 -2^1024 ~ 2^1024 0.0D

(1)带符号数(正数/负数)在计算机中存储方式?
原码:原码就是符号位加上真值的绝对值, 即用第一位表示符号, 其余位表示值。
补码:正数的补码就是其本身。负数的补码是在其原码的基础上, 符号位不变, 其余各位取反, 最后+1. (即在反码的基础上+1)
补码是计算机存储带符号数的方式,可以解决0的符号问题以及两个编码问题。
[为什么byte类型(8位二进制表示)的取值范围为-128127?][byte_8_-128_127]
0用[0000 0000]表示,-128用[1000 0000]表示。byte类型1个字节,8位二进制,范围为[1000 0000]
[0111 1111]

(2)浮点数(小数)在计算机中存储方式?

  1. 小数(浮点数)的二进制转换
    78.375 的整数部分:
    pic_ee10e58d.png
    小数部分:
    pic_aaa49e2a.png
    所以,78.375 的二进制形式就是 1001110.011
    然后,使用二进制科学记数法,有
    pic_134a8cf3.png
    注意,转换后用二进制科学记数法表示的这个数,有底有指数有小数部分,这个就叫做浮点数。
  2. 浮点数在计算机中存储
    在计算机中,保存这个数使用的是浮点表示法,分为三大部分:
    第一部分用来存储符号位(sign),用来区分正负,这里是 0,表示正数
    第二部分用来存储指数(exponent),这里的指数是十进制的 6
    第三部分用来存储小数(fraction),这里的小数部分是 001110011
    pic_7f0e5e93.png
    指数位决定了大小范围,因为指数位能表示的数越大则能表示的数越大,而小数位决定了计算精度,因为小数位能表示的数越大,则能计算的精度越大。
  • float类型是32位,是单精度浮点表示法:
    符号位占用1位,指数位占用 8 位,小数位占用 23 位。
    float 的小数位只有 23 位,即二进制的 23 位,能表示的最大的十进制数为 2 的 23 次方,即 8388608,即十进制的 7 位,严格点,精度只能百分百保证十进制的 6 位运算。
  • double 类型是 64 位,是双精度浮点表示法:
    符号位占用 1 位,指数位占用 11 位,小数位占用 52 位。
    double 的小数位有 52 位,对应十进制最大值为 4 503 599 627 370 496,这个数有 16 位,所以计算精度只能百分百保证十进制的 15 位运算。
  1. 指数位的偏移与无符号表示
    float 的指数部分是 8 位,则指数的取值范围是 -126 到 +127,为了消除负数带来的实际计算上的影响(比如比较大小,加减法等),可以在实际存储的时候,需要把指数转换为无符号整数,即给指数做一个简单的映射,加上一个偏移量,比如float的指数偏移量为 127,这样就不会有负数出现了。比如:指数如果是 6,则实际存储的是 6+127=133,即把 133 转换为二进制之后再存储。
    对应的 double 类型,存储的时候指数偏移量是 1023。
  2. 举例求78.375浮点数表示
    所以用float类型来保存十进制小数78.375的话,需要先转换成浮点数,得到符号位和指数和小数部分。符号位是0,指数位是6+127=133,二进制表示为10 000 101,小数部分是001110011,不足部分请自动补0。
    连起来用 float 表示,加粗部分是指数位,最左边是符号位 0,代表正数:
    0 10000101 001110011 00000 00000 0000

知道float和double类型为什么会出现精度丢失的情况吗?

(1)浮点型数据精度丢失的原因
将十进制浮点数转换为二进制浮点数时,小数的二进制有时也是不可能精确的。
就如同十进制不能准确表示1/3,二进制也无法准确表示1/10,而double类型存储尾数部分最多只能存储52位,于是,计算机在存储该浮点型数据时,便出现了精度丢失。
例:十进制小数如何转化为二进制数

1
2
3
4
5
6
7
8
9
10
算法是乘以2直到没有了小数为止。举个例子,0.9表示成二进制数
0.9*2=1.8 取整数部分 1
0.8(1.8的小数部分)*2=1.6 取整数部分 1
0.6*2=1.2 取整数部分 1
0.2*2=0.4 取整数部分 0
0.4*2=0.8 取整数部分 0
0.8*2=1.6 取整数部分 1
0.6*2=1.2 取整数部分 0
.........
0.9二进制表示为(从上往下): 1100100100100......

注意:上面的计算过程循环了,也就是说*2永远不可能消灭小数部分,这样算法将无限下去。很显然,小数的二进制表示有时是不可能精确的 。
因此将11.9化为二进制后大约是” 1011. 1110011001100110011001100…”。
(2)浮点型数据精度丢失的解决方法
商业运算中应用场景:例如某用户有10块钱,买了一件商品花了8.8,理应剩下1.2元。但却无法继续购买价格为1.2元的商品。

1
2
3
4
5
6
double d1 = 10;
double d2 = 8.8;
double c = d1 - d2;
System.out.println("d1 - d2 = "+c);
// 输出
d1 - d2 = 1.1999999999999993
  • 解决方法1
    在设计数据库表的时候可以将price字段类型设置为int(oracle应设置为number)类型,而在实体中对应的属性单位应该表示为分(即精确到0.00)或者角(即0.0),但一般情况下money会精确到分。
    如:商品的价格为12.53元(精确到分),在数据库中price字段对应的数据为应该为1253。使用这种方法需要编程人员自己在程序中收懂转换,当然也可以封装为一个工具类。
  • 解决方法2
    使用java提供的BigDecimal类。该类封装在java.math.BigDecimal中。该类的构造器有很多,但在使用浮点类型计算时一定要使用String构造器来实例BigDecimal对象。
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
26
27
//加法
public static BigDecimal add(double v1,double v2){
BigDecimal b1 = new BigDecimal(Double.toString(v1));//这里使用的是String构造器,将double转换为String类型
BigDecimal b2 = new BigDecimal(Double.toString(v2));
return b1.add(b2);
}

//减法
public static BigDecimal sub(double v1,double v2){
BigDecimal b1 = new BigDecimal(Double.toString(v1));//同上
BigDecimal b2 = new BigDecimal(Double.toString(v2));
return b1.subtract(b2);//这是b1-b2,可以理解为从b1截取b2
}

//乘法
public static BigDecimal mul(double v1,double v2){
BigDecimal b1 = new BigDecimal(Double.toString(v1));//同上
BigDecimal b2 = new BigDecimal(Double.toString(v2));
return b1.multiply(b2);
}

//除法
public static BigDecimal div(double v1,double v2){
BigDecimal b1 = new BigDecimal(Double.toString(v1));
BigDecimal b2 = new BigDecimal(Double.toString(v2));
return b1.divide(b2,2,BigDecimal.ROUND_HALF_UP); //四舍五入,保留2位小数,除不尽的情况
}

JAVA基本数据类型与封装类型的区别?

封装类(如Integer)是基本数据类型(如int)的包装类。

封装类(Integer) 基本类型(int)
存储数据 封装类本质是对象的引用,需实例化后使用。实际上是生成一个指向该对象的引用(存储对象地址) 值(基本数据类型是一个变量,直接存放数值)
属性和方法 封装类有属性和方法,利用这些方法和属性来处理数据,如Integer.parseInt(Strings) 基本数据类型都是final修饰的,不能继承扩展新的类、新的方法
默认值 null 0
存储位置 封装类的对象引用存储在栈中,实际的对象存储在堆中
使用场景 更好地处理数据之间的转换 速度快(不涉及对象的构造与回收)

什么是拆箱 & 装箱,能给我举栗子吗?

封装类(如Integer)是基本数据类型(如int)的包装类。装箱就是 自动将基本数据类型转换为包装器类型;拆箱就是 自动将包装器类型转换为基本数据类型。

  1. 装箱(基本数据类型->封装类)
1
Integer i = 10; // 实际上执行Integer.valueOf(10);
  1. 拆箱(封装类->基本数据类型)
1
2
Integer i = 10; //装箱 
int t = i; //拆箱,实际上执行了 int t = i.intValue();

能说说多维数组在内存上是怎么存储的吗?

在java中数组也是对象。因此,对象存放在内存中的原理同样适用于数组。
当创建一个数组时,在堆中会为数组对象分配一段内存空间,并返回一个引用。数组对象的引用存放在栈中,实际的数组对象存放在堆中。
多维数组在内存中存储方式:
pic_131587cd.png

你对数组二次封装过吗?说说封装了什么?

使用Java一维数组,仿照ArrayList源码,封装相关构造、获取元素个数、容量大小、判空、增删查改等功能。
[对Java一维数组E[]自定义ArrayList集合][Java_E_ArrayList]
下面是部分实现:

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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
/**
* 通过对数组封装实现自己的Array类
*/
public class MyArrayList<E> {



private E[] data; // 定义一个整型的一维数组的成员变量
private int size; // 数组中元素个数
// 获取数组中元素的个数
public int getSize() {



return size;
}
// 判断数组是否为空
public boolean isEmpty() {



return size == 0;
}
// 向数组的第index位置插入元素e
public void add(int index, E e) {



if (index < 0 || index > size)
throw new IllegalArgumentException("Add failed. Require index >= 0 and index <= size.");

if (size - data.length >= 0) {



int newCapacity = data.length + (data.length >> 1); // 扩容1.5倍
resize(newCapacity);
}

for (int i = size - 1; i >= index; i--)
data[i + 1] = data[i];

data[index] = e;
size++;
}
// 动态数组扩容 newCapacity 扩容长度
private void resize(int newCapactity) {



E[] newData = (E[]) new Object[newCapactity];
for (int i = 0; i < size; i++) {



newData[i] = data[i];
}
data = newData;
newData = null;
}
}

String

原理 & 不可变性

  • 内部
    在 Java 8 中,String 内部使用 char 数组存储数据。
1
2
3
4
5
6
7
8
public final class String
implements java.io.Serializable, Comparable<String>, CharSequence {



/** The value is used for character storage. */
private final char value[];
}

在 Java 9 之后,String 类的实现改用 byte 数组存储字符串,同时使用 coder 来标识使用了哪种编码。

1
2
3
4
5
6
7
8
9
10
11
public final class String
implements java.io.Serializable, Comparable<String>, CharSequence {



/** The value is used for character storage. */
private final byte[] value;

/** The identifier of the encoding used to encode the bytes in {@code value}. */
private final byte coder;
}
  • 不可变性
    String对象是不可变的,即对象的状态(成员变量)在对象创建之后不再改变。
    (一)不可变性实现
    由String内部构造:
    (1)String 被声明为 final,因此它不可被继承。(Integer 等包装类也不能被继承)
    (2)value 数组被声明为 final,这意味着 value 数组初始化之后就不能再引用其它数组。
    (3)String 内部没有改变 value 数组的方法。
    可知String是不可变的。

补充:不可变的实现:
String类被final修饰,保证类不被继承。
String内部所有成员都设置为私有变量,并且用final修饰符修饰,保证成员变量初始化后不被修改。
不提供setter方法改变成员变量,即避免外部通过其他接口修改String的值。
通过构造器初始化所有成员(value[])时,对传入对象进行深拷贝(deep copy),避免用户在String类以外通过改变这个对象的引用来改变其内部的值。
在getter方法中,不要直接返回对象引用,而时返回对象的深拷贝,防止对象外泄。

(二)不可变的好处

  1. 满足字符串常量池的需要(有助于共享)
    可以将字符串对象保存在字符串常量池中以供与字面值相同字符串对象共享。
    如果一个 String 对象已经被创建过了,那么就会从 String Pool 中取得引用。只有 String 是不可变的,才可能使用 String Pool。
    如果String对象是可变的,那就不能这样共享,因为一旦对某一个String类型变量引用的对象值改变,将同时改变一起共享字符串对象的其他 String类型变量所引用的对象的值。
  2. 线程安全考虑
    同一个字符串实例可以被多个线程共享。字符串的不变性保证字符串本身便是线程安全的。
  3. 支持hash映射和缓存
    因为字符串是不可变的,所以在它创建的时候hashcode就被缓存了,不需要重新计算。这就使得String很适合作为Map中的键,字符串的处理速度要快过其它的键对象。这就是HashMap中的键往往都使用字符串。

缺点:String对象不适用于经常发生修改的场景,会创建大量的String对象。
(三)String 的 “改变”?

1
2
3
4
5
6
7
public static void main(String[] args) {
String s = "ABCDEF";
System.out.println("s = " + s);

s = "123456";
System.out.println("s = " + s);
}

String的改变实际上是创建了一个新的String对象”123456”,并将引用指向了这个新的对象,同时原来的String对象”ABCDEF”并没有发生改变,仍保存在内存中。
pic_a0c7532e.png
(四)String 的不可变 真的不可变?
通过反射获取value数组直接改变内存数组中的数据是可以修改所谓的”不可变”对象的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public static void reflectString() throws Exception{
// 创建字符串"ABCDEF"并赋给引用s
String s = "ABCDEF";
System.out.println("s = " + s); // s = ABCDEF

Field valueField = s.getClass().getDeclaredField("value"); // 获取String类中value字段
valueField.setAccessible(true); // 改变value属性的访问权限
char[] value = (char[]) valueField.get(s); // 获取s对象上的value属性的值
value[0] = 'a'; // 改变value所引用的数组中的某个位置字符
value[2] = 'c';
value[4] = 'e';

System.out.println("s = " + s); // s = aBcDeF
}

String && StringBuilder && StringBuffer

  • 可变性
    String 不可变
    StringBuffer 和 StringBuilder 可变
  • 线程安全
    String 不可变,因此是线程安全的
    StringBuilder 不是线程安全的
    StringBuffer 是线程安全的,内部使用 synchronized 进行同步
String StringBuffer StringBuilder
可变性 String StringBuffer StringBuilder
线程安全 安全(不可变) 安全(Synchronized) 不安全
执行效率 低(Synchronized)
适用场景 操作少量的数据,不需要频繁拼接 多线程操作大量数据
只有在对线程安全要求高的情况下使用StringBuffer
单线程操作大量数据
备注 在字符串修改/拼接时,String是不可变的对象, 因此在每次对String 类型进行改变的时候,都会生成一个新的 String 对象,然后将指针指向新的 String 对象。不仅效率低下,还会大量浪费内存空间。 使用 StringBuffer/StringBuilder 类时,每次都会对 StringBuffer/StringBuilder 对象本身进行修改操作,而不产生新的未使用对象。

内存中存储

对于String,其对象的引用都是存储在栈中的。
java中对String对象特殊对待,所以在heap区域分成了两块,一块是字符串常量池(String constant pool),用于存储java字符串常量对象,另一块用于存储普通对象及字符串对象。

  1. “abc”字符串常量/s.intern()——StringPool
    编译期已经创建好(直接用双引号定义的”abc”)的就存储在字符串常量池中。即jvm会在String constant pool中创建对象。字符串常量池(String Pool)保存着所有字符串字面量(literal strings),这些字面量在编译时期就确定。不仅如此,还可以使用 String 的 intern() 方法在运行过程中将字符串添加到 String Pool 中。String Pool用于共享字符串字面量,防止产生大量String对象导致OOM。
    jvm会首先在String constant pool 中寻找是否已经存在(equals)“abc”常量,如果没有则创建该常量,并且将此常量的引用返回给String a;如果已有”abc” 常量,则直接返回String constant pool 中“abc” 的引用给String a。
    当一个字符串调用 intern() 方法时,如果 String Pool 中已经存在一个字符串和该字符串值相等(使用 equals() 方法进行确定),那么就会返回 String Pool 中字符串的引用;否则,就会在 String Pool 中添加一个新的字符串,并返回这个新字符串的引用。
    equals相等(指向同一引用)的字符串在常量池中永远只有一份。

intern() 方法返回字符串对象的规范化表示形式,即一个字符串,内容与此字符串相同,但一定取自具有唯一字符串的池。
它遵循以下规则:对于任意两个字符串 s 和 t,当且仅当 s.equals(t) 为 true 时,s.intern() == t.intern() 才为 true。

  1. new String(“abc”)
    运行期(new出来的 new String(s))才能确定的就存储在堆中。即jvm会直接在heap中非String constant pool 中创建字符串对象,然后把该对象引用返回给String b(并且不会把”abc” 加入到String constant pool中)。
    new就是在堆中创建一个新的String对象,不管”abc”在内存中是否存在,都会在堆中开辟新空间。
    equals相等的字符串在堆中可能有多份。
    对于 new String(“abc”),使用这种方式一共会创建两个字符串对象(前提是 String Pool 中还没有 “abc” 字符串对象)。这两个字符串对象指向同一个value数组。
  • “abc” 属于字符串字面量,因此编译时期会在 String Pool 中创建一个字符串对象,指向这个 “abc” 字符串字面量;
  • 而使用 new 的方式会在堆中创建一个字符串对象。
1
2
3
4
5
6
7
8
9
String s1 = new String("aaa");
String s2 = new String("aaa");
System.out.println(s1 == s2); // false,指向堆内不同引用
String s3 = s1.intern();
String s4 = s1.intern();
System.out.println(s3 == s4); // true,指向字符串常量池中相同引用
String s5 = "bbb";
String s6 = "bbb";
System.out.println(s5 == s6); // true,指向字符串常量池中相同引用

字符串拼接方式 & 比较

  • 拼接方式
  1. “+” 拼接
    加号拼接字符串jvm底层其实是调用StringBuilder来实现的,也就是说”a” + “b” + “c”等效于下面的代码片。
1
2
// String d = "a"+"b"+"c";等效于
String d = new StringBuilder().append("a").append("b").append("c").toString();

但并不是说直接用“+”号拼接就可以达到StringBuilder的效率了,因为每次使用 “+”拼接 都会新建一个StringBuilder对象,并且最后toString()方法还会生成一个String对象。在循环拼接十万次的时候,就会生成十万个StringBuilder对象,会产生大量内存消耗。

  1. concat 拼接
    concat其实就是申请一个char类型的buf数组,将需要拼接的字符串都放在这个数组里,最后再创建并返回一个新的String对象。
1
2
3
4
5
6
7
8
9
10
public String concat(String str) {
int otherLen = str.length();
if (otherLen == 0) {
return this;
}
int len = value.length;
char buf[] = Arrays.copyOf(value, len + otherLen);
str.getChars(buf, len);
return new String(buf, true);
}
  1. StringBuilder/StringBuffer append
    这两个类实现append的方法都是调用父类AbstractStringBuilder的append方法,只不过StringBuffer是的append方法加了sychronized关键字,因此是线程安全的。append代码如下,他主要也是利用char数组保存字符,通过ensureCapacityInternal方法来保证数组容量可用还有扩容。
1
2
3
4
5
6
7
8
9
public AbstractStringBuilder append(String str) {
if (str == null)
return appendNull();
int len = str.length();
ensureCapacityInternal(count + len);
str.getChars(0, len, value, count);
count += len;
return this;
}

他扩容的方法的代码如下,可见,当容量不够的时候,数组容量右移1位(也就是翻倍)再加2。

1
2
3
4
5
6
7
8
9
10
private int newCapacity(int minCapacity) {
// overflow-conscious code
int newCapacity = (value.length << 1) + 2;
if (newCapacity - minCapacity < 0) {
newCapacity = minCapacity;
}
return (newCapacity <= 0 || MAX_ARRAY_SIZE - newCapacity < 0)
? hugeCapacity(minCapacity)
: newCapacity;
}
  • 拼接比较
拼接方式 + concat StringBuilder/StringBuffer
原理 jvm采用append优化,每次执行都会新建一个StringBuilder和String对象 申请一个char类型的buf数组,将需要拼接的字符串都放在这个数组里,最后再创建并返回一个新的String对象 利用char数组保存字符,对Stringbuilder/StringBuffer直接修改,不生成新的String对象
比较 最慢且效率最低,适用于书写方便场景 适用于少量字符串拼接(会新建String对象) 适用于多个字符串拼接,当不考虑线程的情况下,StringBuilder效率比StringBuffer(Synchronized)高

String a = “a”+“b”+“c”;在内存中创建了几个对象?

  1. String a=“a”+“b”+”c”在内存中创建几个对象?——1个对象
    String a = “a”+“b”+”c”经过编译器优化后得到的效果为String a = “abc”
    java编译期会进行常量折叠,全字面量字符串相加是可以折叠为一个字面常量,而且是进入常量池的。
    在JAVA虚拟机(JVM)中存在着一个字符串池,其中保存着很多String对象,并且可以被共享使用,因此它提高了效率。由于String类是final的,它的值一经创建就不可改变,因此我们不用担心String对象共享而带来程序的混乱。字符串池由String类维护,我们可以调用intern()方法来访问字符串池。
    对于String a=“abc”;,这行代码被执行的时候,JAVA虚拟机首先在字符串池中查找是否已经存在了值为”abc”的这么一个对象,它的判断依据是String类equals(Object obj)方法的返回值。如果有,则不再创建新的对象,直接返回已存在对象的引用;如果没有,则先创建这个对象,然后把它加入到字符串池中,再将它的引用返回。
    字符串内部拼接:只有使用引号包含文本的方式创建的String对象之间使用“+”连接产生的新对象才会被加入字符串池中。对于所有包含new方式新建对象(包括null)的“+”连接表达式,它所产生的新对象都不会被加入字符串池中,
  2. String s=new String(“abc”)创建了几个对象?——2个对象
    new String(“abc”)可看成”abc”(创建String对象)和new String(String original)(String构造器,创建String对象)2个对象。
    我们正是使用new调用了String类的上面那个构造器方法创建了一个对象,并将它的引用赋值给了str变量。同时我们注意到,被调用的构造器方法接受的参数也是一个String对象,这个对象正是”abc”。

第三章 Java特性与基本语法(1)

  • Objct通用方法?

    • Object.equals()
    • Object.hashCode()
    • Object.toString()
    • Object.clone()
  • [序列化

    • Serializable接口
      • transient 关键字
    • Parcelable接口
  • 内部类

  • Java 异常

    • Java 异常关键字
    • Java 异常体系
    • 自定义Java异常

Objct通用方法?

Object.equals()

(1)equals 定义 & equals 与 == 区别(等价 与 值相等)

  • ==
    基本数据类型(byte,short,char,int,long,float,double,boolean)用比较的是两个数据的值是否相等。
    引用类型(类、接口、数组)用比较的是它们在内存中的存放地址是否相等(两个变量是否引用同一个对象)。
    对象是存放在堆中的,栈中存放的是对象的引用(地址)。因此==是对栈中的值进行比较的。如果要比较堆中对象的内容是否相同,那么就要重写equals方法了。
  • Object.equals(Object obj)
    Object的equals方法主要用于判断引用的对象是否等价(内容相等)。
1
2
3
4
Integer x = new Integer(1);
Integer y = new Integer(1);
System.out.println(x.equals(y)); // true
System.out.println(x == y); // false

(2)equals 的默认实现 & 重写
默认实现:

  • 检查是否为同一个对象的引用,如果是直接返回 true;
  • 检查是否是同一个类型,如果不是,直接返回 false;
  • 将 Object 对象进行转型;
  • 判断每个关键域是否相等。
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
26
27
28
29
30
31
32
33
public class EqualExample {




private int x;
private int y;
private int z;

public EqualExample(int x, int y, int z) {



this.x = x;
this.y = y;
this.z = z;
}

@Override
public boolean equals(Object o) {



if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;

EqualExample that = (EqualExample) o;

if (x != that.x) return false;
if (y != that.y) return false;
return z == that.z;
}
}

覆盖equals方法一般通过比较对象的内容是否相等来判断对象是否相等。如下为String类对equals方法进行重写。(不同类有不同等价方法的实现)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public boolean equals(Object anObject) {
if (this == anObject) { // 参数是否为这个对象的引用
return true; // 若A==B,即是同一个String对象,返回true
}
if (anObject instanceof String) { //参数是否为正确的类型(若对比对象是String类型则继续)
String anotherString = (String)anObject;
//获取关键域,判断关键域是否匹配
int n = count;
if (n == anotherString.count) { // 判断A、B长度是否一样,不一样则返回false
char v1[] = value;
char v2[] = anotherString.value;
int i = offset;
int j = anotherString.offset;
while (n-- != 0) { //逐个字符比较,若有不相等字符,则返回false
if (v1[i++] != v2[j++])
return false;
}
return true;
}
}
return false;
}

Object.hashCode()

(1)定义 & 散列集合 唯一性原理
hashCode方法返回一个hash码(int),主要作用是在对对象进行散列时作为key输入,因此需要每个对象的hashCode尽可能不同,这样才能保证散列的存取性能。事实上,Object类提供的默认实现确保每个对象的hash码不同(在对象的内存地址基础上经过特定算法返回一个hashCode)
hashCode用于配合基于散列的集合一起正常运行,这样的散列集合包括HashSet、HashMap以及HashTable。散列集合中元素不可重复,Java则依据元素的hashCode来判断两个元素是否重复。当集合要添加新的元素时,先调用这个元素的hashCode方法,就一下子能定位到它应该放置的物理位置上。如果这个位置上没有元素,它就可以直接存储在这个位置上,不用再进行任何比较了(放入对象的hashcode与集合中任一元素的hashcode不相等);如果这个位置上已经有元素了(hashcode相等),就调用它的equals方法与新元素进行比较,相同的话就不存,不相同就散列其它的地址。(通过调用equals解决冲突)
[Set/HashSet如何确保它的唯一性?][Set_HashSet]

1
2
3
4
if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {


... }

(2)equals 与 hashCode 重写

  • 等价的两个对象散列值一定相同
    若重写equals(Object obj)方法,有必要重写hashcode()方法,确保通过equals(Object obj)方法判断结果为true的两个对象具备相等的hashcode()返回值。
  • 散列值相同的两个对象不一定等价
    如果equals(Object obj)返回false,即两个对象“不相同”,并不要求对这两个对象调用hashcode()方法得到两个不相同的数。
    为了满足上述规范,覆盖equals方法时总要覆盖hashCode,这样该类才能结合所有基于散列的集合(如HashMap、HashSet、HashTable)一起正常运作。
    下面的代码中,新建了两个等价的对象,并将它们添加到 HashSet 中。我们希望将这两个对象当成一样的,只在集合中添加一个对象,但是因为 EqualExample 没有实现 hashCode() 方法,因此这两个对象的散列值是不同的,最终导致集合添加了两个等价的对象。
1
2
3
4
5
6
7
EqualExample e1 = new EqualExample(1, 1, 1);
EqualExample e2 = new EqualExample(1, 1, 1);
System.out.println(e1.equals(e2)); // true
HashSet<EqualExample> set = new HashSet<>();
set.add(e1);
set.add(e2);
System.out.println(set.size()); // 2

重写实例:Student类同时覆盖hashCode与equals方法

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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
public class Student {
private int age;
private String name;
public Student() {
}
public Student(int age, String name) {
super();
this.age = age;
this.name = name;
}
public int getAge() {
return age;
}
public String getName() {
return name;
}
public void setAge(int age) {
this.age = age;
}
public void setName(String name) {
this.name = name;
}
@Override
public int hashCode() {
// 重写hashCode保证相同的对象(equals)的hashCode相同,不重复存入集合
// 采用数字31作为优质乘子
final int prime = 31;
int result = 1;
// 对所有关键域与优质乘子进行运算
result = prime * result + age;
result = prime * result + ((name == null) ? 0 : name.hashCode());
System.out.println("hashCode : "+ result);
return result;
}
@Override
public boolean equals(Object obj) {
// 重写equals保证姓名、年龄相等为同一对象
// 引用相等 => 相等
// 类型相等 && 所有关键域相等 =>相等
if (this == obj)
return true;
if (obj == null)
return false;
if (getClass() != obj.getClass())
return false;
Student other = (Student) obj;
if (age != other.age)
return false;
if (name == null) {
if (other.name != null)
return false;
} else if (!name.equals(other.name))
return false;
return true;
}

}

(3)散列函数构造
理想的散列函数应当具有均匀性,即不相等的对象应当均匀分布到所有可能的散列值上。这就要求了散列函数要把所有域的值都考虑进来。可以将每个域都当成 R 进制的某一位,然后组成一个 R 进制的整数。R 一般取 31,因为它是一个奇素数,如果是偶数的话,当出现乘法溢出,信息就会丢失,因为与 2 相乘相当于向左移一位。
一个数与 31 相乘可以转换成移位和减法:31*x == (x<<5)-x,编译器会自动进行这个优化。
String hashCode 方法实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
int h = hash;
if (h == 0 && value.length > 0) {



char val[] = value;
for (int i = 0; i < value.length; i++) {



h = 31 * h + val[i];
}
hash = h;
}
return h;

Object.toString()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 类定义
public class ToStringExample {




private int number;

public ToStringExample(int number) {



this.number = number;
}
}
// 调用实现
ToStringExample example = new ToStringExample(123);
System.out.println(example.toString());
// 输出结果
// 默认返回 ToStringExample@4554617c 这种形式,其中 @ 后面的数值为散列码的无符号十六进制表示。
ToStringExample@4554617c

Object.clone()

(1)使用

  • 重写clone()方法
    clone() 是 Object 的 protected 方法,它不是 public,一个类不显式去重写 clone(),其它类就不能直接去调用该类实例的 clone() 方法。
  • 实现Cloneable接口
    Cloneable 接口只是规定,如果一个类没有实现 Cloneable 接口又调用了 clone() 方法,就会抛出 CloneNotSupportedException。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class CloneExample implements Cloneable {



private int a;
private int b;

@Override
public Object clone() throws CloneNotSupportedException {



return super.clone();
}
}

(2)浅拷贝 & 深拷贝
pic_eb9987d2.png

  • 浅拷贝(super.clone())
    拷贝对象和原始对象的引用类型引用同一个对象。
    创建一个新对象,然后将当前对象的非静态字段复制到该新对象,如果字段是值类型(基本类型)的,那么对该字段值执行复制;如果该字段是引用类型(对象、String)的话,则复制引用但不复制引用的对象。因此,原始对象及其副本对象引用同一个对象,如果其中一个对象改变了这个地址,就会影响到另一个对象。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class ShallowCloneExample implements Cloneable {



private int[] arr;
public void set(int index, int value) {



arr[index] = value;
}
@Override
protected ShallowCloneExample clone() throws CloneNotSupportedException {



return (ShallowCloneExample) super.clone();
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
ShallowCloneExample e1 = new ShallowCloneExample();
ShallowCloneExample e2 = null;
try {



e2 = e1.clone();
} catch (CloneNotSupportedException e) {



e.printStackTrace();
}
e1.set(2, 222);
System.out.println(e2.get(2)); // 222

可以看出e1与e2的arr指向了同一个对象

  • 深拷贝(clone() 代替方案)
    拷贝对象和原始对象的引用类型引用不同对象。
    深拷贝会拷贝所有的属性,并拷贝属性指向的动态分配的内存。当对象和它所引用的对象一起拷贝时即发生深拷贝。深拷贝相比于浅拷贝速度较慢并且花销较大。
    (1)实现Cloneable接口进行深拷贝
    实现clone方法,并且在clone方法内部,把该对象引用的其他对象也要clone一份 , 这就要求这个被引用的对象必须也要实现Cloneable接口并且实现clone方法。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
rotected Person clone() {



Person person = null;
try {



person = (Person) super.clone();
person.setEmail(new Email(person.getEmail().getObject(),person.getEmail().getContent()));
} catch (CloneNotSupportedException e) {



e.printStackTrace();
}
return person;
}

(2)自定义拷贝函数
使用 clone() 方法来拷贝一个对象即复杂又有风险,它会抛出异常,并且还需要类型转换。Effective Java 书上讲到,最好不要去使用 clone(),可以使用拷贝构造函数或者拷贝工厂来拷贝一个对象。

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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
public class CloneConstructorExample {




private int[] arr;

public CloneConstructorExample() {



arr = new int[10];
for (int i = 0; i < arr.length; i++) {



arr[i] = i;
}
}

public CloneConstructorExample(CloneConstructorExample original) {



// 为引用类型重新在堆中分配内存
// 使拷贝对象与原始对象的引用类型指向不同的对象
arr = new int[original.arr.length];
for (int i = 0; i < original.arr.length; i++) {



arr[i] = original.arr[i];
}
}

public void set(int index, int value) {



arr[index] = value;
}

public int get(int index) {



return arr[index];
}
}
CloneConstructorExample e1 = new CloneConstructorExample();
CloneConstructorExample e2 = new CloneConstructorExample(e1);
e1.set(2, 222);
System.out.println(e2.get(2)); // 2

(3)利用序列化完成对象深拷贝
将原对象(需要实现Serializable接口)写入到一个字节流中(outputStream.writeObject),再从字节流中将其读取出来创建一个新的对象(inputStream.readObject)

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
26
27
28
29
30
31
32
33
34
public class CloneUtils {



@SuppressWarnings("unchecked")
public static <T extends Serializable> T clone(T obj){



T cloneObj = null;
try {



//写入字节流
ByteArrayOutputStream out = new ByteArrayOutputStream();
ObjectOutputStream obs = new ObjectOutputStream(out);
obs.writeObject(obj);
obs.close();
//分配内存,写入原始对象,生成新对象
ByteArrayInputStream ios = new ByteArrayInputStream(out.toByteArray());
ObjectInputStream ois = new ObjectInputStream(ios);
//返回生成的新对象
cloneObj = (T) ois.readObject();
ois.close();
} catch (Exception e) {



e.printStackTrace();
}
return cloneObj;
}
}

(3)如何深拷贝一个List集合
Json拷贝法:将List集合映射为一个Json字符串,再解析成一个List集合
序列化法:将List集合写入字节流,再读取出来解析成一个List集合
重写clone法:重写clone方法并遍历即可

序列化

序列化是一种处理对象流的机制,用于将对象状态转换为字节流,即将对象转换为可存储或可传输的状态。我们可以对流化后的对象进行读写操作,或在网络中传输。从字节流转换为对象的过程称为反序列化。
序列化的方式包括两种:

  1. 实现Serializable接口(Java自带)
  2. 实现Parcelable接口(Android专用、效率更高)
    Parcelable方式的实现原理是将一个完整的对象进行分解,而分解后的每一部分都是Intent所支持的数据类型,从而实现传递对象的功能。

序列化常用场景:

  1. 将对象状态存储到文件或数据库中
  2. 在网络上传输对象
  3. 在Activity/Fragment之间通过Intent传递对象(Android)

注意:

  1. 静态成员变量属于类不属于对象,所以不会参与序列化(对象序列化保存的是对象的“状态”,也就是它的成员变量,因此序列化不会关注静态变量)
  2. 用transient关键字标记的成员变量不参与序列化(在被反序列化后,transient 变量的值被设为初始值,如 int 型的是 0,对象型的是 null)

Serializable接口

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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
import java.io.Serializable;

public class StudentSerializable implements Serializable {




// 指定serialVersionUID,
// 因为原则上序列化后的数据中的serialVersionUID只有和当前类的serialVersionUID相同时才能被正常的反序列化
private static final long serialVersionUID = 10000000000000000L;

private int Uid;
private String Name ;

public int getUid() {



return Uid;
}
public void setUid(int uid) {



Uid = uid;
}
public String getName() {



return Name;
}
public void setName(String name) {



Name = name;
}
@Override
public String toString() {



return "StudentSerializable [Uid=" + Uid + ", Name=" + Name + "]";
}
}

// 使用 文件传输
oos = new ObjectOutputStream(new FileOutputStream(fullFilename.getAbsoluteFile()));
oos.writeObject(stu);

ois = new ObjectInputStream(new FileInputStream(fullFilename.getAbsoluteFile()));
StudentSerializable newStu = (StudentSerializable) ois.readObject();

transient 关键字

只要一个类实现了Serilizable接口,这个类的所有属性和方法都会自动序列化。
为了安全起见,不希望在网络操作(主要涉及到序列化操作,本地序列化缓存也适用)中被传输,这些信息对应的变量就可以加上transient关键字。换句话说,这个字段的生命周期仅存于调用者的内存中而不会写到磁盘里持久化。

  • 一旦变量被transient修饰,变量将不再是对象持久化的一部分,该变量内容在序列化后无法获得访问。
  • transient关键字只能修饰变量,而不能修饰方法、类。
  • 一个静态变量不管是否被transient修饰,均不能被序列化。
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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.io.Serializable;

/**
* @description 使用transient关键字不序列化某个变量
* 注意读取的时候,读取数据的顺序一定要和存放数据的顺序保持一致
*/
public class TransientTest {




public static void main(String[] args) {




User user = new User();
user.setUsername("Alexia");
user.setPasswd("123456");

System.out.println("read before Serializable: ");
System.out.println("username: " + user.getUsername());
System.err.println("password: " + user.getPasswd());

try {



ObjectOutputStream os = new ObjectOutputStream(
new FileOutputStream("C:/user.txt"));
os.writeObject(user); // 将User对象写进文件
os.flush();
os.close();
} catch (FileNotFoundException e) {



e.printStackTrace();
} catch (IOException e) {



e.printStackTrace();
}
try {



// 在反序列化之前改变username的值
User.username = "jmwang";

ObjectInputStream is = new ObjectInputStream(new FileInputStream(
"C:/user.txt"));
user = (User) is.readObject(); // 从流中读取User的数据
is.close();

System.out.println("\nread after Serializable: ");
System.out.println("username: " + user.getUsername());
System.err.println("password: " + user.getPasswd());

} catch (FileNotFoundException e) {



e.printStackTrace();
} catch (IOException e) {



e.printStackTrace();
} catch (ClassNotFoundException e) {



e.printStackTrace();
}
}
}

class User implements Serializable {



private static final long serialVersionUID = 8294180014912103005L;

public static String username;
private transient String passwd;

public String getUsername() {



return username;
}

public void setUsername(String username) {



this.username = username;
}

public String getPasswd() {



return passwd;
}

public void setPasswd(String passwd) {



this.passwd = passwd;
}

}

输出

1
2
3
4
5
6
7
read before Serializable: 
username: Alexia
password: 123456

read after Serializable:
username: jmwang
password: null

Parcelable接口

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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
import android.os.Parcel;
import android.os.Parcelable;

public class StudentParcelable implements Parcelable{




private int Uid;
private String Name ;
private Book book ;

// 省略 构造函数 & getter & setter

//功能:返回当前对象的内容描述,如果含有文件描述符,返回1
//即CONTENTS_FILE_DESCRIPTOR
//几乎所有情况都会返回0
@Override
public int describeContents() {



// TODO Auto-generated method stub
return 0;
}

/**
* 序列化功能由writeToParcel完成,最终通过Parcel的一系列Write方法完成
*/
//功能:将当前对象写入序列化结构中,其中flags标识有两种值,0或1
//为1时标识当前对象需要作为返回值返回,不能立刻释放资源,即PARCELABLE_WRITE_RETURN_VALUE
//不过几乎所有情况都为0
@Override
public void writeToParcel(Parcel dest, int flags) {



// TODO Auto-generated method stub
dest.writeInt(Uid);
dest.writeString(Name);
dest.writeParcelable(book, 0);
}

/**
* 反序列化由CREATOR来完成,其内部标明了如何创建序列化对象和数组
* 并通过Parcel的一系列read方法来完成反序列化
*/
public StudentParcelable(Parcel source){



Uid = source.readInt();
Name = source.readString();

//注意:book是一个可序列化对象,所以它的反序列化过程需要传递当前线程的上下文类加载器
//否则会报找不到类的错误
book = source.readParcelable(Thread.currentThread().getContextClassLoader());
}

public static final Parcelable.Creator<StudentParcelable> CREATOR = new Parcelable.Creator<StudentParcelable>() {




//功能: 从Parcel容器中读取传递数据值,封装成Parcelable对象返回逻辑层。
@Override
public StudentParcelable createFromParcel(Parcel source) {



// TODO Auto-generated method stub
return new StudentParcelable(source);
}

//功能:创建一个类型为T,长度为size的数组,仅一句话(return new T[size])即可。方法是供外部类反序列化本类数组使用。
@Override
public StudentParcelable[] newArray(int size) {



// TODO Auto-generated method stub
return new StudentParcelable[size];
}
};
}

/// 使用 组件间(Activity)数据传输
Intent intent = new Intent(this,Second.class);
StudentParcelable stu = new StudentParcelable(001,"fish");
intent.putExtra("student", stu);
startActivity(intent);

Intent intent = getIntent();
StudentParcelable stu = (StudentParcelable) intent.getParcelableExtra("student");
序列化接口 Serializable Parcelable
作用 为了保存对象的属性到本地文件、数据库、网络流、rmi以方便数据传输,当然这种传输可以是程序内的也可以是两个程序间的 因为Serializable效率过慢,为了在程序内不同组件间以及不同Android程序间(AIDL)高效的传输数据而设计,这些数据仅在内存中存在,Parcelable是通过IBinder通信的消息的载体
性能 & 场景 Serializable可将数据持久化方便保存,所以在需要保存或网络传输数据时选择Serializable Parcelable的性能比Serializable好,在内存开销方面较小,所以在内存间数据传输时推荐使用Parcelable,如activity间传输数据
实现 类只需要实现Serializable接口,并提供一个序列化版本id(serialVersionUID)即可对类的所有属性和方法自动序列化 需要实现writeToParcel、describeContents函数以及静态的CREATOR变量,实际上就是将如何打包和解包的工作自己来定义,而序列化的这些操作完全由底层实现
高级功能 不保存静态变量,可以使用Transient关键字对部分字段不进行序列化,也可以覆盖writeObject、readObject方法以实现序列化过程自定义

内部类

[菜鸟教程:Java 内部类详解][Java 4]
在Java中,定义在类内部的类被称为内部类。设计内部类的好处是:

  • 更好地封装:内部类中的属性和方法与其他类隔离,且即时是外部类也不能直接访问,相反内部类可以直接访问外部类的属性和方法。
  • 实现多继承:每个内部类都能独立地实现接口。无论外部类是否已实现了某个接口,对内部类都没有影响。

内部类一般来说包括这四种:成员内部类、局部内部类、匿名内部类和静态内部类。

  1. 成员内部类:成员内部类定义为位于另一个类的内部,类似于一个成员。成员内部类可以无条件访问外部类的所有成员属性和成员方法(内部类编译后会默认持有外部类对象的一个引用,因此可以在成员内部类中随意访问外部类的成员。)。成员内部类是依附外部类而存在的,因此要创建成员内部类的对象,前提是必须存在一个外部类的对象(否则所持有的外部类对象的引用无法初始化赋值,也无法创建内部类对象)。
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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
class Circle {


// 外部类

private double radius = 0;

public Circle(double radius) {



this.radius = radius;
// 外部类如果想调用内部类的成员方法,必须先创建成员内部类的对象,再进行访问
getDrawInstance().drawSahpe();
}

private Draw getDrawInstance() {



return new Draw();
}

class Draw {


//内部类
public void drawSahpe() {



System.out.println(radius); //成员内部类可以无条件访问外部类的所有成员属性和成员方法(包括private成员和静态成员)
}
}
}

public class Main{



public static void main(String[] args) {



//成员内部类是依附外部类而存在的,因此要创建成员内部类的对象,前提是必须存在一个外部类的对象
Circle circle = new Circle(5);
Circle.Draw draw = circle.new Draw();
draw.drawShape();
}
}

编译器在进行编译的时候,会将成员内部类单独编译成一个字节码文件:
pic_09a296f8.png
反编译 Outter$Inner.class 文件得到下面信息:

1
2
3
4
5
// 编译器会默认为成员内部类添加了一个指向外部类对象的引用
final com.cxh.test2.Outter this$0;
// 编译器会为内部类的构造方法默认添加一个参数,该参数的类型为指向外部类对象的一个引用
// 所以成员内部类中的 Outter this&0 指针便指向了外部类对象,因此可以在成员内部类中随意访问外部类的成员。
public com.cxh.test2.Outter$Inner(com.cxh.test2.Outter);
  1. 局部内部类:局部内部类是定义在一个方法或者一个作用域里面的类,它和成员内部类的区别在于局部内部类的访问仅限于方法内或者该作用域内;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Man{



public Man(){


}

public People getWoman(){



class Woman extends People{


//局部内部类
int age =0;
}
return new Woman();
}
}
  • 为什么局部内部类访问局部变量需要final?
    局部内部类引用局部变量,不添加final,会出现生命周期不同,导致非法引用问题。因为非final的局部变量的生命周期比局部内部类的生命周期短,当方法执行结束,非final的局部变量会被销毁,而局部内部类对局部变量的引用依然存在,当局部内部类调用局部变量时,会出错,出现非法引用。
    而且直接拷贝会出现数据不同步问题,所以使用final(若定义为final,即拷贝了一个变量的副本,提供给局部内部类,这个副本的生命周期和局部内部类一样长,并且这个副本不可修改,保证了数据的同步)。
    故使用final可以保证合法引用,而且数据不可修改。
  1. 匿名内部类:匿名内部类是直接使用 new 来生成一个对象的引用,创建匿名内部类时它会立即创建一个该类的实例,且仅能被使用一次,所以匿名内部类是不能够被重复使用;
1
2
3
4
5
6
7
8
9
10
11
12
13
scan_bt.setOnClickListener(new OnClickListener() {



@Override
public void onClick(View v) {



// TODO Auto-generated method stub

}
});
  1. 静态内部类:用static修饰的内部类。静态内部类是不需要依赖于外部类的,不需要创建外部类,便能创建内部类。并且它不能使用外部类的非static成员变量或者方法,因为外部类的非static成员必须依附于具体的对象。(不持有外部类对象的引用)
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
26
27
28
29
30
31
32
33
34
35
36
public class Test {



public static void main(String[] args) {



// 不需要存在外部类对象
Outter.Inner inner = new Outter.Inner();
}
}

class Outter {



public Outter() {




}

static class Inner {



public Inner() {




}
}
}

Java 异常

Java中异常体系是Java提供的一种定位错误以及响应错误的一种机制。
Java异常机制可以使程序中异常处理代码和正常业务代码分离,保证程序代码更加优雅,并提高程序健壮性。

Java 异常关键字

  • try
    用于监听。将要被监听的代码(可能抛出异常的代码)放在try语句块之内,当try语句块内发生异常时,异常就被抛出。
  • catch
    用于捕获异常。catch用来捕获try语句块中发生的异常。
  • finally
    finally语句块总是会被执行。它主要用于回收在try块里打开的物理资源(如数据库连接、网络连接和磁盘文件)。只有finally块,执行完成之后,才会回来执行try或者catch块中的return或者throw语句,如果finally中使用了return或者throw等终止方法的语句,则就不会跳回执行,直接停止。
    (1)被finally控制的语句体一定会执行(特殊情况:在执行到finally之前jvm退出,如:System.exit(0)),用于释放资源,在IO流操作和数据库操作中会见到。
    (2)finally块中的内容会先于try中的return语句执行(return执行前会将finally语句执行完再执行return 进行返回)
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
26
27
28
29
30
31
32
33
34
35
public static void main(String[] args) {



try {



int i = 10/0;
System.out.println("i="+i);
} catch (ArithmeticException e) {



System.out.println("Caught Exception");
System.out.println("e.getMessage(): " + e.getMessage());
System.out.println("e.toString(): " + e.toString());
System.out.println("e.printStackTrace():");
e.printStackTrace();
}finally{



System.out.println("run finally");
}
}

// 输出
Caught Exception
e.getMessage(): / by zero
e.toString(): java.lang.ArithmeticException: / by zero
e.printStackTrace():
java.lang.ArithmeticException: / by zero
at Demo1.main(Demo1.java:6)
run finally

在try语句块中有除数为0的操作,该操作会抛出java.lang.ArithmeticException异常。通过catch,对该异常进行捕获。
观察结果我们发现,并没有执行System.out.println(“i=”+i)。这说明try语句块发生异常之后,try语句块中的剩余内容就不会再被执行了。
finally语句块中的语句总是会执行。

  • 面试:final/finally/finalize的作用?
  • finally修饰符(关键字)
    在Java中,final关键字可以用来修饰类、方法和变量。
  1. 修饰类
    当用final修饰一个类时,表明这个类不能被继承。
  2. 修饰方法
    当用final修饰一个方法时,该方法是不能被子类所覆盖的。
  3. 修饰变量
    当final修饰一个基本数据类型时,表示该基本数据类型的值一旦在初始化后便不能发生变化;如果final修饰一个引用类型时,则在对其初始化之后便不能再让其指向其他对象了,但该引用所指向的对象的内容是可以发生变化的。本质上是一回事,因为引用的值是一个地址,final要求值,即地址的值不发生变化。
    final变量必须要初始化,其初始化可在变量定义时直接赋值,也可在构造函数中赋值或者作为参数传递。且在之后的引用中只读取使用,无法修改。
  • finally(异常处理)
    finally关键字一般用于异常处理中。finally结构使代码总会执行,不关有无异常发生。
    finally在try,catch中可以有,可以没有。如果trycatch中有finally则必须执行finally块中的操作。一般情况下,用于关闭文件的读写操作,或者是关闭数据库的连接等等。
  • finalize(垃圾回收)
    finalize方法是Object提供的的实例方法,通过调用finalize()方法在垃圾收集器将对象从内存中清理出去之前做必要的清理工作。
    finaliza方法执行流程:
    当对象变成(GC Roots)不可达时,GC会判断该对象是否覆盖了finalize方法,若未覆盖或finalize()方法已被调用,则直接将其回收(没有必要执行)。否则,将该对象放入F-Queue队列,由一低优先级Finalizer线程执行该队列中对象的finalize方法。(但并不承诺等待运行结束)执行finalize方法完毕后,GC会再次判断该对象是否可达,若不可达,则进行回收,否则,对象“复活”。
    对象”复活”: finalize()方法是对象逃脱死亡命运的最后一次机会,稍后GC将对F-Queue中的对象进行第二次小规模的标记,如果对象要在finalize()中成功拯救自己——只要重新与引用链上的任何一个对象建立关联即可,譬如把自己(this关键字)赋值给某个类变量或者对象的成员变量,那么在第二次标记时它将被移除出“即将回收”的集合;如果对象这时候还没有逃脱,那么基本上它就真的被回收了。
    因此,finalize()并不是必须要执行的,它只能执行一次或者0次。如果在finalize中建立对象关联,则当前对象可以复活一次。
  • throw
    throw是语句抛出一个异常,一般是在代码块的内部,当程序出现某种逻辑错误时由程序员主动抛出某种特定类型的异常。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public static void main(String[] args) {



String s = "abc";
if(s.equals("abc")) {



throw new NumberFormatException();
} else {



System.out.println(s);
}
//function();
}
  • throws
    当某个方法可能会抛出某种异常时用于throws 声明可能抛出的异常,然后交给上层调用它的方法程序处理。
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
26
27
28
29
public class testThrows(){



public static void function() throws NumberFormatException {



String s = "abc";
Double d = Double.parseDouble(s);
}

public static void main(String[] args) {



try {



function();
} catch (NumberFormatException e) {



System.err.println("非数据类型不能强制类型转换。");
//e.printStackTrace();
}
}
throws throw
位置 函数头 函数体
是否发生异常 表示出现异常的一种可能性,并不一定会发生这些异常 throw则是抛出了异常,执行throw则一定抛出了某种异常对象
相同 两者都是消极处理异常的方式,只是抛出或者可能抛出异常,但是不会由函数去处理异常

Java 异常体系

pic_dfb8439a.png
Java异常以Throwable开始,扩展出Error和Exception。

  • Throwable
    Throwable是 Java 语言中所有错误或异常的超类。Throwable包含两个子类: Error 和 Exception。它们通常用于指示发生了异常情况。
    Throwable包含了其线程创建时线程执行堆栈的快照,它提供了printStackTrace()等接口用于获取堆栈跟踪数据等信息。
  • Error
    Error是程序代码无法处理的错误,当资源不足、约束失败、或是其它程序无法继续运行的条件发生时,就产生错误。比如OutOfMemoryError、ThreadDeath等。和运行时异常一样,编译器也不会对错误进行检查。这些异常发生时,Java虚拟机(JVM)一般会选择线程终止退出,其表示程序在运行期间出现了十分严重、不可恢复的错误,应用程序只能中止运行。
  • Exception
    Exception及其子类是 Throwable 的一种形式,它指出了合理的应用程序想要捕获的条件。分为运行时异常和非运行时异常:
  • 运行时异常(不检查异常)
    运行时异常都是RuntimeException类及其子类异常,是那些可能在 Java 虚拟机正常运行期间抛出的异常的超类。编译器不会检查RuntimeException。程序代码中自行选择捕获处理,也可以不处理。这些异常一般是由程序逻辑错误引起的,程序代码应该从逻辑角度尽可能避免这类异常的发生。
    举例常见运行时异常
  1. java.lang.NullPointerException
    空指针异常,调用了未经初始化的对象或者是不存在的对象(null对象)。
  2. java.lang.ClassNotFoundException
    指定的类不存在异常,一般类的名称和路径不正确。
  3. java.lang.ArrayIndexOutOfBoundsException
    数组下标越界异常,调用的下标超出了数组的范围。
  4. java.lang.NoSuchMethodError
    方法不存在错误,当应用试图调用某类未定义的某个方法。
  5. java.lang.IndexOutOfBoundsException
    索引越界异常。当访问某个序列的索引值小于0或大于等于序列大小时,抛出该异常。
  6. java.lang.NumberFormatException(extends IllegalArgumentException )
    数字格式异常。当试图将一个String转换为指定的数字类型,而该字符串确不满足数字类型要求的格式时,抛出该异常。
  7. java.sql.SQLException
    Sql语句执行异常
  8. java.io.IOException
    输入输出异常
  9. java.lang.IllegalArgumentException
    方法参数错误
  10. java.lang.IllegalAccessException
    无访问权限异常
  • 非运行时异常(检查异常)
    所有继承Exception且不是RuntimeException的异常都是非运行时异常,也称检查异常,如上图中的IOException和ClassNotFoundException,编译器会对其作检查,故程序中一定会对该异常进行处理,处理方法要么在方法体中声明抛出checked Exception,要么使用catch语句捕获checked Exception进行处理,不然不能通过编译。

自定义Java异常

  • 为这个异常找一个近似的类作为父类;
  • 再该类中编写两个构造器:(1)默认构造器(2)带message参数的构造器
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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
class MyException extends Exception {



public MyException() {


}
public MyException(String msg) {



super(msg);
}
}

public class Demo3 {




public static void main(String[] args) {



try {



test();
} catch (MyException e) {



System.out.println("Catch My Exception");
e.printStackTrace();
}
}
public static void test() throws MyException{



try {



int i = 10/0;
System.out.println("i="+i);
} catch (ArithmeticException e) {



throw new MyException("This is MyException");
}
}
}

第三章 Java特性与基本语法(2)

  • 反射
    • 简介 & 功能 & 应用场景
    • 基本使用
      • 通过Java反射查看类信息
      • 通过Java反射生成并操作对象
    • Java反射机制 与 动态代理
    • Java反射机制 与 泛型
    • 反射在项目中的应用
    • 反射效率低 原因 & 解决
  • 注解(Annotation)
    • 简介
    • 工作机制
    • JDK注解 / 自定义注解
    • 如何解析注解
  • 控制反转(IOC)/依赖注入(DI)
    • ButterKnife用法 & 原理?

反射

简介 & 功能 & 应用场景

  • Java反射机制定义
    Java 反射机制是在运行状态中,对于任意一个类,都能够获得这个类的所有属性和方法;对于任意一个对象都能够调用它的任意一个属性和方法。这种在运行时动态的获取类信息以及动态调用对象的方法的功能称为Java 的反射机制。
  • Java 反射机制的功能
  1. 在运行时判断任意一个对象所属的类。
  2. 在运行时构造任意一个类的对象。
  3. 在运行时判断任意一个类所具有的成员变量和方法。
  4. 在运行时调用任意一个对象的方法。
  5. 生成动态代理。
  • Java 反射机制的应用场景
  1. 逆向代码 ,例如反编译
  2. 与注解相结合的框架 例如Retrofit
  3. 单纯的反射机制应用框架 例如EventBus
  4. 动态生成类框架 例如Gson

基本使用

通过Java反射查看类信息

(1)获取Class类对象——描述.class字节码文件
每个类被加载之后,系统就会为该类生成一个对应的Class对象。通过该Class对象就可以访问到JVM中的这个类。
在Java程序中获得Class对象通常有如下三种方式:

  1. 使用Class类的forName(String clazzName)静态方法。该方法需要传入字符串参数,该字符串参数的值是某个类的全限定名(必须添加完整包名)。
  2. 调用某个类的class属性来获取该类对应的Class对象。
  3. 调用某个对象的getClass()方法。该方法是java.lang.Object类中的一个方法。
1
2
3
4
5
6
7
//第一种方式 通过Class类的静态方法——forName()来实现
class1 = Class.forName("com.lvr.reflection.Person");
//第二种方式 通过类的class属性
class1 = Person.class;
//第三种方式 通过对象getClass方法
Person person = new Person();
Class<?> class1 = person.getClass();

(2)类成员变量的反射

  1. 获取类成员变量:
  • Field[] getFields():获取所有public 修饰的成员变量
  • Field getField(String name):获取指定名称的public修饰的成员变量
  • Field[] getDeclaredFields():获取所有的成员变量,不考虑修饰符
  • Filed getDeclaredField(String name):获取指定名称的成员变量,不考虑修饰符
  1. Filed:成员变量
  • get(Object object) :获取值
  • void set(Object obj, Object value):设置值
  • setAccessible(true) :忽略访问权限修饰符的安全检查,用于暴力反射,修改私有成员变量的值
1
2
3
4
Field[] allFields = class1.getDeclaredFields();//获取class对象的所有属性
Field[] publicFields = class1.getFields();//获取class对象的public属性
Field ageField = class1.getDeclaredField("age");//获取class指定属性
Field desField = class1.getField("des");//获取class指定的public属性

(3)类成员方法的反射

  1. 获取类成员方法:
  • Method[] getMethods():获取所有public修饰的成员方法
  • Method getMethod(String name,类<?>… parameterTypes):获取指定的public修饰的成员方法,name 为方法名,parameterTypes为参数列表(重载)
  • Method[] getDeclaredMethods():获取所有成员方法
  • Method getDeclaredMethod(String name,类<?>… parameterTypes):获取指定的成员方法,name 为方法名,parameterTypes为参数列表(重载)
  1. Method
  • invoke(obj … args):执行方法
  • setAccessible(true) :忽略访问权限修饰符的安全检查,用于暴力反射,修改私有成员方法的值
1
2
3
4
Method[] methods = class1.getDeclaredMethods();//获取class对象的所有声明方法
Method[] allMethods = class1.getMethods();//获取class对象的所有public方法 包括父类的方法
Method method = class1.getMethod("info", String.class);//返回次Class对象对应类的、带指定形参列表的public方法
Method declaredMethod = class1.getDeclaredMethod("info", String.class);//返回次Class对象对应类的、带指定形参列表的方法

(4)类构造方法的反射

  1. 获取类构造方法:
  • Constructor[] getConstructors():获取public修饰的构造方法
  • Constructor getConstructor(类<?>… parameterTypes):获取指定的public修饰的构造方法(构造方法的方法名 = 类名),parameterTypes为参数列表
  • Constructor[] getDeclaredConstructors():获取所有构造方法
  • Constructor getDeclaredConstructor(类<?>… parameterTypes):获取指定的构造方法,name为方法名(构造方法的方法名 = 类名),parameterTypes为参数列表
  1. Constructor:构造方法
  • T.newInstance(Object… init args):创建对象
  • Class.newInstance():如果使用空参数构造方法创建对象,操作可以简化为:Class对象的newInstance方法
  • setAccessible(true) :忽略访问权限修饰符的安全检查,用于暴力反射,修改私有构造方法的值
1
2
3
4
Constructor<?>[] allConstructors = class1.getDeclaredConstructors();//获取class对象的所有声明构造函数
Constructor<?>[] publicConstructors = class1.getConstructors();//获取class对象public构造函数
Constructor<?> constructor = class1.getDeclaredConstructor(String.class);//获取指定声明构造函数
Constructor publicConstructor = class1.getConstructor(String.class);//获取指定声明的public构造函数

通过Java反射生成并操作对象

  • 生成类的实例对象
1
2
3
4
5
//第一种方式 Class对象调用newInstance()方法生成
Object obj = class1.newInstance();
//第二种方式 对象获得对应的Constructor对象,再通过该Constructor对象的newInstance()方法生成
Constructor<?> constructor = class1.getDeclaredConstructor(String.class);//获取指定声明构造函数
obj = constructor.newInstance("hello");
  • 调用类的方法
1
2
3
4
5
6
7
8
// 生成新的对象:用newInstance()方法
Object obj = class1.newInstance();
//首先需要获得与该方法对应的Method对象
Method method = class1.getDeclaredMethod("setAge", int.class);
//开启调用该方法的权限
method.setAccessible(true);
//调用指定的函数并传递参数
method.invoke(obj, 28);
  • 访问成员变量值
1
2
3
4
5
6
7
8
//生成新的对象:用newInstance()方法 
Object obj = class1.newInstance();
//获取age成员变量
Field field = class1.getField("age");
//将obj对象的age的值设置为10
field.setInt(obj, 10);
//获取obj对象的age的值
field.getInt(obj);

Java反射机制 与 动态代理

[第七章 设计模式 —— 动态代理][_ _ 1]

  • 静态代理
    在程序运行前就已经存在代理类的.class文件,已经确定代理类和委托类的关系。
  • 动态代理
    通过动态代码可实现对不同类、不同方法的代理。动态代理的源码是在程序运行期间由JVM根据反射等机制动态的生成,所以不存在代理类的字节码文件(.class)。代理类和委托类的关系在程序运行时确定。
    动态代理与静态代理相比较,最大的好处是接口中声明的所有方法都被转移到调用处理器一个集中的方法中处理(InvocationHandler.invoke)。而且动态代理提高了软件系统的可扩展性,因为Java 反射机制可以生成任意类型的动态代理类。(而每个静态代理只能为一个接口服务)
  • 实现原理 —— 反射
  1. 动态代理类只能代理接口,需创建一个实现接口InvocationHandler的调用处理器,它必须实现invoke方法。invoke方法是调用代理接口所有方法都要调用,返回值是被代理接口的一个实现类(动态代理类)。
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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
public class LogHandler implements InvocationHandler {




// 目标对象
private Object targetObject;
//绑定关系,也就是关联到哪个接口(与具体的实现类绑定)的哪些方法将被调用时,执行invoke方法。
public Object newProxyInstance(Object targetObject){



this.targetObject=targetObject;
//该方法用于为指定类装载器、一组接口及调用处理器生成动态代理类实例
//第一个参数指定产生代理对象的类加载器,需要将其指定为和目标对象同一个类加载器
//第二个参数要实现和目标对象一样的接口,所以只需要拿到目标对象的实现接口
//第三个参数表明这些被拦截的方法在被拦截时需要执行哪个InvocationHandler的invoke方法
//根据传入的目标返回一个代理对象
return Proxy.newProxyInstance(targetObject.getClass().getClassLoader(),
targetObject.getClass().getInterfaces(),this);
}
@Override
//关联的这个实现类的方法被调用时将被执行
/*InvocationHandler接口的方法,proxy表示代理,method表示原对象被调用的方法,args表示方法的参数*/
public Object invoke(Object proxy, Method method, Object[] args)
throws Throwable {



System.out.println("start-->>");
for(int i=0;i<args.length;i++){



System.out.println(args[i]);
}
Object ret=null;
try{



/*原对象方法调用前处理日志信息*/
System.out.println("satrt-->>");

//调用目标方法
ret=method.invoke(targetObject, args);
/*原对象方法调用后处理日志信息*/
System.out.println("success-->>");
}catch(Exception e){



e.printStackTrace();
System.out.println("error-->>");
throw e;
}
return ret;
}
}
  1. 创建接口及具体实现类(委托类)
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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
// 接口
public interface UserManager{



public void addUser(String userId, String userName);
public void delUser(String userId) ;
public String findUser(String userId) ;
public void modifyUser(String userId, String userName) ;
}
// 实现类(委托类)
public class UserManagerImpl implements UserManager {




@Override
public void addUser(String userId, String userName) {



System.out.println("UserManagerImpl.addUser");
}

@Override
public void delUser(String userId) {



System.out.println("UserManagerImpl.delUser");
}

@Override
public String findUser(String userId) {



System.out.println("UserManagerImpl.findUser");
return "张三";
}

@Override
public void modifyUser(String userId, String userName) {



System.out.println("UserManagerImpl.modifyUser");

}
}
  1. 通过动态代理类调用Proxy的静态方法newProxyInstance,提供ClassLoader和代理接口类型数组动态创建一个代理类,并通过代理调用方法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class Client {




public static void main(String[] args){



LogHandler logHandler=new LogHandler();
UserManager userManager=(UserManager)logHandler.newProxyInstance(new UserManagerImpl());
// 获取动态代理对象
userManager.addUser("1111", "张三"); // 调用动态代理的addUser方法,该调用会转发到logHandler的invoke上,从而达到动态代理的效果
}
}

可以看到,我们可以通过LogHandler代理不同类型的对象,如果我们把对外的接口都通过动态代理来实现,那么所有的函数调用最终都会经过invoke函数的转发,因此我们就可以在这里做一些自己想做的操作,比如日志系统、事务、拦截器、权限控制等。这也就是AOP(面向切面编程)的基本原理。

AOP(AspectOrientedProgramming):将日志记录,性能统计,安全控制,事务处理,异常处理等代码从业务逻辑代码中划分出来,通过对这些行为的分离,我们希望可以将它们独立到非指导业务逻辑的方法中,进而改变这些行为的时候不影响业务逻辑的代码—解耦。

Java反射机制 与 泛型

反射在项目中的应用

应用一:简单工厂创建对象

  • 需求
    通过一个工厂类创建不同类型的实例。
  • 解决方案
  1. 在配置文件中配置需要创建对象的信息
  2. 通过类加载器加载配置文件
  3. 获取类对象,通过类的全限定名创建实例对象
  • 代码
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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
public class BasicFactory {



private BasicFactory(){


}

private static BasicFactory bf = new BasicFactory();
private static Properties pro = null;

static{



pro = new Properties();
try{



//通过类加载器加载配置文件
pro.load(new FileReader(BasicFactory.class.getClassLoader().
getResource("config.properties").getPath()));
}catch (Exception e) {



e.printStackTrace();
}
}

public static BasicFactory getFactory(){



return bf;
}

//使用泛型获得通用的对象
public <T> T newInstance(Class<T> clazz){



String cName = clazz.getSimpleName(); //获得字节码对象的类名
String clmplName = pro.getProperty(cName); //根据字节码对象的类名通过配置文件获得类的全限定名

try{



return (T)Class.forName(clmplName).newInstance(); //根据类的全限定名创建实例对象
}catch (Exception e) {



throw new RuntimeException(e);
}
}
}

应用二:过滤符合输入关键字的数据

  • 需求
    输入关键字检索通过网络请求加载好的列表数据,过滤出符合过滤条件(包含关键字)的数据项。
  • 为何使用反射
    一般解决方案为,遍历存放数据的List,然后每项对比关键字,将过滤后的数据存入一个新的List中,返回给RecyclerView的Adapter进行数据刷新。
    有2个问题:
  1. List获取的数据由于业务不同,其泛型也不同,且泛型没有关联性
  2. 每一个泛型都是一个Bean,要对Bean中的属性尽兴过滤,属性是不确定的
    由于列表数据过滤在项目中很常用,几乎每个列表项都有过滤功能,如果对于每种列表都采用硬编码进行封装,代码的复用性很低,且项目不易维护。
  • 解决方案
    采用反射机制
    主要是getClassInfo获取某个类需要过滤的数据项,并与关键字对比并过滤。
  • 代码
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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
public class ListFilter<T> {




/**
* 过滤ArrayList中的关键字数据
* @param models 网络获取到的数据列表
* @param query 过滤关键字
* @param propertyName 泛型的数据过滤项
* @return 返回过滤后的数据
*/
public ArrayList<T> filter(ArrayList<T> models, String query, String propertyName) {




ArrayList<T> filteredModelList = null;
//实例化这个类赋给o
try {



query = query.toLowerCase();
filteredModelList = new ArrayList<>();
for (T model : models) {



final String text = getClassInfo(model,model.getClass().getName(),propertyName).toLowerCase();
if (text.contains(query)) {



filteredModelList.add(model);
}
}
} catch (Exception e) {



e.printStackTrace();
}
return filteredModelList;
}

/**
* 获取类的属性值
* @param obj 类实例
* @param classNameString 类名
* @param propertyNameString 获取的属性名
* @return 属性值
*/
private String getClassInfo(Object obj,String classNameString,String propertyNameString) {



String returnString="";
try{



Class classInfo = Class.forName(classNameString);
if(!(classInfo.isInstance(obj))){



L.e("传入的java实例与配置的java对象类型不符!");
return returnString;
}
Field field = classInfo.getDeclaredField(propertyNameString);
field.setAccessible(true);
returnString=field.get(obj).toString();
}catch(Exception e){



e.printStackTrace();
}
return returnString;
}
}

反射效率低 原因 & 解决

导致反射效率慢的因素及解决:

  1. 获取Class对象时使用Class.forName效率低,耗时。把Class.forName返回的Class对象缓存起来,下一次使用的时候直接从缓存里面获取,这样就极大的提高了获取Class的效率。
  2. 同理,在项目启动时将反射需要的相关配置和数据(Field、Method、Constructor等)加载进内存,在运行时直接从缓存中取这些元数据进行反射操作。
  3. 调用method.setAccessible(true)
    jdk在设置获取字段,调用方法的时候会执行安全访问检查,而此类操作会比较耗时,所以通过setAccessible(true)的方式可以关闭安全检查,从而提升反射效率。
  4. 采用高性能的反射工具包,如ReflectASM。
  5. 使用高版本JDK,提高反射性能。
  6. 反射效率慢,但速度也是可以接受的。所以对于反射不应该因为速度慢而对其”望而却步”。

注解(Annotation)

简介

注解(元数据)是一种代码级别的说明。是JDK 5.0 后引入的新特性。注解作为程序的元数据嵌入到程序中,声明在类、成员变量、成员方法等前面,用来对这些元素进行说明,注释。注解可以被解析工具/编译工具解析。
Annotation的作用可分为3类:

  1. 编写文档:通过代码里标识的注解生成文档
  2. 代码分析:通过反射获取注解信息并对代码进行分析
1
2
3
4
Class<ReflectTest> reflectTestClass = ReflectTest.class;	// 1.通过反射获取字节码文件对象
Pro an = reflectTestClass.getAnnotation(Pro.class); // 2.调用getAnnotation(class)获取注解对象
String className = an.className();
String methodName = an.methodName(); // 3.调用注解对象中定义的抽象方法,获取返回值(返回值即注解信息)
  1. 编译检查:如@override 用于检测被该注解标注的方法是否是继承自父类(接口)

工作机制

注解本质是一个继承了Annotation 的特殊接口,其具体实现类是Java 运行时生成的动态代理类。而我们通过反射获取注解时,返回的是Java 运行时生成的动态代理对象$Proxy1。通过代理对象调用自定义注解(接口)的方法,会最终调用AnnotationInvocationHandler 的invoke方法。该方法会从memberValues 这个Map 中索引出对应的值。而memberValues 的来源是Java 常量池。

JDK注解 / 自定义注解

  • JDK —— 编译检查
  1. @Override
    用于检测被该注解标注的方法是否是继承自父类(接口)的。
    如果某个方法带有该注解但并没有覆写超类相应的方法,则编译器会生成一条错误信息。如果父类没有这个要覆写的方法,则编译器也会生成一条错误信息。
    @Override可适用元素为方法,仅仅保留在java源文件中。
  2. @Deprecated
    用于标注已经过时的方法。
    用于告知编译器,某一程序元素(比如方法,成员变量)不建议使用了(即过时了)。编译器会出现警告。
    @Deprecated可适合用于除注解类型声明之外的所有元素,保留时长为运行时。
  3. @SuppressWarnnings
    用于通知java编译器禁止特定的编译警告。
    @SuppressWarnings可适合用于除注解类型声明和包名之外的所有元素,仅仅保留在java源文件中。
  • 自定义注解 —— 代码分析/编写文档
  • 自定义注解的格式
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
26
27
28
29
30
31
32
33
34
35
元注解
public @interface 注解名称{



... 属性列表
}

// 举例
@Documented
@Target(ElementType.METHOD)
@Inherited
@Retention(RetentionPolicy.RUNTIME)
public @interface MyAnnotataion{



String name();
String website() default "hello";
int revision() default 1;
}

public class AnnotationDemo {



// 当注解中有成员变量时,若没有默认值,需要在使用注解时,指定成员变量的值。
@MyAnnotataion(name="lvr", website="hello", revision=2)
public void demo(){



System.out.println("I am demo method");
}
}
  • 元注解
    通过元注解注解其他注解。Java提供了4个标准元注解:
    (1)Target:描述注解作用位置,如CONSTRUCTOR、FIELD、METHOD、TYPE等等
    (2)Retention:描述注解保留的阶段,包括SOURCE、CLASS、RUNTIME
    (3)Documented:描述注解是否被抽取到生成的API文档中
    (4)Inherited:描述注解是否被子类继承
  • 属性列表
    接口中的抽象方法被称为注解的属性。每一个抽象方法实际上是声明了一个配置参数。方法的名称就是参数的名称,返回值类型就是参数类型。

如何解析注解

  • 通过反射技术解析自定义注解
    关于反射类位于包java.lang.reflect,其中有一个接口AnnotatedElement。该接口定义了注释相关的几个核心方法,如下:
    pic_8790c743.png
    因此,当获取了某个类的Class对象,然后获取其Field,Method等对象,通过上述4个方法提取其中的注解,然后获得注解的详细信息。
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
26
27
28
29
public class AnnotationParser {



public static void main(String[] args) throws SecurityException, ClassNotFoundException {



String clazz = "com.lvr.annotation.AnnotationDemo";
Method[] demoMethod = AnnotationParser.class
.getClassLoader().loadClass(clazz).getMethods();

for (Method method : demoMethod) {



if (method.isAnnotationPresent(MyAnnotataion.class)) {



MyAnnotataion annotationInfo = method.getAnnotation(MyAnnotataion.class);
System.out.println("method: "+ method);
System.out.println("name= "+ annotationInfo.name() +
" , website= "+ annotationInfo.website()
+ " , revision= "+annotationInfo.revision());
}
}
}
}
  • 注解解析案例:实现反射动态加载类
  1. 通过反射获取注解定义位置的对象(Class,Method,Field)
  2. 获取指定的注解:getAnnotation(Class)其实就是在内存中生成了一个该注解接口的子类实现对象
  3. 调用注解中的抽象方法获取配置的属性值

Pro.java

1
2
3
4
5
6
7
8
9
10
11
12
13
//注解配置文件:描述需要执行的类名和方法名
@Target ({


ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface Pro{



String className();
String methodName();
}

ReflectTest.java

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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
public class ReflectTest{



public static void main(String[] args) throws Exception{



// 前提:不能改变该类的任何代码,可以创建任意类的对象,可以执行任意方法
// 1. 解析注解
// 1.1 获取该类的字节码文件对象
Class<ReflectTest> reflectTestClass = ReflectTest.class;
// 2.获取上边的注解对象,起始就是在内存中生成了一个注解接口的子类实现对象
// public class ProImpl implements Pro{



// public String className(){



// return "cn.itcast.annotation.Demo1";
// }
// public String methodName(){



// return "show";
// }
// }
Pro an = reflectTestClass.getAnnotation(Pro.class);
// 3.调用注解对象中定义的抽象方法,获取返回值
String className = an.className();
String methodName = an.methodName();
// 4. 加载该类进内存
Class cls = Class.forName(className);
// 5. 创建对象
Object obj = cls.newInstance();
// 6. 获取方法对象
Method method = cls.getMethod(methodName);
// 7. 执行方法
method.invoke(obj);
}

控制反转(IOC)/依赖注入(DI)

  • 依赖(Dependency)
    依赖是类与类之间的相互联系,如人(Person)出行可以使用自行车Bike、轿车Car或火车(Train),因此Person类可以依赖Bike类、Car类和Train类。
    每次出行都需要修改Person类代码。
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
26
27
28
29
30
31
32
33
34
35
36
37
public class Person {




private Bike mBike;
private Car mCar;
private Train mTrain;

public Person(){



mBike = new Bike();
// mCar = new Car();
// mTrain = new Train();
}

public void goOut(){



System.out.println("出游");
mBike.drive();
// mCar.drive();
// mTrain.drive();
}

public static void main(String ... args){



//TODO:
Person person = new Person();
person.goOut();
}
}
  • 依赖反转(IOC)
    IOC对上层模块与底层模块进行了进一步的解耦。控制反转的意思是反转了上层模块对于底层模块的依赖控制。
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
26
27
28
29
30
31
32
33
public class Person {




private Driveable mDriveable;

public Person2(Driveable driveable){



this.mDriveable = driveable;
}

public void goOut(){



System.out.println("出门啦");
mDriveable.drive();
// mCar.drive();
// mTrain.drive();
}

public static void main(String ... args){



//TODO:
Person2 person = new Person2(new Car());
person.goOut();
}
}
  • 依赖注入(DI)
    DI是指在外部将依赖实例化并赋值给IOC容器。依赖注入有3种方式:构造函数注入、setter方式注入、接口注入。
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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
public interface DepedencySetter {



void set(Driveable driveable);
}
public class Person2 implements DepedencySetter {




//接口方式注入
@Override
public void set(Driveable driveable) {



this.mDriveable = mDriveable;
}

private Driveable mDriveable;

//构造函数注入
public Person2(Driveable driveable){



this.mDriveable = driveable;
}

//setter 方式注入
public void setDriveable(Driveable mDriveable) {



this.mDriveable = mDriveable;
}

public void goOut(){



System.out.println("出门啦");
mDriveable.drive();
//mCar.drive();
// mTrain.drive();
}

public static void main(String ... args){



//TODO:
Person2 person = new Person2(new Car());
person.goOut();
}
}

ButterKnife用法 & 原理?

ButterKnife:是视图注入中相对简单易懂的开源框架,其优势在于:

  1. 强大的View绑定和Click事件处理功能,简单代码,提高开发效率
  2. 方便的处理Adapter和ViewHolder绑定问题
  3. 提高APP运行效率,使用配置方便
  4. 代码清晰,可读性强
1
2
3
4
5
6
7
8
9
@InjectView(R.id.listview)
ListView mListview;
@OnItemClick(R.id.listview)
public void onItemClick(int position){



Toast.makeText(getBaseContext(), "item"+position, Toast.LENGTH_SHORT).show();
}

第四章 抽象类与接口

  • 抽象类&接口?区别?优缺点?适用场景?

抽象类&接口?区别?优缺点?适用场景?

[菜鸟教程:抽象类 & 接口][Link 2]

  • 抽象类 & 接口
  • 抽象类
    不能实例化对象的类。通过abstract class来定义抽象类。抽象类必须被继承,才能被使用。abstract可声明抽象方法,抽象方法只包含方法名,没有方法体,具体实现由它的子类确定。抽象方法有两条规则:
    (1)包含抽象方法的类必定是抽象类(但抽象类不一定有抽象方法)
    (2)任何子类必须重写父类的抽象方法,或者声明自身为抽象类
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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
// 抽象类
public abstract class Employee
{



private String name;
private String address;
public Employee(String name, String address)
{



System.out.println("Constructing an Employee");
this.name = name;
this.address = address;
}
// 一般成员方法
public void setName(){


this.name = name;}
public String getName(){


return name;}
// 抽象成员方法
public double void computePay();
}
// 子类继承抽象类,具体实现
public class Salary extends Employee
{



private double salary; // Annual salary
// 必须实现抽象成员方法 computePay
public double computePay()
{



System.out.println("Computing salary pay for " + getName());
return salary/52;
}
}
  • 接口
    接口不是类,是抽象方法的集合。通过Interface来声明接口。一个类通过implements关键字来实现接口中的抽象方法。一个实现接口的类,必须实现接口内所描述的所有方法,否则就必须声明为抽象类。
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
26
27
28
29
30
31
32
33
34
35
// 声明接口
interface Animal {



public void eat();
public void travel();
}
// 实现接口
public class MammalInt implements Animal{




public void eat(){



System.out.println("Mammal eats");
}

public void travel(){



System.out.println("Mammal travels");
}

public int noOfLegs(){



return 0;
}
}
  • 联系 & 区别
抽象类
abstract
接口
interface
定义 不能实例化对象的类。通过abstract class来定义抽象类。抽象类必须被继承,才能被使用。一个类通过extends关键字来继承抽象类。 接口不是类,是抽象方法的集合。通过Interface来声明接口。一个类通过implements关键字来实现接口中的抽象方法。
作用 is-a关系
是什么
like-a
能做什么
描述 描述对象的属性和方法,关注的是事物本身的抽象 声明类要实现的方法,关注的是操作行为的规范
相同点 均不可实例化
均需要子类继承/实现对应的抽象方法
成员方法 抽象类的方法有方法体,可以实现方法的具体功能 接口中只有方法的声明,没有方法体(接口中的方法默认public abstract类型)
成员变量 抽象类的成员变量可以是各种类型 接口中只成员变量只能是public static final类型(公共静态常量)
static修饰符 抽象类可以有静态代码块和静态方法 接口中没有静态代码块以及静态方法
继承关系 一个类只能继承一个抽象类 一个类可以实现多个接口
优缺点 优点:实现公共特性的提取,使具体类可以从抽象类自动得到缺省实现,复用性强,简化代码
缺点:单继承,使子类的功能的扩展性大打折扣
优点:一个类可以实现多个接口,接口可以使这个类不仅具有主类型的行为,而且具有其他的次要行为,比如 HashMap主要类型是Map,而Cloneable接口使它具有一个次要类型,这个类型说明它可以安全的克隆
使用建议 抽象类用于给子类提供一个抽象的描述,用作主要类型的说明
接口用于给子类提供多个功能的补充,用作次要类型的说明

第五章 JVM、垃圾回收(GC)

  • Java代码执行流程/Java虚拟机工作原理?
  • Java内存结构 / 运行时数据区域?
  • 对象的创建、内存布局 和 访问定位
    • 对象的创建
    • 对象的内存布局
    • 对象的访问定位
  • 类加载机制
    • 什么是class文件?
    • 类加载机制
    • 类的生命周期
    • 触发类加载的条件
    • 类加载的具体过程
    • 类加载器
      • 类加载器分类
      • 双亲委派模型
  • 垃圾回收机制
    • 如何判断对象是否会被垃圾回收机制处理掉?
    • 说说Java种的4种引用以及用法?
    • GC回收算法有哪些?优缺点?
    • GC回收机制?
    • GC是什么时候触发的?
    • 垃圾收集器?
  • 面试
    • 对象的生命周期?
    • 谈谈static关键字/谈谈static编译运行时的流程,在虚拟机中如何保存的?

Java代码执行流程/Java虚拟机工作原理?

Java 代码执行流程主要划分为以下5个步骤:编辑源代码、编译生成class文件、加载class文件、运行class文件、垃圾回收。

  1. 源码编写
    编辑源代码,并命名为Student.java。
  2. 编译
    输入javac Student.java/使用Java源码编译器编译。将源代码文件编译生成student.class字节码文件。字节码文件存放了这个类的字段、方法、父类、实现的接口等各种信息。
  3. 类加载
    通过类加载器将.class二进制数据读入内存中。即读取student.class文件中数据并存储在方法区中,并建立一个Class对象,作为运行时方法Student类各种数据的接口。

一个类文件加载到方法区,一些符号引用被解析(静态解析:如class文件的常量池被加载到方法区运行时常量池,各种其他静态存储结构被加载为方法区运行时数据结构等等)为直接引用或等到运行时分派(动态绑定),经过一系列加载过程后,程序可以通过Class对象方法方法区各种类型数据。

  1. JVM执行
    JVM执行.class文件。执行引擎找到main()入口方法,即在栈里创建一个栈帧,逐行执行方法中的字节码指令。操作完成后方法返回给调用方,栈帧出栈。

当前正在运行的方法的栈帧位于栈顶。若当前方法返回,则当前方法对应的栈帧出栈;当前方法的方法体中若是调用了其他方法,则为被调用方法创建栈帧,并将其压入栈顶。

  1. 垃圾回收
    内存空间通过JVM垃圾回收机制GC

Java内存结构 / 运行时数据区域?

Java内存结构描述的是Java程序执行过程中, 由JVM管理的不同的数据区域。包括以下5部分:
堆内存(heap)、方法区(method)、程序计数器、栈内存(stack)、本地方法栈(java中JNI调用)

  1. 堆内存(线程共享):JVM所管理的内存中最大一块。唯一目的就是存放实例对象,几乎所有的对象实例都在这里分配。Java堆是垃圾收集器管理的主要区域,因此很多时候也被称为“GC堆”。异常状态 OutOfMemoryError
  2. 方法区(线程共享):方法区是被所有线程共享的区域。用于存放类的所有信息(字段、方法、构造函数等)、静态变量、常量。异常状态 OutOfMemoryError
    包含运行时常量池:存放编译器生成的各种字面量和符号引用
  3. (虚拟机)栈内存(线程私有):一个线程对应一个栈,生命周期与线程相同。描述的是java方法执行的内存模型:每个方法执行时会创建一个栈帧,用于存放局部变量、操作数栈、方法出口等信息。每一个方法从调用直至完成的过程,对应着一个栈帧在虚拟机中入栈到出栈的过程。异常状态 OutOfMemoryError StackOverflowError
  4. 本地方法栈(线程私有):与虚拟机栈作用相似,区别在于本地方法栈用于支持Native方法执行,存储了每个Native方法调用的状态。
  5. 程序计数器(线程私有):可看做当前线程所执行的字节码的行号指示器。指向方法区方法字节码(下一个指令的地址),并由执行引擎读取并执行下一指令。
    pic_172a6b7c.png

Java中的常量池,实际上分为两种形态:静态常量池和运行时常量池。
所谓静态常量池,即*.class文件中的常量池,这种常量池主要用于存放两大类常量:字面量(Literal)和符号引用量(Symbolic References)
字面量相当于Java语言层面常量的概念,如文本字符串,声明为final的常量值等。
符号引用则属于编译原理方面的概念,包括了如下三种类型的常量:
(1)类和接口的全限定名
(2)字段名称和描述符
(3)方法名称和描述符
而运行时常量池,则是jvm虚拟机在完成类装载操作后,将class文件中的常量池载入到内存中,并保存在方法区中,我们常说的常量池,就是指方法区中的运行时常量池。

常量池是为了避免频繁的创建和销毁对象而影响系统性能,其实现了对象的共享。例如字符串常量池,在编译阶段就把所有的字符串文字放到一个常量池中。

对象的创建、内存布局 和 访问定位

对象的创建

1.虚拟机遇到一个new指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用;
2.检查这个符号引用代表的类是否已经被加载,解析和初始化过。如果没有,那必须先执行响应的类加载过程;
3.在类加载检查功通过后,为新生对象分配内存。对象所需的内存大小在类加载完成后便可完全确定。

对象的内存布局

分为3个区域:对象头,实例数据,对齐填充。
对象头:
包括两部分信息,第一部分:对象自身的运行时数据,如哈希码,GC分代年龄,锁状态标志,线程持有的锁,偏向线程ID,偏向时间戳等,这部分数据的长度在32位和64位的虚拟机中分别为32 bit和64 bit,官方称它为“Mark Word”。
第二部分:类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。如果对象是一个java数组,那在对象头中还必须有一块用于记录数组长度的数据。
实例数据:
是对象真正存储的有效信息,也是在程序代码中所定义的各种类型的字段内容。
对齐填充:
对齐填充不是必然存在的。HotSpot VM的自动内存管理系统要求对象起始地址必须是8字节的整数倍,也就是说对象的大小必须是8字节的整数倍。而对象头部分正好是8字节的整数倍。因此,当对象实例数据部分没有对齐时,就需要通过对其补充来补全了。

对象的访问定位

Java程序需要通过栈上了reference数据来操作堆上的具体对象。
目前主流的访问方式有使用句柄和直接指针两种。
句柄访问:
Java堆中会划分出一块内存来作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对实例数据与类型数据的各自具体的地址信息。
pic_0c4b52d7.png
直接指针访问:
reference中存储的直接就是对象地址。
pic_3e91c735.png

类加载机制

什么是class文件?

Java的编译器在编译java类文件时,会将原有的文本文件(.java)翻译成二进制的字节码,并将这些字节码存储在.class文件。
也就是说java类文件中的属性、方法,以及类中的常量信息,都会被分别存储在.class文件中。当然还会添加一个公有的静态常量属性.class,这个属性记录了类的相关信息,即类型信息,是Class类的一个实例。
class文件存在的意义就是:跨平台。各种不同平台的虚拟机都统一使用这种相同的程序存储格式。不同平台的JVM运行相同的.class文件。

Java为什么能跨平台运行?
因为每个平台都拥有自己的JVM。Java编译器会先将Java代码编译成二进制字节码的class文件,并使用不同平台的JVM解释运行这些字节码文件。因此Java语言能跨平台运行。

类加载机制

把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型。
在Java语言里,类型的加载、连接和初始化过程都是在程序运行期间完成的,这种策略虽然会令类加载时稍微增加一些性能开销,但是会为Java应用程序提供高度的灵活性,Java里天生可以动态扩展的语言特性就是依赖运行期动态加载和动态连接这个特点来实现的。

类的生命周期

加载,验证,准备,解析,初始化,使用和卸载。其中验证,准备,解析3个部分统称为连接。
这7个阶段发生顺序如下图:
pic_7a10fe35.png
加载,验证,准备,初始化,卸载这5个阶段的顺序是确定的,而解析阶段则不一定:它在某些情况下可以在初始化完成后在开始,这是为了支持Java语言的运行时绑定。
其中加载,验证,准备,解析及初始化是属于类加载机制中的步骤。注意此处的加载不等同于类加载。

触发类加载的条件

①.遇到new,getstatic,putstatic或invokestatic这4条字节码指令时,如果类没有进行过初始化,则需要先触发初始化。生成这4条指令的最常见的Java代码场景是:使用new关键字实例化对象的时候,读取或设置一个类的静态字段的时候(被final修饰,已在编译期把结果放入常量池的静态字段除外),以及调用一个类的静态方法的时候。
②.使用java.lang.reflect包的方法对类进行反射调用的时候。
③.当初始化一个类的时候,发现其父类还没有进行过初始化,则需要先出发父类的初始化。
④.当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类。
⑤.当使用JDK1.7的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果REF_getStatic,REF_putStatic,REF_invokeStatic的方法句柄,并且这个方法句柄所对应的类没有进行初始化,则需要先出发初始化。

类加载的具体过程

加载:

①.通过一个类的全限定名来获取定义此类的二进制字节流
②.将这个字节流所代表的静态存储结构转换为方法区内的运行时数据结构
③.在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。
验证:

是连接阶段的第一步,目的是为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。
包含四个阶段的校验动作
a.文件格式验证
验证字节流是否符合Class文件格式的规范,并且能被当前版本的虚拟机处理。
b.元数据验证
对类的元数据信息进行语义校验,是否不存在不符合Java语言规范的元数据信息
c.字节码验证
最复杂的一个阶段,主要目的是通过数据流和控制流分析,确定程序语义是合法的,符合逻辑的。对类的方法体进行校验分析,保证被校验类的方法在运行时不会做出危害虚拟机安全的事件。
d.符号引用验证
最后一个阶段的校验发生在虚拟机将符号引用转换为直接引用的时候,这个转换动作将在连接的第三个阶段——解析阶段中发生。
符号验证的目的是确保解析动作能正常进行。
准备:

准备阶段是正式为类变量分配内存并设置类变量初始值的阶段。这些变量所使用的内存都将在方法区中分配。只包括类变量。初始值“通常情况”下是数据类型的零值。
“特殊情况”下,如果类字段的字段属性表中存在ConstantValue属性,那么在准备阶段变量的值就会被初始化为ConstantValue属性所指定的值。
解析:

虚拟机将常量池内的符号引用替换为直接引用的过程。
“动态解析”的含义就是必须等到程序实际运行到这条指令的时候,解析动作才能进行。相对的,其余可触发解析的指令都是“静态”的,可以在刚刚完成加载阶段,还没有开始执行代码时就进行解析。
初始化:

类加载过程中的最后一步。
初始化阶段是执行类构造器()方法的过程。
()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块中的语句合并产生的。
()与类的构造函数不同,它不需要显示地调用父类构造器,虚拟机会保证在子类的()方法执行之前,父类的()方法已经执行完毕。
简单地说,初始化就是对类变量进行赋值及执行静态代码块。

类加载器

通过上述的了解,我们已经知道了类加载机制的大概流程及各个部分的功能。其中加载部分的功能是将类的class文件读入内存,并为之创建一个java.lang.Class对象。这部分功能就是由类加载器来实现的。

类加载器分类

不同的类加载器负责加载不同的类。主要分为两类。
启动类加载器(Bootstrap ClassLoader): 由C++语言实现(针对HotSpot),负责将存放在\lib目录或-Xbootclasspath参数指定的路径中的类库加载到内存中,即负责加载Java的核心类。
其他类加载器: 由Java语言实现,继承自抽象类ClassLoader。如:
扩展类加载器(Extension ClassLoader): 负责加载\lib\ext目录或java.ext.dirs系统变量指定的路径中的所有类库,即负责加载Java扩展的核心类之外的类。
应用程序类加载器(Application ClassLoader): 负责加载用户类路径(classpath)上的指定类库,我们可以直接使用这个类加载器,通过ClassLoader.getSystemClassLoader()方法直接获取。一般情况,如果我们没有自定义类加载器默认就是用这个加载器。
以上2大类,3小类类加载器基本上负责了所有Java类的加载。下面我们来具体了解上述几个类加载器实现类加载过程时相互配合协作的流程。

双亲委派模型

双亲委派模型的工作流程是:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把请求委托给父加载器去完成,依次向上,因此,所有的类加载请求最终都应该被传递到顶层的启动类加载器中,只有当父加载器在它的搜索范围中没有找到所需的类时,即无法完成该加载,子加载器才会尝试自己去加载该类。
pic_616deb2a.png
这样的好处是不同层次的类加载器具有不同优先级,比如所有Java对象的超级父类java.lang.Object,位于rt.jar,无论哪个类加载器加载该类,最终都是由启动类加载器进行加载,保证安全。即使用户自己编写一个java.lang.Object类并放入程序中,虽能正常编译,但不会被加载运行,保证不会出现混乱。
代码实现:ClassLoader中loadClass方法实现了双亲委派模型

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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException
{



synchronized (getClassLoadingLock(name)) {



//检查该类是否已经加载过
Class c = findLoadedClass(name);
if (c == null) {



//如果该类没有加载,则进入该分支
long t0 = System.nanoTime();
try {



if (parent != null) {



//当父类的加载器不为空,则通过父类的loadClass来加载该类
c = parent.loadClass(name, false);
} else {



//当父类的加载器为空,则调用启动类加载器来加载该类
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {



//非空父类的类加载器无法找到相应的类,则抛出异常
}

if (c == null) {



//当父类加载器无法加载时,则调用findClass方法来加载该类
long t1 = System.nanoTime();
c = findClass(name); //用户可通过覆写该方法,来自定义类加载器

//用于统计类加载器相关的信息
sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
sun.misc.PerfCounter.getFindClasses().increment();
}
}
if (resolve) {



//对类进行link操作
resolveClass(c);
}
return c;
}
}

整个流程大致如下:
a.首先,检查一下指定名称的类是否已经加载过,如果加载过了,就不需要再加载,直接返回。
b.如果此类没有加载过,那么,再判断一下是否有父加载器;如果有父加载器,则由父加载器加载(即调用parent.loadClass(name, false);).或者是调用bootstrap类加载器来加载。
c.如果父加载器及bootstrap类加载器都没有找到指定的类,那么调用当前类加载器的findClass方法来完成类加载(自定义加载器)。

垃圾回收机制

如何判断对象是否会被垃圾回收机制处理掉?

  1. 引用计数法
  • 原理
    假设有一个对象A,任何一个对象对A的引用,那么对象A的引用计数器+1,当引用失败时,对象A的引用计数器-1,如果对象A的计数器的值为0,说明A没有引用,可以被回收。
  • 特点
    无法解决循环引用问题。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class abc_test {

public static void main(String[] args) {
// TODO Auto-generated method stub

MyObject object1=new MyObject(); // object1 引用计数 1
MyObject object2=new MyObject(); // object2 引用计数 1

object1.object=object2; // object1 引用计数 2
object2.object=object1; // object2 引用计数 2

object1=null; // object1 引用计数 1 ,无法回收
object2=null; // object2 引用计数 1 ,无法回收
}
}
  1. 可达性分析算法
    可达性分析算法是从离散数学中的图论引入的,程序把所有的引用关系看作一张图,从一个节点GC Root开始,寻找对应的引用节点,找到这个节点以后,继续寻找这个节点的引用节点,可达的对象都是存活的。当所有的引用节点寻找完毕之后,剩余的不可达节点则被认为是没有被引用到的节点,即无用的节点(GC Root 不可达对象),无用的节点将会被判定为是可回收的对象。
    在Java语言中,可作为GC Roots的对象包括下面几种:
  • 虚拟机栈中引用的对象(栈帧中的本地变量表)
  • 方法区中类静态属性引用的对象
  • 方法区中常量引用的对象
  • 本地方法栈中JNI(Native方法)引用的对象
    pic_74470a99.png
  1. finalize —— 对象死亡(被回收)前的最后一次挣扎/自救计划
    即使在可达性分析算法中不可达的对象,也并非是“非死不可”,这时候它们暂时处于“缓刑”阶段,要真正宣告一个对象死亡,至少要经历两次标记过程。
    第一次标记:如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链,那它将会被第一次标记;
    第一次标记后接着会进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法。即:当对象没有被覆盖finalize()或者finalize()方法已经被虚拟机调用过了,虚拟机将这两种情况都是为没有必要执行。
    第二次标记:当一个对象被判断为有必要执行finalize()方法,那么这个对象会被放置到F-Queue队列中,并且稍后JVM自动建立一个低优先级的Finalizer线程执行它,这里“执行”是虚拟机会触发这个方法,但不会承诺等待它运行结束(万一这个方法运行缓慢或者死循环,F-Queue队列其他对象岂不是永久等待)。finalize()是对象逃脱死亡的最后一次机会。稍后GC会对F-Queue进行第二次小规模标记。如果对象能在finalize()方法中重新与引用链上任何一个方法建立关联(例如把自己this关键字赋值给某个类变量或者对象的成员变量)。那么第二次标记时,将会移出即将回收的集合。否则,这个对象就会被回收了。

说说Java种的4种引用以及用法?

  1. 强引用
    被强引用关联的对象不会被垃圾回收器回收。使用 new 一个新对象的方式来创建强引用。
1
Object obj = new Object();
  1. 软引用
    用来描述一些还有用但并非必须的对象。被软引用关联的对象只有在内存不够的情况下才会被回收。使用 SoftReference 类来创建软引用。
1
2
3
Object obj = new Object();
SoftReference<Object> sf = new SoftReference<Object>(obj);
obj = null; // 使对象只被软引用关联
  1. 弱引用
    用来描述无用对象的,被弱引用关联的对象只要被垃圾回收器扫描到,无论内存是否足够,就一定会回收,即被弱引用关联的对象只能生存到下一次垃圾收集发生之前。使用 WeakReference 类来创建弱引用。
1
2
3
Object obj = new Object();
WeakReference<Object> wf = new WeakReference<Object>(obj);
obj = null;
  1. 虚引用
    也叫幽灵引用或幻影引用,是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。它的唯一作用是能在这个对象被收集器回收时收到一个系统通知。使用 PhantomReference 来创建虚引用。
1
2
3
Object obj = new Object();
PhantomReference<Object> pf = new PhantomReference<Object>(obj, null);
obj = null;

GC回收算法有哪些?优缺点?

  1. 标记清除法
  • 原理
    标记清除算法,是将垃圾回收分为2个阶段,分别是标记和清除。
    标记:从根节点开始标记引用的对象。清除:未被标记引用的对象是垃圾对象,可以被清理。
    pic_9df3a0da.png
    图一代表的是程序运行期间所有对象的状态,它们的标志位全部是0(即未标记,默认0是未标记,1为已标记),假设当前有效内存空间耗尽了,JVM将会停止应用程序的运行并开启GC线程,然后开始进行标记工作,按照根搜索算法,标记完以后,对象状态如图二。
    pic_6ee87b45.png
    图二可以看出,根据跟搜索算法,所有从root对象可达的对象都被标记为存活的对象。此时已完成了第一阶段的标记。接下来要执行第二阶段的清除,清除完后,剩下的对象及对象的状态如图三。
    pic_ef6e711b.png
    图三可以看出,没有被标记的对象将会回收清除掉,而被标记的对象将会留下,并且会将标记位重新归0。接下来唤醒停止的程序线程,让程序继续运行。

若有效内存空间耗尽,JVM会停止应用程序的运行并开启GC线程,再开始标记清除
防止产生新对象,新引用关系。导致标记时遍历所有对象时结果不准确(使存活对象也被垃圾回收)
pic_f794ebcc.png

  • 特点
    标记清除算法解决了循环引用问题(没有从root节点引用的对象都会被回收)
    但通过清除算法清理出来的内存,碎片化较为严重。因为被回收的对象可能存在于内存的各个角落,所以清理后的空闲内存不连贯。
  1. 标记压缩法
  • 原理
    标记压缩算法是标记清除算法的基础上,做了优化改进的算法。和标记清除算法一样,也是从根节点开始,对对象的引用进行标记,在清理阶段,并不是简单的清理未标记对象,而是将存活的对象压缩到内存的一段,然后清理边界以外的垃圾,从而解决了碎片化的问题。
    pic_1af6a9cb.png
  • 特点
    解决了标记清除算法的碎片化问题。
    但是标记压缩算法多了一步,对象移动内存位置的步骤,其效率有一定影响。
  1. 复制算法
  • 原理
    复制算法的核心就是,将原有的内存空间一分为二,每次只用其中的一块,在垃圾回收时,将正在使用的对象复制到另一个内存空间中,然后将该内存空间清空,交换两个内存的角色,完成垃圾的回收。
    如果内存中的垃圾对象较多,需要复制的对象就较少,这种情况下适合使用该方法并且效率较高,反之,则不适合。
    pic_4b8b2017.png
  • 特点
    适用于垃圾对象较多的情况,且清理后,内存无碎片。但不适用于垃圾对象少的情况(如老年代内存),且分配的2块内存空间,在同一个时刻,只能使用一半,内存使用率较低。
  1. 分代算法
    分代算法根据回收对象的特点进行选择,在jvm中,年轻代适合使用复制算法,老年代适合使用标记清除/压缩算法。

GC回收机制?

(1)Java垃圾回收
程序的运行必然需要申请内存资源,无效的对象资源如果不及时处理就会一直占有内存资源,最终导致内存溢出。为了让程序员更专注于代码的实现,而不用过多考虑内存释放的问题(内存的释放由系统自动识别完成)。Java语言中有了自动的垃圾回收机制,即GC回收机制。
(2)JVM堆内存结构(分代模型)
(2.1)jdk1.7 堆内存模型
pic_3d08b55a.png

  • Young 年轻代
    年轻代被划分为三部分,Eden区和两个大小严格相同的Survivor区,其中,Survivor区间中,某一时刻只有其中一个(Survivor_From)是被使用的,另一个(Survivor_To)留作垃圾收集时赋值对象使用。几乎所有新生成的对象首先是放在Eden区间。
  • Tenured 老年代
    Tenured区主要保存生命周期长的对象,一般是一些存活时间长的对象(如较大的对象直接分配老年代),或当一些对象在Young区复制转移一定的次数以后,对象就会被转移到Tenured区。因此老年代对象存活时间比较长,存活率高。
  • Perm 永久代
    Perm代主要保存class,method,field对象。

(2.2)jdk1.8 堆内存模型
pic_ec1d6cdc.png
由上图可以看出,jdk 1.8 的内存模型由2部分组成,年轻代+老年代。
年轻代:Eden + 2*Survivor
年老代:Old
在jdk 1.8 中变化最大的Perm区,用Metaspace(元数据空间)进行了替换。其中,Metaspace所占用的内存空间不是在虚拟机内部,而是在本地内存空间中。(防止由于永久代内存经常不够用或发生内存泄露,改用本地内存空间)
(3)GC回收算法(分代算法)
分代收集,是基于这样一个事实:不同的对象的生命周期是不一样的。因此,可以将不同生命周期的对象分代,不同的代采取不同的回收算法进行垃圾回收(GC),以便提高回收效率。

  • Young 年轻代(复制算法)
    由于年轻代垃圾对象较多,复制的对象较少,因此复制算法效率较高。年轻代划分为一块较大的Eden区和两块较小的Survivor区(Survivor_From、Survivor_To)
  1. 几乎所有新生成的对象首先是放在Eden区间。当Eden区间内存不足时,会发起一次GC。
  2. 在GC开始的时候,对象只会存在于Eden区和Survivor_From区,Survivor_To区是空的。
  3. 紧接着进行GC,Eden区所有存活对象都会复制到Survivor_To区,Survivor_From区中仍存活的对象根据他们的年龄决定去向,若年龄达到年龄阀值(MaxTenuringThreshold)的对象会移动到老年代,没有到达阀值的对象会复制到Survivor_To区。
  4. 清空Eden区和Survivor_From区。然后交换Survivor_To与Survivor_From区。使存活的对象均位于Survivor_From区域,Survivor_To区为空。
  5. GC会一直重复上述过程,直到Survivor_To区被填满(Survivor_To区不足以存放Eden与Survivor_From区中存活的对象时),此时所有存活的对象移动到老年代。
    pic_03cb5ade.png
  • Tenured 老年代(标记-清除/压缩法)
    由于老年代垃圾对象较少(对象存活率较高),因此适合使用标记-清除/压缩法。
    当老年代内存满时触发GC。

GC是什么时候触发的?

由于对象进行了分代处理,因此垃圾回收区域、时间也不一样。GC有两种类型:Minor GC和Full GC。

  1. Minor GC
    这种方式的GC是对年轻代的Eden区进行,不会影响到年老代。因为年轻代对象存活时间很短,所以Minor GC会频繁进行。因而,一般在这里需要使用速度快、效率高的算法,使Eden去能尽快空闲出来。
    触发条件:
    一般情况下,当新对象生成,会在年轻代Eden上分配,若Eden区域已满,申请空间失败时,就会触发Minor GC:对Eden区域进行GC,在回收时,将 Eden 和 Survivor_From 中还存活着的对象全部复制到 Survivor_To 上,然后清理 Eden 和 Survivor_From,最后将Survivor_To中对象移动到 Survivor_From。
  2. Full GC
    对整个堆进行整理,包括回收年轻代、老年代和持久代。Full GC因为需要对整个堆进行回收,所以比Minor GC要慢,因此应该尽可能减少Full GC的次数。
    触发条件:
  • 年老代(Tenured)被写满;
  • 持久代(Perm)被写满;
  • System.gc()被显示调用;
  • 上一次GC之后Heap的各域分配策略动态变化;

垃圾收集器?

[深入理解Java 垃圾收集器][Java]
pic_a6225384.png
以上是 HotSpot 虚拟机中的 7 个垃圾收集器,连线表示垃圾收集器可以配合使用。

  • 单线程与多线程:单线程指的是垃圾收集器只使用一个线程,而多线程使用多个线程;
  • 串行与并行:串行指的是垃圾收集器与用户程序交替执行,这意味着在执行垃圾收集的时候需要停顿用户程序;并行指的是垃圾收集器和用户程序同时执行。除了 CMS 和 G1 之外,其它垃圾收集器都是以串行的方式执行。
  • Serial 垃圾收集器
    串行垃圾收集器,是指使用单线程进行垃圾回收。垃圾回收时,只有一个线程在工作,并且java应用中的所有线程都要暂停,等待垃圾回收的完成。这种现象称之为STW(Stop-The-World)
    对于交互性交强的应用而言,这种垃圾收集器是不能够接受的。一般在javaweb应用中是不会采用该收集器的。在G1的FULL GC采用Serial GC进行回收。
    在程序运行参数中(VM options)添加-XX:+UseSerialGC 设置年轻代和老年代都使用串行垃圾收集器
1
2
// 设置堆的初始和最大内存为16M
-XX:+UseSerialGC -XX:+PrintGCDetails -Xms16m -Xmx16m

pic_6ad55296.png

  • Parallel 垃圾收集器
    并行垃圾收集器在串行垃圾收集器的基础上做了改进,将单线程改为了多线程进行垃圾回收,这样可以缩短垃圾回收的时间。并行垃圾收集器在收集的过程中也会暂停应用程序,与串行垃圾收集器是一样的,只是并行执行,速度更快,暂停时间更短。分为:
  • ParNew 垃圾收集器
    ParNew收集器是工作在年轻代上的,只是将串行的垃圾收集器改为了并行。ParNew GC可以与CMS GC合作使用
    在程序运行参数中(VM options)添加-XX:+UseParNewGC 设置年轻代使用ParNew回收器,老年代仍使用串行收集器
1
-XX:+UseParNewGC -XX:+PrintGCDetails -Xms16m -Xmx16m

pic_bc747b8a.png

  • ParallelGC 垃圾收集器
    ParallelGC收集器工作机制和ParNewGC收集器一样,不同在于新增了可以对老年代进行多线程垃圾回收,且增加了对吞吐量(=运行用户代码时间 / CPU总消耗时间(运行用户代码时间+垃圾收集时间))的控制。
    高吞吐量则可以高效率地利用CPU时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。
    相关参数如下:
    -XX:+UseParallelGC:年轻代使用ParallelGC垃圾回收器,老年代使用串行回收器
    -XX:+UseParallelOldGC:年轻代使用ParallelGC垃圾回收器,老年代使用ParallelOldGC回收器
    -XX:+MaxGCPauseMillis:设置最大的垃圾收集时的停顿时间,单位为毫秒(可能会调整堆的相关参数,对性能有影响,慎用)
    -XX:+GCTimeRatio:设置垃圾回收时间占程序运行时间大的百分比
    -XX:+UseAdaptiveSizePolicy:自适应GC模式,垃圾回收器将自动调整新生代,老年代等参数,达到吞吐量、堆大小、停顿时间的平衡。
1
-XX:+UseParallelGC -XX:+UseParallelOldGC -XX:+MaxGCPauseMillis=100 -XX:+PrintGCDetails -Xms16m -Xmx16m

此时年轻代和老年代都使用了ParallelGC垃圾回收器
pic_490682df.png

  • CMS 收集器
    CMS全称Concurrent Mark Sweep,是一款并发的、使用标记-清除算法的垃圾回收器,该回收器是针对老年代垃圾回收的,通过参数-XX:+UseConcMarkSweepGC进行设置。
    CMS是HotSpot虚拟机中第一款真正意义上的并发收集器,它第一次实现了让垃圾收集线程与用户线程同时工作。
    CMS垃圾回收器的执行过程如下:
    pic_68cb34f0.png
    (1)初始化标记 CMS Initial mark:会导致stw,标记一下GC Roots能直接关联到的对象,速度很快。
    (2)并发标记 CMS-concurrent-mark:与用户线程同时运行,进行GC Roots Tracing的过程。
    (3)预清理 CMS-concurrent-preclean:与用户线程同时运行,用于修正并发标记。
    (4)重新标记 CMS Final Remark:会导致stw,重新标记阶段则是为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象(产生新的/释放旧的 对象或引用关系)的标记几率(这个阶段的停顿时间一般会比初始标记阶段稍长,但远比并发标记时间短)。
    (5)并发清除 CMS-concurrent-sweep:与用户线程同时运行,清除标记对象(采用标记-清除)。
    (6)调整堆大小,设置CMS在清理之后进行内存压缩,目的是清理内存中的碎片。
    (7)并发重置状态 CMS-concurrent-reset:与用户线程同时运行,等待下次CMS的触发。
1
-XX:+UseConcMarkSweepGC -XX:+PrintGCDetails -Xms16m -Xmx16m

年轻代默认使用ParNew收集器,老年代使用CMS收集器。

  • G1 收集器(重要)
    G1垃圾收集器是在jdk1.7中正式使用的全新的垃圾收集器,是一种面向服务端应用的垃圾收集器。oracle官方计划在jdk9中将G1编程默认的收集器,以代替CMS。
    G1的设计原则就是简化JVM性能调优,开发人员只需要简单的三步便可完成调优:
    第一步:开启G1垃圾收集器
    第二步:设置堆的最大内存
    第三步:设置最大停顿时间
    设置以下参数开启G1垃圾收集器并设置GC最大暂停时间为200ms
1
-XX:+UseG1GC -Xmx32g -XX:MaxGCPauseMillis=200 -XX:+PrintGCDetails -Xms16m -Xmx16m

G1垃圾收集器的原理是:
相比其他收集器而言,最大的区别在于G1垃圾收集器取消了年轻代、老年代的物理划分,取而代之将堆划分为若干个区域(Regin),这些区域包含了有逻辑上的年轻代、老年代区域。
每个区域被标记了Eden、Survivor、Old和Humongous,在运行时充当相应的角色。H代表Humongous,用于存放占用空间超过分区容量50%以上的大对象。
每个Regin都有一个RememberSet,用来记录该Regin对象的引用对象所在Regin。通过使用Remember Set,在做可达性分析时可以避免全堆扫描。
G1取消了新生代、老年代物理空间的划分。这样我们再也不用单独地对每个代的空间进行设置,也不用担心每个代地内存是否足够。
pic_dcfc1a70.png
G1垃圾收集器提供三种垃圾回收模式:

  • Young GC
    发生在年轻代的GC算法,一般对象(除了巨型对象)都是在Eden Region中分配内存,当所有eden region被耗尽无法申请内存时,就会触发一次Young GC,采用复制算法,执行完一次Young GC,存活对象会被拷贝到Survivor Region或者晋升到Old Region中。
  • Mixed GC
    当越来越多地对象晋升到老年代Old Regin时候,为了避免堆内存被耗尽,虚拟机会触发一个混合垃圾回收器,即Mixed GC。该算法除了回收整个Young Regin,还会回收一部分Old Regin。
    Mixed GC触发通过设置参数XX:InitiatingHeapOccupancyPercent触发执行
1
2
// 当老年代大小占整个堆大小百分比达到 80% 时,触发一次mixed gc
-XX:InitiatingHeapOccupancyPercent = 80

Mixed GC执行过程分为以下几个步骤:
(1)初始标记 initial mark:STW,标记GC Roots直接关联对象
(2)并发标记 concurrent marking:与应用程序并发执行,在整个堆中查找从初始标记衍生出的存活对象
(3)最终标记 Remark:STW,修正在并发标记期间因用户程序继续运作而导致标记产生变动的那一部分标记记录
(4)清除垃圾 Cleanup:STW,采用复制算法进行垃圾回收,将一部分Regin里的存活对象复制到另一部分Regin中。
pic_cd0fef39.png

  • Full GC
    如果对象内存分配速度过快,Mixed Gc来不及回收,导致老年代被填满,就会触发一次Full GC,G1的Full GC算法就是单线程执行的Serial GC,会导致异常长时间的暂停时间,需要进行不断的调优,尽可能的避免Full GC

面试

对象的生命周期?

  • 创建阶段(Created)
    在创建阶段系统通过下面的几个步骤来完成对象的创建过程:
    (1)为对象分配存储空间
    (2)开始构造对象
    (3)从超类到子类对static成员进行初始化
    (4)超累成员变量按顺序初始化,递归调用超累的构造方法
    (5)子类成员变量按顺序初始化,子类构造方法调用
    一旦对象被创建,并被分派给某些变量赋值,这个对象的状态就切换到应用状态
  • 应用阶段(In Use)
    对象至少被一个强引用持有。
  • 不可见阶段(Invisible)
    当一个对象处于不可见阶段时,说明程序本身不再持有该对象的任何强引用,虽然该这些引用仍然是存在着的。
    简单说就是程序的执行已经超出了该对象的作用域了。
  • 不可达阶段(Unreachable)
    对象处于不可达阶段是指该对象不再被任何强引用所持有。
    与“不可见阶段”相比,“不可见阶段”是指程序不再持有该对象的任何强引用,这种情况下,该对象仍可能被JVM等系统下的某些已装载的静态变量或线程或JNI等强引用持有着,这些特殊的强引用被称为”GC root”。存在着这些GC root会导致对象的内存泄露情况,无法被回收。
  • 收集阶段(Collected)
    当垃圾回收器发现该对象已经处于“不可达阶段”并且垃圾回收器已经对该对象的内存空间重新分配做好准备时,则对象进入了“收集阶段”。如果该对象已经重写了finalize()方法,则会去执行该方法的终端操作。
  • 终结阶段(Finalized)
    当对象执行完finalize()方法后仍然处于不可达状态时,则该对象进入终结阶段。在该阶段是等待垃圾回收器对该对象空间进行回收。
  • 对象空间重分配阶段(De-allocated)
    垃圾回收器对该对象的所占用的内存空间进行回收或者再分配了,则该对象彻底消失了,称之为“对象空间重新分配阶段”。

谈谈static关键字/谈谈static编译运行时的流程,在虚拟机中如何保存的?

  1. static 关键字
    被static关键字修饰的方法或者变量不需要依赖于对象来进行访问,只要类被加载了,就可以通过类名去进行访问。
1
2
3
// 通过类名直接访问静态变量/方法
ClassName.propertyName
ClassName.methodName(……)

static方法
static方法一般称作静态方法,静态方法不依赖于任何对象就可以进行访问。因此静态方法不使用this,且在静态方法中不能访问类的非静态成员变量和非静态成员方法,因为静态方法独立于任何对象实例,非静态成员方法/变量都是必须依赖具体的对象才能够被调用,因此:
(1)静态方法仅能调用其他static方法
(2)静态方法仅能访问static数据
(3)静态方法不能引用this或super
static变量
静态变量是随着类加载时被完成初始化的,它在内存中仅有一个,且JVM也只会为它分配一次内存,可以直接通过类名来访问它,同时类所有的实例都共享静态变量,所有实例的引用都指向同一个地方,任何一个实例对其的修改都会导致其他实例的变化。但是实例变量则不同,它是伴随着实例的,每创建一个实例就会产生一个实例变量,它与该实例同生共死。

  1. static 在内存中存储方式
    Java把内存分为栈内存和堆内存,其中栈内存用来存放一些基本类型的变量、数组和对象的引用,堆内存主要存放一些对象实例。在JVM加载一个类的时候,若该类存在static修饰的成员变量和成员方法,则会为这些成员变量和成员方法在固定的位置(方法区:存放class被加载后的类信息、常量、静态变量等)开辟一个固定大小的内存区域(只要这个类被加载,Java虚拟机就能根据类名在运行时数据区的方法区内定找到他们),有了这些“固定”的特性,那么JVM就可以非常方便地访问他们。
存放位置 生命周期
实例变量/方法 随着对象的创建存放于堆内存中 随对象的消失而消失
静态(类)变量/方法 随着类的加载存放于方法区 生命周期最长,随类的消失而消失

第六章 Java容器类

  • Java 集合框架
    • Java 集合类继承关系
    • Java 集合类简介
      • Collection (Interface)
      • Map (Interface)
  • HashMap
    • 存储结构
    • 工作原理
    • 源码解析
    • HashMap、HashSet、HashTable区别
    • 面试
  • List
    • ArrayList
    • LinkedList
  • 红黑树
  • 谈谈Java集合中那些线程安全的集合 & 实现原理?

Java 集合框架

Java 集合类继承关系

pic_a0059b65.png

Java 集合类简介

Collection (Interface)

Collection是最基本的集合接口,存储对象元素集合。一个Collection代表一组Object(元素)。
适用Iterator迭代器遍历集合元素

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Collection<Day> days = new ArrayList<Day>();
for(int i =0;i<10;i++){



Day day = new Day(i,i*60,i*3600);
days.add(day);
}
//获取days集合的迭代器
Iterator<Day> iterator = days.iterator();
while(iterator.hasNext()){


//判断是否有下一个元素
Day next = iterator.next();//取出该元素
//逐个遍历,取得元素后进行后续操作
.....
}
  • List (Interface)
    List是一个允许重复元素的指定索引、有序集合。集合中每个元素都有其对应的顺序索引。List集合允许使用重复元素,可以通过索引来访问指定位置的集合元素 。List集合默认按元素的添加顺序设置元素的索引,例如第一个添加的元素索引为0,第二个添加的元素索引为1…
    从List接口的方法来看,List接口增加了面向位置的操作,允许在指定位置上插入/访问元素。
List实现 数据特点 使用场景
ArrayList 实现了List接口的动态大小的数组 查找元素
LinkedSet 实现了List接口的链表维护的序列容器(双向链表) 插入/删除元素
  • Set (Interface)
    Set是一种不包含重复的元素的Collection,即任意的两个元素e1和e2都有e1.equals(e2)=false,Set最多有一个null元素。
    如果试图把两个相同的元素加入同一个Set集合中,则添加操作失败,add()方法返回false,且新元素不会被加入。
Set实现 数据特点 数据结构
HashSet 无序的、无重复的数据集合 基于HashMap(使用HashMap的key作为单个元素存储)
LinkedSet 维护次序的HashSet 基于LinkedHashMap
TreeSet 保持元素大小次序的集合,元素需要实现Comparable接口 基于TreeMap
  • Set/HashSet如何确保它的唯一性?
    [Object.equals() & Object.hashcode()][Object.equals_ _ Object.hashcode]
    向Set中添加元素时需先判断是否有相同key的元素
1
if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {... }
  1. 首先判断set集合中是否有与新添加数据hashcode值一致的元素
  2. 如果有hashcode相同的元素,再调用equals方法进一步判断(若集合中没有与新添加数据hashcode值一致的元素,则不调用equals方法)

因此,在Java运行时环境判断HashSet和HastMap中的两个对象相同或不同应该先判断hashcode是否相等,再判断是否equals。 只有两者均相同,才能保证对象的一致性。
为了保证HashSet中的对象不会出现重复值,在被存放元素的类中必须要重写hashCode()和equals()这两个方法。

重写规范

  1. 如果两个对象相同,那么他们的hashcode应该相等
  2. 如果两个对象不相同,他们的hashcode可能相同
  • Queue (Interface)
    Queue用户模拟队列这种数据结构,队列通常是指“先进先出”(FIFO,first-in-first-out)的容器。队列的头部是在队列中存放时间最长的元素,队列的尾部是保存在队列中存放时间最短的元素。新元素插入(offer)到队列的尾部,访问元素(poll)操作会返回队列头部的元素。通常,队列不允许随机访问队列中的元素。
Queue实现 数据特点
Deque(Interface) 扩展自Queue的双端队列,它支持在两端插入和删除元素,LinkedList实现Deque接口
PriorityQueue 一个基于优先级堆的无界优先级队列。优先级队列的元素按照其自然顺序进行排序,或者根据构造队列时提供的 Comparator 进行排序

Map (Interface)

Map是图接口,存储键值对映射的容器类。Map提供key到value的映射。一个Map中不能包含相同的key,每个key只能映射一个value。

Map实现 使用场景 数据结构
HashMap 哈希表存储键值对,key不重复,无序 哈希散列表
LinkedHashMap 可以记录插入顺序的HashMap Hash表和链表的实现,并且依靠着双向链表保证了迭代顺序是插入的顺序
TreeMap 具有元素排序功能的HashMap,即保持key的大小顺序 红黑树(中序遍历迭代输出有序序列)
WeakHashMap 弱键映射,映射之外无引用的键,可被垃圾回收 哈希散列表
  • Map的遍历方式
    Map集合提供3种遍历访问方法,
  1. Set keySet() 获得所有key的集合然后通过key访问value
1
2
3
4
5
6
7
Set<String> keySet = map.keySet();	//先获取map集合的所有键的Set集合
Iterator<String> it = keySet.iterator(); //有了Set集合,就可以获取其迭代器。

while(it.hasNext()) {
String key = it.next(); // 获取键值
String value = map.get(key); // 有了键可以通过map集合的get方法获取其对应的值。
}
  1. Collection values() 获得value的集合
1
Collection<String> collection = map.values();//返回值是 值的Collection集合
  1. Set< Map.Entry< K, V>> entrySet() 获得key-value键值对的集合
1
2
3
4
5
6
7
8
9
10
//通过entrySet()方法将map集合中的映射关系取出(这个关系就是Map.Entry类型)
Set<Map.Entry<String, String>> entrySet = map.entrySet();
//将关系集合entrySet进行迭代,存放到迭代器中
Iterator<Map.Entry<String, String>> it = entrySet.iterator();

while(it.hasNext()) {
Map.Entry<String, String> me = it.next();//获取Map.Entry关系对象me
String key = me.getKey();//通过关系对象获取key
String value = me.getValue();//通过关系对象获取value
}

Entry<K,V>为Map<K,V>的一个内部接口,其实每一个键值对都是一个Entry的实例关系对象,所以Map实际其实就是Entry的一个Collection。

HashMap

HashMap就是最基础最常用的一种Map,它无序,以散列表(数组+链表/红黑树)的方式进行存储,存储内容是键值对映射。是一种非同步的容器类,故它的线程不安全。

存储结构

  • 内部原理
    HashMap采用散列表(哈希表)存储。即由数组和单向链表共同完成,当链表长度超过8个时会转化为红黑树(实现查找时间复杂度O(logn))
    哈希表是通过哈希函数把特定的键值映射到表中一个位置来访问记录的数据结构,哈希函数用来维护键与值之间一一对应关系。
    它的特点是查询快,时间复杂度是O(1),插入和删除的操作比较慢,时间复杂度是O(n)
  • 内部实现
    内部包含了一个 Entry 类型的数组 table。
1
transient Entry[] table;

Entry 存储着键值对。它包含了四个字段,从 next 字段我们可以看出 Entry 是一个链表。即数组中的每个位置被当成一个桶,一个桶存放一个链表。HashMap 使用拉链法来解决冲突,同一个链表中存放哈希值和散列桶取模运算结果相同的 Entry。

1
2
3
4
5
6
7
8
9
static class Entry<K,V> implements Map.Entry<K,V> {



final K key;
V value;
Entry<K,V> next;
int hash;
}

pic_c5738fce.png

工作原理

  1. 确认桶下标——计算hash & 取模
1
2
3
4
5
6
7
8
9
10
// 计算hash值
int hash = hash(key);
// 将 key 的 hash 值对桶个数取模:hash%capacity 得到下标
int i = indexFor(hash, table.length);
/* indexFor的实现:
如果能保证 capacity 为 2 的 n 次方,那么就可以将这个操作转换为位运算。
static int indexFor(int h, int length) {
return h & (length-1);
}
*/

HashMap中hash函数的实现:

1
2
3
4
5
6
7
static final int hash(Object key) {



int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

高16bit不变,低16bit和高16bit做了一个异或。
pic_5a634072.png
主要是从速度、功效、质量来考虑的,这么做可以在bucket的n比较小的时候,也能保证考虑到高低bit都参与到hash的计算中,同时不会有太大的开销。

哈希函数的设计:保证将关键字均匀地分配到每个桶中
(1)计算结果为int类型
(2)数组长度范围内(0~length-1)
(3)尽可能充分利用数组中每一个位置

  • 除留余数法:用一个特定的质数来除所给定的关键字,所得余数即为该关键字的哈希值
  1. 冲突处理——拉链法

(1)查找

  • 计算键值对所在的桶下标
  • 在链表上顺序查找,时间复杂度与链表长度成正比

(2)插入

  • 采用链表头插法,也就是新的键值对插在链表的头部
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
void addEntry(int hash, K key, V value, int bucketIndex) {



if ((size >= threshold) && (null != table[bucketIndex])) {



resize(2 * table.length);
hash = (null != key) ? hash(key) : 0;
bucketIndex = indexFor(hash, table.length);
}

createEntry(hash, key, value, bucketIndex);
}

void createEntry(int hash, K key, V value, int bucketIndex) {



Entry<K,V> e = table[bucketIndex];
// 头插法,链表头部指向新的键值对
table[bucketIndex] = new Entry<>(hash, key, value, e);
size++;
}

哈希表冲突处理方法:

  • 开放定址法(线性探测再散列,二次探测再散列,伪随机探测再散列):发生哈希冲突,寻找另一个未被占用的数组地址;
  • 链地址法(HashMap):将新节点添加到对应哈希值所在链表链尾;
  • 再哈希法:提供多个哈希函数,直到不再产生冲突;
  • 建立公共溢出区:将哈希表分为基本表和溢出表,产生哈希冲突的节点放入移除表。

源码解析

  • put 解析
  1. 如果HashMap为空,则进行初始化;
  2. 调用哈希函数对Key求Hash值,然后再计算下标。并查找所在链表。
  3. 如果链表长度超过阈值(TREEIFY_THRESHOLD == 8),就把链表转换成红黑树。
  4. 如果结点的键已经存在就替换旧值。否则用头插法插入新结点。
  5. 如果桶满了(容量+加载因子),就需要resize(双倍扩容,保证2的n次幂)进行扩容,并且为了使结点均匀分散,应该重新分配结点位置
    if(++size>threshold)resize();
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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
public V put(K key, V value) {



if (table == EMPTY_TABLE) {



// 1. HashMap 为空,则进行初始化
inflateTable(threshold);
}
// 键为 null 单独处理
if (key == null)
return putForNullKey(value);
// 2. 调用哈希函数对key求hash值,并取模得桶下标
int hash = hash(key);
int i = indexFor(hash, table.length);
// 3. 先找出是否已经存在键为 key 的键值对
for (Entry<K,V> e = table[i]; e != null; e = e.next) {



Object k;
// 注:散列类容器唯一性判断
if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {



// 如果存在的话就更新这个键值对的值为 value
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}

modCount++;
// 4. 插入新键值对(如果碰撞了则头插法,没有碰撞则直接插在)
addEntry(hash, key, value, i);
return null;
}

/*
HashMap 允许插入键为 null 的键值对。但是因为无法调用 null 的 hashCode() 方法,也就无法确定该键值对的桶下标,只能通过强制指定一个桶下标来存放。HashMap 使用第 0 个桶存放键为 null 的键值对。
private V putForNullKey(V value) {
for (Entry<K,V> e = table[0]; e != null; e = e.next) {
if (e.key == null) {
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
modCount++;
addEntry(0, null, value, 0);
return null;
}
*/
  • get 解析
  1. 调用哈希函数对Key求Hash值,然后再计算Entry数组的索引i。
  2. 遍历table[i]为头结点的链表/红黑树,如果发现有节点hash,key都相同的节点,则取出该节点的值。
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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
public V get(Object key) {



Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
final Node<K,V> getNode(int hash, Object key) {



Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {



// bucket里的第一个节点,直接命中;
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k))))
return first;
// 未命中(有冲突),则查找对应的entry
if ((e = first.next) != null) {



// 若为树,则在树中查找对应Entry,O(logn)
if (first instanceof TreeNode)
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
// 若为链表,则在链表中查找对应Entry,O(n)
do {



if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;
}
  • resize 解析(扩容+重新分配)
    当put时,如果发现目前的bucket占用程度已经超过了Load Factor(负载因子)所希望的比例,那么就会发生resize。
  1. 将新结点加到链表后
  2. bucket容量扩充为原来的两倍,然后对每个结点重新计算哈希值
  3. 这个值只可能在两个地方,一个是原下标的位置,另一种是在下标为<原下标+原容量>的位置
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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
// 需要扩容时,令 capacity 为原来的两倍
void addEntry(int hash, K key, V value, int bucketIndex) {



Entry<K,V> e = table[bucketIndex];
table[bucketIndex] = new Entry<>(hash, key, value, e);
if (size++ >= threshold)
resize(2 * table.length);
}

// 扩容操作同样需要把 oldTable 的所有键值对重新插入 newTable 中(对每个结点重新计算hash值)
void resize(int newCapacity) {



Entry[] oldTable = table;
int oldCapacity = oldTable.length;
if (oldCapacity == MAXIMUM_CAPACITY) {



threshold = Integer.MAX_VALUE;
return;
}
Entry[] newTable = new Entry[newCapacity];
transfer(newTable);
table = newTable;
threshold = (int)(newCapacity * loadFactor);
}

void transfer(Entry[] newTable) {



Entry[] src = table;
int newCapacity = newTable.length;
for (int j = 0; j < src.length; j++) {



Entry<K,V> e = src[j];
if (e != null) {



src[j] = null;
do {



Entry<K,V> next = e.next;
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e;
e = next;
} while (e != null);
}
}
}

HashMap、HashSet、HashTable区别

HashTable HashMap HashSet
接口 Map Map Set
存储类型 键值对 键值对 元素(HashSet的集合其实就是HashMap的key的集合)
线程安全 同步(synchronized) 不同步 不同步
是否可以插入NULL 不可以 可以 可以

面试

  1. 什么时候会使用HashMap?他有什么特点?
    是基于Map接口的实现,存储键值对时,它可以接收null的键值,是非同步的,HashMap存储着Entry(hash, key, value, next)对象。
  2. 你知道HashMap的工作原理吗?
    通过hash的方法,通过put和get存储和获取对象。存储对象时,我们将K/V传给put方法时,它调用hashCode计算hash从而得到bucket位置,进一步存储,HashMap会根据当前bucket的占用情况自动调整容量(超过Load Facotr则resize为原来的2倍)。获取对象时,我们将K传给get,它调用hashCode计算hash从而得到bucket位置,并进一步调用equals()方法确定键值对。如果发生碰撞的时候,Hashmap通过链表将产生碰撞冲突的元素组织起来,在Java 8中,如果一个bucket中碰撞冲突的元素超过某个限制(默认是8),则使用红黑树来替换链表,从而提高速度。
  3. 你知道get和put的原理吗?equals()和hashCode()的都有什么作用?
    通过对key的hashCode()进行hashing,并计算下标( (n-1) & hash),从而获得buckets的位置。如果产生碰撞,则利用key.equals()方法去链表或树中去查找对应的节点
  4. 你知道hash的实现吗?为什么要这样实现?
    在Java 1.8的实现中,是通过hashCode()的高16位异或低16位实现的:(h = k.hashCode()) ^ (h >>> 16),主要是从速度、功效、质量来考虑的,这么做可以在bucket的n比较小的时候,也能保证考虑到高低bit都参与到hash的计算中,同时不会有太大的开销。
  5. 如果HashMap的大小超过了负载因子(load factor)定义的容量,怎么办?
    如果超过了负载因子(默认0.75),则会重新resize一个原来长度两倍的HashMap,并且重新调用hash方法。

List

ArrayList

以数组实现。节约空间,但数组有容量限制。超出限制时会增加50%容量,用System.arraycopy()复制到新的数组,因此最好能给出数组大小的预估值。默认第一次插入元素时创建大小为10的数组。
按数组下标访问元素—get(i)/set(i,e) 的性能很高,这是数组的基本优势。
直接在数组末尾加入元素—add(e)的性能也高,但如果按下标插入、删除元素—add(i,e), remove(i), remove(e),则要用System.arraycopy()来移动部分受影响的元素,性能就变差了,这是基本劣势。

  • ArrayList “动态数组” 扩容机制
    ArrayList是基于数组实现的,添加元素时若数组的容量不够,ArrayList会自动扩容:
  1. 添加元素前判断数组容量是否足够,若不够,则先扩容
  2. 每次扩容都是按原容量的1.5倍进行扩容(新数组容量 = 原数组容量*1.5 + 1)
  3. 原数组通过Arrays.copyOf()将原数据元素拷贝到心数组
1
2
3
4
5
6
7
8
9
10
11
12
public void ensureCapacity(int minCapacity) {



if (minCapacity > oldCapacity) {


// 判断原数组容量是否足够,若不够
int newCapacity = oldCapacity + (oldCapacity >> 1); // 新数组长度为原数组1.5倍扩容
elementData = Arrays.copyOf(elementData, newCapacity); //将原数组拷贝一份到新数组
}
}
  • add方法
1
2
3
4
5
6
7
8
9
// 在ArrayList中增加元素的时候,会使用add函数。他会将元素放到末尾。
public boolean add(E e) {



ensureCapacityInternal(size + 1); // 自动扩容机制的核心
elementData[size++] = e;
return true;
}

当增加数据的时候,如果ArrayList的大小已经不满足需求时,那么就将数组变为原长度的1.5倍,之后的操作就是把老的数组拷到新的数组里面。

  • set/get方法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 先做index检查,然后执行赋值或访问操作
public E set(int index, E element) {



rangeCheck(index);
E oldValue = elementData(index);
elementData[index] = element;
return oldValue;
}
public E get(int index) {



rangeCheck(index);
return elementData(index);
}
  • remove方法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public E remove(int index) {



rangeCheck(index);
modCount++;
E oldValue = elementData(index);
int numMoved = size - index - 1;
if (numMoved > 0)
// 把后面的往前移
System.arraycopy(elementData, index+1, elementData, index,
numMoved);
// 把最后的置null
elementData[--size] = null; // clear to let GC do its work
return oldValue;
}

LinkedList

以双向链表(head、tail)实现。链表无容量限制,但双向链表本身使用了更多空间,也需要额外的链表指针操作。
按下标访问元素—get(i)/set(i,e) 要悲剧的遍历链表将指针移动到位(如果i>数组大小的一半,会从末尾移起)。
插入、删除元素时修改前后节点的指针即可,但还是要遍历部分链表的指针才能移动到下标所指的位置,只有在链表两头的操作—add(),addFirst(),removeLast()或用iterator()上的remove()能省掉指针的移动。

  • set/get()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public E set(int index, E element) {



checkElementIndex(index);
Node<E> x = node(index);
E oldVal = x.item;
x.item = element;
return oldVal;
}
public E get(int index) {



checkElementIndex(index);
return node(index).item;
}

这两个函数都调用了node函数,该函数会以O(n/2)的性能去获取一个节点,具体实现如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
Node<E> node(int index) {



// assert isElementIndex(index);
if (index < (size >> 1)) {



Node<E> x = first;
for (int i = 0; i < index; i++)
x = x.next;
return x;
} else {



Node<E> x = last;
for (int i = size - 1; i > index; i--)
x = x.prev;
return x;
}
}

就是判断index是在前半区间还是后半区间,如果在前半区间就从head搜索,而在后半区间就从tail搜索。而不是一直从头到尾的搜索。如此设计,将节点访问的复杂度由O(n)变为O(n/2)。

红黑树

  • 红黑树特点

二叉查找树(BST)特性:

  1. 左子树上节点值均小于等于根节点的值
  2. 右子树上节点值均大于等于根节点的值
  3. 左右子树均为二叉排序树

二叉查找树采用二分查找的思想,查找所需最大次数等同于二叉查找树的高度。
红黑树(Red Black Tree)是自平衡(防止高度过高)的二叉查找树,特性:

  1. 节点是红色或黑色
  2. 根节点是黑色
  3. 叶子节点是黑色的空节点
  4. 每个红色节点的2个子结点都是黑色
  5. 从任一节点到其每个叶子的所有路径都包含相同数目黑色节点

pic_6a0c86b7.png

  • 红黑树操作
    红黑树和AVL树类似,都是在进行插入和删除操作时通过特定操作保持二叉查找树的平衡,从而获得较高的查找性能。它可以在O(log n)时间内做查找,插入和删除,这里的n 是树中元素的数目。
    由于插入新结点后可能会破坏红黑树的规则,此时需要进行调整,包括变色和旋转,旋转包括左旋和右旋。
  • 红黑树后继节点
    a. 空节点,没有后继
    b. 有右子树的节点,后继就是右子树的“最左节点”
    c. 无右子树的节点,后继就是该节点所在左子树的第一个祖先节点
    有右子树的节点,节点的下一个节点,肯定在右子树中,而右子树中“最左”的那个节点则是右子树中最小的一个,那么当然是右子树的“最左节点”,就好像下图所示:
    pic_b16a0fce.png
    无右子树的节点,先找到这个节点所在的左子树(右图),那么这个节点所在的左子树的父节点(绿色节点),就是下一个节点。
    pic_66beb657.png
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
26
27
28
29
30
31
32
33
34
35
static <K,V> TreeMap.Entry<K,V> successor(Entry<K,V> t) {



if (t == null)
return null;
else if (t.right != null) {



// 有右子树的节点,后继节点就是右子树的“最左节点”
// 因为“最左子树”是右子树的最小节点
Entry<K,V> p = t.right;
while (p.left != null)
p = p.left;
return p;
} else {



// 如果右子树为空,则寻找当前节点所在左子树的第一个祖先节点
// 因为左子树找完了,根据LDR该D了
Entry<K,V> p = t.parent;
Entry<K,V> ch = t;
// 保证左子树
while (p != null && ch == p.right) {



ch = p;
p = p.parent;
}
return p;
}
}
  • 红黑树应用
    红黑树应用很多,其中JDK的集合类TreeMap和TreeSet底层使用红黑树实现,Java8中HashMap也是用红黑树实现。

参考:[漫画算法:什么是红黑树][Link 6]

谈谈Java集合中那些线程安全的集合 & 实现原理?

  1. 同步集合类
    采用synchronized锁机制保证线程安全。
    包括Hashtable、Vector、同步集合包装类,Collections.synchronizedMap()和Collections.synchronizedList()
1
2
3
// HashTable 的 get、put方法
public synchronized V get(Object key) {...}
public synchronized V put(K key, V value) {...}
  1. 并发集合类
    同步集合比并发集合会慢得多,主要原因是使用一个全局的锁来同步不同线程间的并发访问。同一时间点,只能有一个线程持有锁,也就是说在同一时间点,只能有一个线程能访问容器。这虽然保证多线程间的安全并发访问,但同时也导致对容器的访问变成串行化的了。
  • ConcurrentHashMap
    ConcurrentHashMap通过使用分离锁,只针对某(键的hash值对应的)具体的Segment(而不是整个ConcurrentHashMap。因为插入键值对的操作只在这个Segment包含的桶中完成,因此不需要锁定整个ConcurrentHashMap。此时其他写进程对另外15个Segment加锁并不会因为对当前这个Segment加锁而阻塞。同时所有读线程几乎不会因本线程加锁而阻塞)进行加锁,同时允许多线程访问其他未上锁Segment。
    相比较于 HashTable 和由同步包装器包装的 HashMap每次只能有一个线程执行读或写操作,ConcurrentHashMap 在并发访问性能上有了质的提高。在理想状态下,ConcurrentHashMap 可以支持 16 个线程执行并发写操作(如果并发级别设置为 16),及任意数量线程的读操作。
  • CopyOnWrite容器(CopyOnWriteArrayList、CopyOnWriteHashSet)
    CopyOnWrite容器即写时复制的容器。通俗的理解是当我们往一个容器添加元素的时候,不直接往当前容器添加,而是先将当前容器进行Copy,复制出一个新的容器,然后新的容器里添加元素,添加完元素之后,再将原容器的引用指向新的容器。这样做的好处是我们可以对CopyOnWrite容器进行并发的读,而不需要加锁,因为当前容器不会添加任何元素。所以CopyOnWrite容器也是一种读写分离的思想,读和写不同的容器。
    CopyOnWrite并发容器用于读多写少的并发场景。

第八章 泛型

  • 语法糖
  • 什么是泛型?能解决什么问题?
  • 说一下Java泛型工作机制?什么是类型擦除?
  • 泛型的使用
  • 什么是泛型中的限定通配符和非限定通配符?

语法糖

语法糖(Syntactic Sugar),也称糖衣语法,是由英国计算机学家Peter.J.Landin发明的一个术语,指在计算机语言中添加的某种语法,这种语法对语言的功能并没有影响,但是更方便程序员使用。Java中最常用的语法糖主要有泛型、变长参数、条件编译、自动拆装箱、内部类等。虚拟机并不支持这些语法,它们在编译阶段就被还原回了简单的基础语法结构,这个过程成为解语法糖。

泛型的目的: Java 泛型就是把一种语法糖,通过泛型使得在编译阶段完成一些类型转换的工作,避免在运行时强制类型转换而出现ClassCastException,即类型转换异常。

什么是泛型?能解决什么问题?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class Box<T> {



// T stands for "Type"
private T t;
public void set(T t) {


this.t = t; }
public T get() {


return t; }
}

泛型的本质是参数化类型,即将所操作数据类型指定为参数。实现数据类型的任意化。

在Java1.5之前(泛型 是JDK5中引入的一个新特性),在集合中存储对象时通过对类型Object的引用实现参数的任意化,需要在使用前进行强制类型转换。对于强制类型转换错误的情况,编译器可能不提示错误,在运行的时候才出现异常,这是一个安全隐患。
泛型的好处在于:

  • 提供了编译期的类型安全,确保你只能把正确类型的对象放入集合中,避免了在运行时出现ClassCastException。
  • 消除了代码中许多的强制类型转换,增强了代码的可读性。

说一下Java泛型工作机制?什么是类型擦除?

泛型是通过类型擦除来实现的。
类型擦除是编译器在编译时擦除了所有类型相关的信息,所以在运行时不存在任何类型相关的信息。例如List在运行时仅用一个List来表示。
这是因为不管为泛型的类型形参传入哪一种类型实参,对于Java来说,它们依然被当成同一类处理,在内存中也只占用一块内存空间。从Java泛型这一概念提出的目的来看,其只是作用于代码编译阶段,在编译过程中,对于正确检验泛型结果后,会将泛型的相关信息擦出,也就是说,成功编译过后的class文件中是不包含任何泛型信息的。泛型信息不会进入到运行时阶段。
这样做的目的,是确保能和Java 5之前的版本进行兼容。你无法在运行时访问到类型参数,因为编译器已经把泛型类型转换成了原始类型。

1
2
3
4
5
List<String> l1 = new List<String>();
List<Double> l2 = new List<Double>();
System.out.println(l1.getClass() == l2.getClass());
// 输出 true
// 由于类型擦除,l1.getClass() == l2.getClass() == java.util.List

pic_f669f7f4.png

  1. 优点
  • 向前兼容性好
    是确保能和Java 5之前的版本进行兼容
  • 减小运行时负担
    在Java中,是不存在类似List,List等等这样的类型,真正被加载进方法区存储的只有List类型
  1. 缺点
  • 基本类型无法作为泛型实参
  • 泛型类型无法用做方法重载
    类型擦除意味着List和List编译后其类型都是List,也就是属于同个方法:
1
2
3
4
5
6
7
8
public void testMethod(List<Integer> array) {


}
public void testMethod(List<Double> array) {


} // compile error
  • 泛型类型无法当做真实类型使用
    由于类型擦除后像List这样的类型是不存在的,所以也就无法直接当成真实类型使用:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
static <T> void genericMethod(T t) {



T newInstance = new T(); // compile errror
Class c = T.class; // compile errror
List<T> list = new ArrayList<T>(); // compile errror
if (list instance List<Integer>) {


} // compile errror
}
// 这也是Gson.fromJson需要传入Class的原因
public <T> T fromJson(String json, Class<T> classOfT)
throws JsonSyntaxException {



Object object = fromJson(json, (Type)classOfT);
return Primitives.wrap(classOfT).cast(object);
}
  • 静态方法无法引用类泛型参数
    类的泛型参数只有在类实例化的时候才知道,而静态方法的执行不需要有类的示例存在,所以静态方法无法引用类泛型参数:
1
2
3
4
5
6
7
8
9
class GenericClass<T> {



public static T max(T a, T b) {


}
}
  • 泛型类型会带来类型强转的运行时开销
1
2
3
4
List<String> strList = new Array<>();
strList.add("Hallo");
String value = strList.get(0); // 返回Object,compile errror
String value = (String)strList.get(0);

泛型的使用

  1. 泛型类和泛型接口
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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
public class Container<K, V> {




private K key;
private V value;

public Container(K k, V v) {



key = k;
value = v;
}

public K getkey() {



return key;
}

public V getValue() {



return value;
}

public void setKey() {



this.key = key;
}

public void setValue() {



this.value = value;
}

}

在使用Container类时,只需要指定K,V的具体类型即可,从而创建出逻辑上不同的Container实例,用来存放不同的数据类型。

1
2
3
4
5
6
7
8
public static void main(String[] args) {



Container<String,String> c1=new Container<String ,String>("name","hello");
Container<String,Integer> c2=new Container<String,Integer>("age",22);
Container<Double,Double> c3=new Container<>(1.1,1.3); // 泛型的“菱形”语法:Java允许在构造器后不需要带完成的泛型信息,只要给出一对尖括号(<>)即可,Java可以推断尖括号里应该是什么泛型信息。
}
  1. 泛型的方法
    所谓泛型方法,就是在声明方法时定义一个或多个类型形参。 泛型方法的用法格式如下:
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
26
27
28
29
30
31
32
修饰符<T, S> 返回值类型 方法名(形参列表){
方法体


class Demo{



public <T> T fun(T t){


// 可以接收任意类型的数据
return t ; // 直接把参数返回
}
};

// 当调用fun()方法时,根据传入的实际对象,编译器就会判断出类型形参T所代表的实际类型。
public class GenericsDemo26{



public static void main(String args[]){



Demo d = new Demo() ; // 实例化Demo对象
String str = d.fun("汤姆") ; // 传递字符串
int i = d.fun(30) ; // 传递数字,自动装箱
System.out.println(str) ; // 输出内容
System.out.println(i) ; // 输出内容
}
};
  1. 泛型构造器
    正如泛型方法允许在方法签名中声明类型形参一样,Java也允许在构造器签名中声明类型形参,这样就产生了所谓的泛型构造器。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class Person {



public <T> Person(T t) {



System.out.println(t);
}

}
public static void main(String[] args){



// 隐式推断泛型参数
new Person(22);
// 显示指定泛型参数
new<String> Person("hello");
}

什么是泛型中的限定通配符和非限定通配符?

表示了非限定通配符,因为

可以用任意类型来替代。

使用通配符的目的是来限制泛型的类型参数的类型。有两种限定通配符:一种是<? extends T>它通过确保类型必须是T的子类来设定类型的上界,另一种是<? super T>它通过确保类型必须是T的父类来设定类型的下界。泛型类型必须用限定内的类型来进行初始化,否则会导致编译错误。

限定通配符 非限定通配符
表示 <? extends T>
子类型通配符
<? super T>
父类型通配符
<?>
功能 通过确保类型必须是T的子类来设定类型的上界 通过确保类型必须是T的父类来设定类型的下界 可以用任意类型来替代
举例 List<? extends T>可以接受任何继承自T的类型的List
例如List<? extends Number>可以接受List< Integer >或List< Float >
List<? super T>
可以接受任何T的父类构成的List

第九章 Java I/O与NIO

  • Java IO
    • 字节与字符
      • Java 编码格式
    • File类
      • RandomAccessFile
    • IO流
      • IO流 简介
      • IO流 分类
      • IO流 四大基类
  • Java NIO
    • NIO 简介
    • 通道与缓冲区
    • 选择器

Java IO

字节与字符

在Java中有输入、输出两种IO流,每种输入、输出流又分为字节流和字符流两大类。关于字节,我们在学习8大基本数据类型中都有了解,每个字节(byte)有8bit组成,每种数据类型又几个字节组成等。关于字符,我们可能知道代表一个汉字或者英文字母。
但是字节与字符之间的关系是怎样的?
Java采用unicode编码,2个字节来表示一个字符,这点与C语言中不同,C语言中采用ASCII,在大多数系统中,一个字符通常占1个字节,但是在0~127整数之间的字符映射,unicode向下兼容ASCII。而Java采用unicode来表示字符,一个中文或英文字符的unicode编码都占2个字节。但如果采用其他编码方式,一个字符占用的字节数则各不相同。可能有点晕,举个例子解释下。
例如:Java中的String类是按照unicode进行编码的,当使用String(byte[] bytes, String encoding)构造字符串时,encoding所指的是bytes中的数据是按照那种方式编码的,而不是最后产生的String是什么编码方式,换句话说,是让系统把bytes中的数据由encoding编码方式转换成unicode编码。如果不指明,bytes的编码方式将由jdk根据操作系统决定。
getBytes(String charsetName)使用指定的编码方式将此String编码为 byte 序列,并将结果存储到一个新的 byte 数组中。如果不指定将使用操作系统默认的编码方式,我的电脑默认的是GBK编码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class Hel {



public static void main(String[] args){



String str = "你好hello";
int byte_len = str.getBytes().length;
int len = str.length();
System.out.println("字节长度为:" + byte_len);
System.out.println("字符长度为:" + len);
System.out.println("系统默认编码方式:" + System.getProperty("file.encoding"));
}
}

输出结果

1
2
3
字节长度为:9
字符长度为:7
系统默认编码方式:GBK

这是因为:在 GB 2312 编码或 GBK 编码中,一个英文字母字符存储需要1个字节,一个汉字字符存储需要2个字节。 在UTF-8编码中,一个英文字母字符存储需要1个字节,一个汉字字符储存需要3到4个字节。在UTF-16编码中,一个英文字母字符存储需要2个字节,一个汉字字符储存需要3到4个字节(Unicode扩展区的一些汉字存储需要4个字节)。在UTF-32编码中,世界上任何字符的存储都需要4个字节。
简单来讲,一个字符表示一个汉字或英文字母,具体字符与字节之间的大小比例视编码情况而定。有时候读取的数据是乱码,就是因为编码方式不一致,需要进行转换,然后再按照unicode进行编码。

Java 编码格式

计算机中存储信息的最小单元是1byte即8bit,所以能表示的字符范围是 0~255 个,人类要表示的符号太多,无法用一个字节来完全表示,需要一个新的数据结构char来表示这些字符。char与byte之间转换需要编码与解码。
编码:字节(Byte = 8bit 默认数据的最小单位)与字符(Character = 2byte)的转换方式。
编码就是把字符转换为字节,而解码是把字节重新组合成字符。
如果编码和解码过程使用不同的编码方式那么就出现了乱码。
编码规范

  1. ASCII
    ASCII码一共规定了128个字符的编码,用一个字节的低 7 位表示,031 是控制字符如换行回车删除等;32126 是打印字符,可以通过键盘输入并且能够显示出来。
  2. ISO-8859-1
    ISO 组织在 ASCII 码基础上又制定了一些列标准用来扩展 ASCII 编码,共有256个字符。
  3. GB2312
  4. GBK
    中文字符占 2 个字节,英文字符占 1 个字节;
  5. UTF-16
    中文字符和英文字符都占 2 个字节;
  6. UTF-8
    UTF-8就是在互联网上使用最广的一种Unicode的实现方式。UTF-8最大的一个特点,就是它是一种变长的编码方式。它可以使用1~4个字节表示一个符号,根据不同的符号而变化字节长度。
    中文字符占 3 个字节,英文字符占 1 个字节;
    Java中需要编码的场景包括:
  7. I/O操作中存在的编码
    Reader类是Java I/O中读字符的父类,InputStream类是读字节的父类,通过InputStreamReader类将字节转换为字符。
    同理,Writer是写字符的父类,OutputStram类是写字节的父类,通过OutputStreamWriter将字符转换为字节。
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
26
27
28
29
30
31
32
33
34
35
public class File_Stream {



public static void main(String[] args) throws IOException {



Scanner sca=new Scanner(System.in);
//写文件
System.out.print("请输入文件名:");
String name=sca.next();
File file=new File(name+".txt");//文件名 相对路径(项目名根目录下)
// FileOutputStream fs=new FileOutputStream(file);//如果文件存在 覆盖
FileOutputStream fos=new FileOutputStream(file,true);//true表示追加,如果文件存在 向里面继续添加内容
System.out.println("请输入写入的内容:");
String str=sca.next();
byte bytes[]=str.getBytes(); //FileOutputStream 是基于字节流 把要写入的信息 保存到字节数组中
fos.write(bytes,0,bytes.length);//将字节数组中全部内容写到文件中 从0—数组的长度
fos.close();//关闭流
System.out.println("文件写入成功!");

//读文件
FileInputStream fis=new FileInputStream(file);
byte bt[]=new byte[1024];//1KB 每次最多读取的1KB 根据文件大小而定
int temp=0;
while((temp=fis.read(bt))!=-1){


//将数据保存到数组(缓冲区)中 并返回读取的字节数 -1表示读完了
System.out.println(new String(bt,0,temp));//输出数组中保存内容 按照每次读取的字节数
}
fis.close();
}
}
  1. 内存中的编码(String 的 编码方式)
    Java 中用 String 表示字符串,所以 String 类就提供转换到字节的方法,也支持将字节转换为字符串的构造函数。
1
2
3
String s = "这是一段中文字符串";    
byte[] b = s.getBytes("UTF-8"); // (字符转字节)将字符串所表示的字符按照charset编码,并以字节方式表示。
String n = new String(b,"UTF-8"); // (字节转字符)将字节数组按照charset编码进行组合识别,最后转换为unicode存储

File类

File类是java.io包下代表与平台无关的文件和目录,也就是说,如果希望在程序中操作文件和目录,都可以通过File类来完成。
① 构造函数

1
2
3
4
5
6
7
8
//构造函数File(String pathname)
File f1 =new File("c:\\abc\\1.txt");
//File(String parent,String child)
File f2 =new File("c:\\abc","2.txt");
//File(File parent,String child)
File f3 =new File("c:"+File.separator+"abc");//separator 跨平台分隔符
File f4 =new File(f3,"3.txt");
System.out.println(f1);//c:\abc\1.txt

路径分隔符: windows: “/” “” 都可以 linux/unix: “/”
注意:如果windows选择用”“做分割符的话,那么请记得替换成””,因为Java中”“代表转义字符
所以推荐都使用”/“,也可以直接使用代码File.separator,表示跨平台分隔符。
路径:

  • 相对路径:
    ./表示当前路径(默认情况下,java.io 包中的类总是根据当前用户目录来分析相对路径名。此目录由系统属性user.dir 指定,通常是 Java 虚拟机的调用目录。)
    …/表示上一级路径
  • 绝对路径:
    绝对路径名是完整的路径名,不需要任何其他信息就可以定位自身表示的文件

② 创建与删除方法

1
2
3
4
5
6
7
8
9
10
//如果文件存在返回false,否则返回true并且创建文件 
boolean createNewFile();
//创建一个File对象所对应的目录,成功返回true,否则false。且File对象必须为路径而不是文件。只会创建最后一级目录,如果上级目录不存在就抛异常。
boolean mkdir();
//创建一个File对象所对应的目录,成功返回true,否则false。且File对象必须为路径而不是文件。创建多级目录,创建路径中所有不存在的目录
boolean mkdirs() ;
//如果文件存在返回true并且删除文件,否则返回false
boolean delete();
//在虚拟机终止时,删除File对象所表示的文件或目录。
void deleteOnExit();

③ 判断方法

1
2
3
4
5
6
7
8
boolean canExecute()    ;//判断文件是否可执行
boolean canRead();//判断文件是否可读
boolean canWrite();//判断文件是否可写
boolean exists();//判断文件是否存在
boolean isDirectory();//判断是否是目录
boolean isFile();//判断是否是文件
boolean isHidden();//判断是否是隐藏文件或隐藏目录
boolean isAbsolute();//判断是否是绝对路径 文件不存在也能判断

③获取方法

1
2
3
4
5
6
7
8
9
10
11
String getName();//返回文件或者是目录的名称
String getPath();//返回路径
String getAbsolutePath();//返回绝对路径
String getParent();//返回父目录,如果没有父目录则返回null
long lastModified();//返回最后一次修改的时间
long length();//返回文件的长度
File[] listRoots();// 列出所有的根目录(Window中就是所有系统的盘符)
String[] list() ;//返回一个字符串数组,给定路径下的文件或目录名称字符串
String[] list(FilenameFilter filter);//返回满足过滤器要求的一个字符串数组
File[] listFiles();//返回一个文件对象数组,给定路径下文件或目录
File[] listFiles(FilenameFilter filter);//返回满足过滤器要求的一个文件对象数组

其中包含了一个重要的接口FileNameFilter,该接口是个文件过滤器,包含了一个accept(File dir,String name)方法,该方法依次对指定File的所有子目录或者文件进行迭代,按照指定条件,进行过滤,过滤出满足条件的所有文件。

1
2
3
4
5
6
7
8
9
10
11
12
13
// 文件过滤
File[] files = file.listFiles(new FilenameFilter() {



@Override
public boolean accept(File file, String filename) {



return filename.endsWith(".mp3");
}
});

file目录下的所有子文件如果满足后缀是.mp3的条件的文件都会被过滤出来。

RandomAccessFile

  • 简介

RandomAccessFile既可以读取文件内容,也可以向文件输出数据。同时,RandomAccessFile支持“随机访问”的方式,程序快可以直接跳转到文件的任意地方来读写数据。
由于RandomAccessFile可以自由访问文件的任意位置,所以如果需要访问文件的部分内容,而不是把文件从头读到尾,使用RandomAccessFile将是更好的选择。
与OutputStream、Writer等输出流不同的是,RandomAccessFile允许自由定义文件记录指针,RandomAccessFile可以不从开始的地方开始输出,因此RandomAccessFile可以向已存在的文件后追加内容。如果程序需要向已存在的文件后追加内容,则应该使用RandomAccessFile。

RandomAccessFile的方法虽然多,但它有一个最大的局限,就是只能读写文件,不能读写其他IO节点。
RandomAccessFile的一个重要使用场景就是网络请求中的多线程下载及断点续传。

  • 方法
  1. RandomAccessFile的构造函数
    RandomAccessFile类有两个构造函数,其实这两个构造函数基本相同,只不过是指定文件的形式不同——一个需要使用String参数来指定文件名,一个使用File参数来指定文件本身。除此之外,创建RandomAccessFile对象时还需要指定一个mode参数,该参数指定RandomAccessFile的访问模式,一共有4种模式。

“r”: 以只读方式打开。调用结果对象的任何 write 方法都将导致抛出 IOException。
“rw”: 打开以便读取和写入。
“rws”: 打开以便读取和写入。相对于 “rw”,“rws” 还要求对“文件的内容”或“元数据”的每个更新都同步写入到基础存储设备。
“rwd” : 打开以便读取和写入,相对于 “rw”,“rwd” 还要求对“文件的内容”的每个更新都同步写入到基础存储设备。

  1. RandomAccessFile的重要方法
    RandomAccessFile既可以读文件,也可以写文件,所以类似于InputStream的read()方法,以及类似于OutputStream的write()方法,RandomAccessFile都具备。除此之外,RandomAccessFile具备两个特有的方法,来支持其随机访问的特性。
    RandomAccessFile对象包含了一个记录指针,用以标识当前读写处的位置,当程序新创建一个RandomAccessFile对象时,该对象的文件指针记录位于文件头(也就是0处),当读/写了n个字节后,文件记录指针将会后移n个字节。除此之外,RandomAccessFile还可以自由移动该记录指针。下面就是RandomAccessFile具有的两个特殊方法,来操作记录指针,实现随机访问:

long getFilePointer( ):返回文件记录指针的当前位置
void seek(long pos):将文件指针定位到pos位置

  • 使用

利用RandomAccessFile实现文件的多线程下载,即多线程下载一个文件时,将文件分成几块,每块用不同的线程进行下载。下面是一个利用多线程在写文件时的例子,其中预先分配文件所需要的空间,然后在所分配的空间中进行分块,然后写入:

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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
/** 
* 测试利用多线程进行文件的写操作
*/
public class Test {




public static void main(String[] args) throws Exception {



// 预分配文件所占的磁盘空间,磁盘中会创建一个指定大小的文件
RandomAccessFile raf = new RandomAccessFile("D://abc.txt", "rw");
raf.setLength(1024*1024); // 预分配 1M 的文件空间
raf.close();

// 所要写入的文件内容
String s1 = "第一个字符串";
String s2 = "第二个字符串";
String s3 = "第三个字符串";
String s4 = "第四个字符串";
String s5 = "第五个字符串";

// 利用多线程同时写入一个文件
new FileWriteThread(1024*1,s1.getBytes()).start(); // 从文件的1024字节之后开始写入数据
new FileWriteThread(1024*2,s2.getBytes()).start(); // 从文件的2048字节之后开始写入数据
new FileWriteThread(1024*3,s3.getBytes()).start(); // 从文件的3072字节之后开始写入数据
new FileWriteThread(1024*4,s4.getBytes()).start(); // 从文件的4096字节之后开始写入数据
new FileWriteThread(1024*5,s5.getBytes()).start(); // 从文件的5120字节之后开始写入数据
}

// 利用线程在文件的指定位置写入指定数据
static class FileWriteThread extends Thread{



private int skip;
private byte[] content;

public FileWriteThread(int skip,byte[] content){



this.skip = skip;
this.content = content;
}

public void run(){



RandomAccessFile raf = null;
try {



raf = new RandomAccessFile("D://abc.txt", "rw");
raf.seek(skip);
raf.write(content);
} catch (FileNotFoundException e) {



e.printStackTrace();
} catch (IOException e) {



// TODO Auto-generated catch block
e.printStackTrace();
} finally {



try {



raf.close();
} catch (Exception e) {



}
}
}
}

}

当RandomAccessFile向指定文件中插入内容时,将会覆盖掉原有内容。如果不想覆盖掉,则需要将原有内容先读取出来,然后先把插入内容插入后再把原有内容追加到插入内容后。

IO流

IO流 简介

Java的IO流是实现输入/输出的基础,它可以方便地实现数据的输入/输出操作,在Java中把不同的输入/输出源抽象表述为”流”。流是一组有顺序的,有起点和终点的字节集合,是对数据传输的总称或抽象。即数据在两设备间的传输称为流,流的本质是数据传输,根据数据传输特性将流抽象为各种类,方便更直观的进行数据操作。
流有输入和输出,输入时是流从数据源流向程序。输出时是流从程序传向数据源,而数据源可以是内存,文件,网络或程序等。

IO流 分类

  1. 输入流和输出流

根据数据流向不同分为:输入流和输出流。
输入流:程序从数据源中读取数据。
输出流:程序向数据源中写入数据。
如下如所示:对程序而言,向右的箭头,表示输入,向左的箭头,表示输出。
pic_1b9d2b1a.png
2. 字节流和字符流

字节流和字符流和用法几乎完全一样,区别在于字节流和字符流所操作的数据单元不同。
字符流的由来: 因为数据编码的不同,而有了对字符进行高效操作的流对象。本质其实就是基于字节流读取时,去查了指定的码表。字节流和字符流的区别:
(1)读写单位不同:字节流以字节(8bit)为单位,字符流以字符为单位,根据码表映射字符,一次可能读多个字节。
(2)处理对象不同:字节流能处理所有类型的数据(如图片、avi等),而字符流只能处理字符类型的数据。
只要是处理纯文本数据,就优先考虑使用字符流。 除此之外都使用字节流。

  1. 节点流和处理流

按照流的角色来分,可以分为节点流和处理流。
可以从/向一个特定的IO设备(如磁盘、网络)读/写数据的流,称为节点流,节点流也被成为低级流。
处理流是对一个已存在的流进行连接或封装,通过封装后的流来实现数据读/写功能,处理流也被称为高级流。

1
2
3
4
//节点流,直接传入的参数是IO设备
FileInputStream fis = new FileInputStream("test.txt");
//处理流,直接传入的参数是流对象
BufferedInputStream bis = new BufferedInputStream(fis);

pic_fff37b23.png
当使用处理流进行输入/输出时,程序并不会直接连接到实际的数据源,没有和实际的输入/输出节点连接。使用处理流的一个明显好处是,只要使用相同的处理流,程序就可以采用完全相同的输入/输出代码来访问不同的数据源,随着处理流所包装节点流的变化,程序实际所访问的数据源也相应地发生变化。
实际上,Java使用处理流来包装节点流是一种典型的装饰器设计模式,通过使用处理流来包装不同的节点流,既可以消除不同节点流的实现差异,也可以提供更方便的方法来完成输入/输出功能。

IO流 四大基类

根据流的流向以及操作的数据单元不同,将流分为了四种类型,每种类型对应一种抽象基类。这四种抽象基类分别为:InputStream,Reader,OutputStream以及Writer。四种基类下,对应不同的实现类,具有不同的特性。在这些实现类中,又可以分为节点流和处理流。下面就是整个由着四大基类支撑下,整个IO流的框架图。
pic_ff5b6489.png
InputStream,Reader,OutputStream以及Writer,这四大抽象基类,本身并不能创建实例来执行输入/输出,但它们将成为所有输入/输出流的模版,所以它们的方法是所有输入/输出流都可以使用的方法。类似于集合中的Collection接口。

  1. InputStream
    InputStream 是所有的输入字节流的父类,它是一个抽象类,主要包含三个方法:
1
2
3
4
5
6
//读取一个字节并以整数的形式返回(0~255),如果返回-1已到输入流的末尾。 
int read()
//读取一系列字节并存储到一个数组buffer,返回实际读取的字节数,如果读取前已到输入流的末尾返回-1。
int read(byte[] buffer)
//读取length个字节并存储到一个字节数组buffer,从off位置开始存,最多len, 返回实际读取的字节数,如果读取前以到输入流的末尾返回-1。
int read(byte[] buffer, int off, int len)

实例

1
2
3
4
5
6
7
8
9
10
11
12
// 读取f盘下该文件f://hell/test.txt
InputStream inputStream = new FileInputStream(new File("f://hello//test.txt"));
int i = 0;
//一次读取一个字节
while ((i = inputStream.read()) != -1) {



System.out.print((char) i + " ");
}
//关闭IO流
inputStream.close();
  1. Reader
    Reader 是所有的输入字符流的父类,它是一个抽象类,主要包含三个方法:
1
2
3
4
5
6
//读取一个字符并以整数的形式返回(0~255),如果返回-1已到输入流的末尾。 
int read()
//读取一系列字符并存储到一个数组buffer,返回实际读取的字符数,如果读取前已到输入流的末尾返回-1。
int read(char[] cbuf)
//读取length个字符,并存储到一个数组buffer,从off位置开始存,最多读取len,返回实际读取的字符数,如果读取前以到输入流的末尾返回-1。
int read(char[] cbuf, int off, int len)

实例

1
2
3
4
5
6
7
8
9
10
//使用默认编码        
FileReader reader = new FileReader("test.txt");
int len;
while ((len = reader.read()) != -1) {



System.out.print((char) len);
}
reader.close();

对比InputStream和Reader所提供的方法,就不难发现两个基类的功能基本一样的,只不过读取的数据单元不同。
在执行完流操作后,要调用close()方法来关系输入流,因为程序里打开的IO资源不属于内存资源,垃圾回收机制无法回收该资源,所以应该显式关闭文件IO资源。

  1. OutputStream
    OutputStream 是所有的输出字节流的父类,它是一个抽象类,主要包含如下四个方法:
1
2
3
4
5
6
7
8
//向输出流中写入一个字节数据,该字节数据为参数b的低8位。 
void write(int b) ;
//将一个字节类型的数组中的数据写入输出流。
void write(byte[] b);
//将一个字节类型的数组中的从指定位置(off)开始的,len个字节写入到输出流。
void write(byte[] b, int off, int len);
//将输出流中缓冲的数据全部写出到目的地。
void flush();

实例

1
2
3
4
5
OutputStream outputStream = new FileOutputStream(new File("test.txt"));
// 写入数据
outputStream.write("ABCD".getBytes());
// 关闭IO流
outputStream.close();
  1. Writer
    Writer 是所有的输出字符流的父类,它是一个抽象类,主要包含如下六个方法:
1
2
3
4
5
6
7
8
9
10
11
12
//向输出流中写入一个字符数据,该字节数据为参数b的低16位。 
void write(int c);
//将一个字符类型的数组中的数据写入输出流,
void write(char[] cbuf)
//将一个字符类型的数组中的从指定位置(offset)开始的,length个字符写入到输出流。
void write(char[] cbuf, int offset, int length);
//将一个字符串中的字符写入到输出流。
void write(String string);
//将一个字符串从offset开始的length个字符写入到输出流。
void write(String string, int offset, int length);
//将输出流中缓冲的数据全部写出到目的地。
void flush()

实例

1
2
3
4
5
FileWriter writer = new FileWriter("test.txt");
// 写入数据
writer.write("ABCD".getBytes());
// 关闭IO流
writer.close();

可以看出,Writer比OutputStream多出两个方法,主要是支持写入字符和字符串类型的数据。
使用Java的IO流执行输出时,不要忘记关闭输出流,关闭输出流除了可以保证流的物理资源被回收之外,还能将输出流缓冲区的数据flush到物理节点里(因为在执行close()方法之前,自动执行输出流的flush()方法)

Java NIO

NIO 简介

新的输入/输出 (NIO) 库是在 JDK 1.4 中引入的,弥补了原来的 I/O 的不足,提供了高速的、面向块的 I/O。
NIO 与普通 I/O 的区别主要有以下两点:

  • NIO 是非阻塞的;
    当一个read操作发生时,它会经历两个阶段:
    1 等待数据准备 (Waiting for the data to be ready)
    2 将数据从内核拷贝到进程中 (Copying the data from the kernel to the process)
    blocking IO的特点就是在IO执行的两个阶段都被block了。
    non-blocking IO的特点是当用户进程发出read操作时,如果kernel中的数据还没有准备好,那么它并不会block用户进程,而是立刻返回一个error。用户进程判断结果是一个error时,它就知道数据还没有准备好,于是它可以再次发送read操作。一旦kernel中的数据准备好了,并且又再次收到了用户进程的system call,那么它马上就将数据拷贝到了用户内存,然后返回。所以,用户进程其实是需要不断的主动询问kernel数据好了没有。
  • NIO 面向块,I/O 面向流。
    I/O 与 NIO 最重要的区别是数据打包和传输的方式,I/O 以流的方式处理数据,而 NIO 以块的方式处理数据。按块处理数据比按流处理数据要快得多。但是面向块的 I/O 缺少一些面向流的 I/O 所具有的优雅性和简单性。

通道与缓冲区

  1. 通道
    通道 Channel 是对原 I/O 包中的流的模拟,可以通过它读取和写入数据。

通道与流的不同之处在于,流只能在一个方向上移动(一个流必须是 InputStream 或者 OutputStream 的子类),而通道是双向的,可以用于读、写或者同时用于读写。

通道包括以下类型:

  • FileChannel:从文件中读写数据;
  • DatagramChannel:通过 UDP 读写网络中数据;
  • SocketChannel:通过 TCP 读写网络中数据;
  • ServerSocketChannel:可以监听新进来的 TCP 连接,对每一个新进来的连接都会创建一个 SocketChannel。
  1. 缓冲区

发送给一个通道的所有数据都必须首先放到缓冲区中,同样地,从通道中读取的任何数据都要先读到缓冲区中。也就是说,不会直接对通道进行读写数据,而是要先经过缓冲区。

缓冲区实质上是一个数组,但它不仅仅是一个数组。缓冲区提供了对数据的结构化访问,而且还可以跟踪系统的读/写进程。

缓冲区包括以下类型:

  • ByteBuffer
  • CharBuffer
  • ShortBuffer
  • IntBuffer
  • LongBuffer
  • FloatBuffer
  • DoubleBuffer

缓冲区状态变量包括:

  • capacity:最大容量;
  • position:当前已经读写的字节数;
  • limit:还可以读写的字节数。

状态变量的改变过程举例:

① 新建一个大小为 8 个字节的缓冲区,此时 position 为 0,而 limit = capacity = 8。capacity 变量不会改变,下面的讨论会忽略它。

pic_0acfd191.png

② 从输入通道中读取 5 个字节数据写入缓冲区中,此时 position 为 5,limit 保持不变。

pic_b2bb651f.png

③ 在将缓冲区的数据写到输出通道之前,需要先调用 flip() 方法,这个方法将 limit 设置为当前 position,并将 position 设置为 0。

pic_f31342fc.png

④ 从缓冲区中取 4 个字节到输出缓冲中,此时 position 设为 4。

pic_7701dba0.png

⑤ 最后需要调用 clear() 方法来清空缓冲区,此时 position 和 limit 都被设置为最初位置。

pic_13fdec05.png

选择器

  1. 简介
    NIO 常常被叫做非阻塞 IO,主要是因为 NIO 在网络通信中的非阻塞特性被广泛使用。

NIO 实现了 IO 多路复用中的 Reactor 模型,一个线程 Thread 使用一个选择器 Selector 通过轮询的方式去监听多个通道 Channel 上的事件,从而让一个线程就可以处理多个事件。

通过配置监听的通道 Channel 为非阻塞,那么当 Channel 上的 IO 事件还未到达时,就不会进入阻塞状态一直等待,而是继续轮询其它 Channel,找到 IO 事件已经到达的 Channel 执行。

因为创建和切换线程的开销很大,因此使用一个线程来处理多个事件而不是一个线程处理一个事件,对于 IO 密集型的应用具有很好地性能。

应该注意的是,只有Socket Channel 才能配置为非阻塞,而 FileChannel 不能,为 FileChannel 配置非阻塞也没有意义。

pic_743e8810.png

  1. 原理
    IO multiplexing —— 整体 blocking 局部 non-blocking
    当用户进程调用了select,那么整个进程会被block,而同时,kernel通过不断轮询,“监视”所有select负责的所有kernel(socket),当任何一个kernel(socket)中的数据准备好了,select就会返回。这个时候用户进程再调用read操作,将数据从kernel拷贝到用户进程。
    使用IO multiplexing性能不一定比使用multi-threading + blocking IO效率高,使用select的优势在于它可以同时处理多个connection。
    在IO multiplexing Model中,实际中,对于每一个socket channel,一般都设置成为non-blocking,但是,整个用户的process其实是一直被block的。只不过process是被select这个函数block,而不是被socket channel IO给block。
  2. 使用
  • 创建选择器
1
Selector selector = Selector.open();
  • 将通道注册到选择器上
1
2
3
ServerSocketChannel ssChannel = ServerSocketChannel.open();
ssChannel.configureBlocking(false);
ssChannel.register(selector, SelectionKey.OP_ACCEPT);

通道必须配置为非阻塞模式,否则使用选择器就没有任何意义了,因为如果通道在某个事件上被阻塞,那么服务器就不能响应其它事件,必须等待这个事件处理完毕才能去处理其它事件,显然这和选择器的作用背道而驰。

在将通道注册到选择器上时,还需要指定要注册的具体事件,主要有以下几类:

  • SelectionKey.OP_CONNECT
  • SelectionKey.OP_ACCEPT
  • SelectionKey.OP_READ
  • SelectionKey.OP_WRITE

它们在 SelectionKey 的定义如下:

1
2
3
4
public static final int OP_READ = 1 << 0;
public static final int OP_WRITE = 1 << 2;
public static final int OP_CONNECT = 1 << 3;
public static final int OP_ACCEPT = 1 << 4;

可以看出每个事件可以被当成一个位域,从而组成事件集整数。例如:

1
int interestSet = SelectionKey.OP_READ | SelectionKey.OP_WRITE;
  • 监听事件
1
int num = selector.select();

使用 select() 来监听到达的事件,它会一直阻塞直到有至少一个事件到达。

  • 获取到达的事件
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
Set<SelectionKey> keys = selector.selectedKeys();
Iterator<SelectionKey> keyIterator = keys.iterator();
while (keyIterator.hasNext()) {



SelectionKey key = keyIterator.next();
if (key.isAcceptable()) {



// ...
} else if (key.isReadable()) {



// ...
}
keyIterator.remove();
}
  • 事件循环

因为一次 select() 调用不能处理完所有的事件,并且服务器端有可能需要一直监听事件,因此服务器端处理事件的代码一般会放在一个死循环内。

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
26
while (true) {



int num = selector.select();
Set<SelectionKey> keys = selector.selectedKeys();
Iterator<SelectionKey> keyIterator = keys.iterator();
while (keyIterator.hasNext()) {



SelectionKey key = keyIterator.next();
if (key.isAcceptable()) {



// ...
} else if (key.isReadable()) {



// ...
}
keyIterator.remove();
}
}
  • 套接字 NIO 实例
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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
public class NIOServer {




public static void main(String[] args) throws IOException {




Selector selector = Selector.open();

ServerSocketChannel ssChannel = ServerSocketChannel.open();
ssChannel.configureBlocking(false);
ssChannel.register(selector, SelectionKey.OP_ACCEPT);

ServerSocket serverSocket = ssChannel.socket();
InetSocketAddress address = new InetSocketAddress("127.0.0.1", 8888);
serverSocket.bind(address);

while (true) {




selector.select();
Set<SelectionKey> keys = selector.selectedKeys();
Iterator<SelectionKey> keyIterator = keys.iterator();

while (keyIterator.hasNext()) {




SelectionKey key = keyIterator.next();

if (key.isAcceptable()) {




ServerSocketChannel ssChannel1 = (ServerSocketChannel) key.channel();

// 服务器会为每个新连接创建一个 SocketChannel
SocketChannel sChannel = ssChannel1.accept();
sChannel.configureBlocking(false);

// 这个新连接主要用于从客户端读取数据
sChannel.register(selector, SelectionKey.OP_READ);

} else if (key.isReadable()) {




SocketChannel sChannel = (SocketChannel) key.channel();
System.out.println(readDataFromSocketChannel(sChannel));
sChannel.close();
}

keyIterator.remove();
}
}
}

private static String readDataFromSocketChannel(SocketChannel sChannel) throws IOException {




ByteBuffer buffer = ByteBuffer.allocate(1024);
StringBuilder data = new StringBuilder();

while (true) {




buffer.clear();
int n = sChannel.read(buffer);
if (n == -1) {



break;
}
buffer.flip();
int limit = buffer.limit();
char[] dst = new char[limit];
for (int i = 0; i < limit; i++) {



dst[i] = (char) buffer.get(i);
}
data.append(dst);
buffer.clear();
}
return data.toString();
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class NIOClient {




public static void main(String[] args) throws IOException {



Socket socket = new Socket("127.0.0.1", 8888);
OutputStream out = socket.getOutputStream();
String s = "hello world";
out.write(s.getBytes());
out.close();
}
}

第十章 多线程

  • 线程状态 & 转换
  • 创建线程 3 种方式
    • 实现 Runnable 接口
    • 实现 Callable 接口
    • 继承 Thread 类
  • 互斥
    • Synchronized 类(JVM)
    • ReentrantLock 类(JUC)
  • 协作
    • join()
    • wait() 、notify()、 notifyAll()(结合synchronized)
    • await() 、signal()、 signalAll()(结合Lock)
    • 生产者/消费者 模式实现
  • JUC
    • AQS
      • Semaphore
      • CountDownLatch
      • CyclicBarrier
    • 其他组件
      • FutureTask
      • ForkJoin
      • Executor && ThreadPoolExecutor
  • Java 内存模型
    • 内存模型 & 特性
    • Volatile
  • 线程安全
    • 不可变
    • 互斥同步(锁)
    • 非阻塞同步(CAS)
        1. CAS
        1. AtomicInteger
    • 无同步方案
        1. 线程本地存储(Thread Local Storage)
        1. 可重入代码(Reentrant Code)
  • 并发集合
    • BlockingQueue(JUC)
    • ConcurrentHashMap
  • 多线程开发良好的实践
  • 实例 —— 多线程断点续传

线程状态 & 转换

pic_5394a242.png

  1. 新建(New)
    创建后尚未启动。
  2. 可运行(Runnable)
    可能正在运行,也可能正在等待 CPU 时间片。包含了操作系统线程状态中的 Running 和 Ready。
  3. 阻塞(Blocked)
    等待获取一个排它锁,如果其线程释放了锁就会结束此状态。
  4. 无限期等待(Waiting)
    等待其它线程显式地唤醒,否则不会被分配 CPU 时间片。
进入方法 退出方法
没有设置 Timeout 参数的 Object.wait() 方法 Object.notify() / Object.notifyAll()
没有设置 Timeout 参数的 Thread.join() 方法 被调用的线程执行完毕
  1. 限期等待(Timed Waiting)
    无需等待其它线程显式地唤醒,在一定时间之后会被系统自动唤醒。
    调用 Thread.sleep() 方法使线程进入限期等待状态时,常常用“使一个线程睡眠”进行描述。
    调用 Object.wait() 方法使线程进入限期等待或者无限期等待时,常常用“挂起一个线程”进行描述。
    睡眠和挂起是用来描述行为,而阻塞和等待用来描述状态。
    阻塞和等待的区别在于,阻塞是被动的,它是在等待获取一个排它锁。而等待是主动的,通过调用 Thread.sleep() 和 Object.wait() 等方法进入。
进入方法 退出方法
Thread.sleep() 方法 时间结束
设置了 Timeout 参数的 Object.wait() 方法 时间结束 / Object.notify() / Object.notifyAll()
设置了 Timeout 参数的 Thread.join() 方法 时间结束 / 被调用的线程执行完毕
  1. 死亡(Terminated)
    可以是线程结束任务之后自己结束,或者产生了异常而结束。

控制线程方法:

  • Thread.start():创建了新的线程,在新的线程中执行
  • Thread.run():在主线程中执行该方法,和调用普通方法一样

创建线程 3 种方式

  • 实现 Runnable 接口;
  • 实现 Callable 接口;
  • 继承 Thread 类。

实现 Runnable 和 Callable 接口的类只能当做一个可以在线程中运行的任务,不是真正意义上的线程,因此最后还需要通过 Thread 来调用。可以说任务是通过线程驱动从而执行的。

实现 Runnable 接口

需要实现 run() 方法。
通过 Thread 调用 start() 方法来启动线程。

1
2
3
4
5
6
7
8
9
10
11
public class MyRunnable implements Runnable {



public void run() {



// ...
}
}
1
2
3
4
5
6
7
8
public static void main(String[] args) {



MyRunnable instance = new MyRunnable();
Thread thread = new Thread(instance);
thread.start();
}

实现 Callable 接口

与 Runnable 相比,Callable 可以有返回值,返回值通过 FutureTask 进行封装。

1
2
3
4
5
6
7
8
9
10
11
public class MyCallable implements Callable<Integer> {



public Integer call() {



return 123;
}
}
1
2
3
4
5
6
7
8
9
10
public static void main(String[] args) throws ExecutionException, InterruptedException {



MyCallable mc = new MyCallable();
FutureTask<Integer> ft = new FutureTask<>(mc);
Thread thread = new Thread(ft);
thread.start();
System.out.println(ft.get()); // FutureTask.get()方法可以得到子线程执行结束后的返回值
}

继承 Thread 类

同样也是需要实现 run() 方法,因为 Thread 类也实现了 Runable 接口。
当调用 start() 方法启动一个线程时,虚拟机会将该线程放入就绪队列中等待被调度,当一个线程被调度时会执行该线程的 run() 方法。

1
2
3
4
5
6
7
8
9
10
11
public class MyThread extends Thread {



public void run() {



// ...
}
}
1
2
3
4
5
6
7
public static void main(String[] args) {



MyThread mt = new MyThread();
mt.start();
}

实现接口 VS 继承 Thread
实现接口会更好一些,因为:

  • Java 不支持多重继承,因此继承了 Thread 类就无法继承其它类,但是可以实现多个接口;
  • 类可能只要求可执行就行,继承整个 Thread 类开销过大。

互斥

Synchronized 类(JVM)

synchronized 规定了同一个时刻只允许一条线程可以进入临界区(互斥性),同时还保证了共享变量的内存可见性。此规则决定了持有同一个对象锁的多个同步块只能串行执行。
(一)原理
synchronized实现同步的基础:Java中每个对象都可以作为锁。当线程试图访问同步代码时,必须先获得对象锁,退出或抛出异常时必须释放锁。
Synchronzied实现同步的表现形式分为:代码块同步 和 方法同步。

synchronized 方法实现的本质是通过对对象的监视器(monitor)的获取:
任意一个对象都拥有自己的监视器,当同步代码块或方法时,执行方法的线程必须先获得该对象的监视器才能进入同步块或同步方法;没有获取监视器的将会被阻塞,并进入同步队列,状态变为BLOCKED。当获取监视器的线程释放锁后,才回唤醒阻塞在同步队列中的线程,使其尝试对监视器的获取。
对象、监视器、同步队列和执行线程间的关系如下图:
pic_ab9ecded.png
(二)使用

  1. 对象锁 —— 用于对象实例方法,或者一个对象实例上的
  • 同步代码块
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class SynchronizedExample {




public void func1() {



synchronized (this) {


...}
}
}
  • 同步方法
1
2
3
4
public synchronized void func1() {


...}
  1. 类锁 —— 用于类的静态方法或者一个类的class对象上的
  • 同步代码块
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class SynchronizedExample {




public void func2() {



synchronized (SynchronizedExample.class) {


...}
}
}
  • 同步静态方法
1
2
3
4
public synchronized static void fun() {


...}

ReentrantLock 类(JUC)

ReentrantLock,一个可重入的互斥锁。
(一)Lock 接口
Lock,锁对象。用于控制多个线程访问共享资源(互斥 & 协作(如:读写锁))。
优点在于拥有锁的获取与释放的可操作性,并且可以中断、超时获取锁等。具有更为强大的同步功能;
缺点在于使用时需要显示获取和释放锁,缺少synchronized那样隐式获取和释放锁的便捷性。
常用方法:

方法 解释
void lock() 执行此方法时,如果锁处于空闲状态,当前线程将获取到锁。相反,如果锁已经被其他线程持有,将阻塞当前线程,直到当前线程获取到锁。
boolean tryLock() 如果锁可用,则获取锁,并立即返回true,否则返回false. 该方法和lock()的区别在于,tryLock()只是"试图"获取锁,如果锁不可用,不会导致当前线程被阻塞,当前线程仍然继续往下执行代码
void unlock() 执行此方法时,当前线程将释放持有的锁. 锁只能由持有者释放,如果线程并不持有锁,却执行该方法,可能导致异常的发生
Condition newCondition() 条件对象,获取等待通知组件。该组件和当前的锁绑定,当前线程只有获取了锁,才能调用该组件的await()方法,而调用后,当前线程将释放锁

(二)可重入锁
当一个线程得到一个对象后,再次请求该对象锁时是可以再次得到该对象的锁的。即由于本身已经具有该锁,所以自己可以再次获取该锁。
Java里面内置锁(synchronized)和Lock(ReentrantLock)都是可重入的。

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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
// 即调用method1()方法时,已经获得了锁,此时内部调用method2()方法时,由于本身已经具有该锁,所以可以再次获取。
// synchronized 可重入示例
public class SynchronizedTest {



public void method1() {



synchronized (SynchronizedTest.class) {



System.out.println("方法1获得ReentrantTest的锁运行了");
method2();
}
}
public void method2() {



synchronized (SynchronizedTest.class) {



System.out.println("方法1里面调用的方法2重入锁,也正常运行了");
}
}
public static void main(String[] args) {



new SynchronizedTest().method1();
}
}
// ReentrantLock 可重入示例
public class ReentrantLockTest {



private Lock lock = new ReentrantLock();
public void method1() {



lock.lock();
try {



System.out.println("方法1获得ReentrantLock锁运行了");
method2();
} finally {



lock.unlock();
}
}
public void method2() {



lock.lock();
try {



System.out.println("方法1里面调用的方法2重入ReentrantLock锁,也正常运行了");
} finally {



lock.unlock();
}
}
public static void main(String[] args) {



new ReentrantLockTest().method1();
}
}

(三)公平锁
CPU在调度线程的时候是在等待队列里随机挑选一个线程,由于这种随机性所以是无法保证线程先到先得的(synchronized控制的锁就是这种非公平锁)。但这样就会产生饥饿现象,即有些线程(优先级较低的线程)可能永远也无法获取CPU的执行权,优先级高的线程会不断的强制它的资源。那么如何解决饥饿问题呢,这就需要公平锁了。
公平锁可以保证线程按照时间的先后顺序执行,避免饥饿现象的产生。但公平锁的效率比较低,因为要实现顺序执行,需要维护一个有序队列。
ReentrantLock便是一种公平锁,通过在构造方法中传入true就是公平锁,传入false,就是非公平锁。

1
2
3
4
5
6
public ReentrantLock(boolean fair) {



sync = fair ? new FairSync() : new NonfairSync();
}

(四)ReentrantLock 的 使用
关于ReentrantLock的使用很简单,只需要显示调用,获得同步锁,释放同步锁即可。

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 LockExample {




private Lock lock = new ReentrantLock();

public void func() {



lock.lock(); // 获取锁
try {



// 操作
} finally {



lock.unlock(); // 释放锁
}
}
}

(五)Synchronized & ReentrantLock 比较

Synchronized ReentrantLock
锁的实现 JVM JDK
性能 新版本 Java 对 synchronized 进行了很多优化,例如自旋锁等
等待可中断 不可中断,使用synchronized时,等待的线程会一直等待下去,不能够响应中断 可中断,当持有锁的线程长期不释放锁的时候,正在等待的线程可以选择放弃等待,改为处理其他事情
公平锁 非公平 默认非公平,可公平,公平锁是指多个线程在等待同一个锁时,必须按照申请锁的时间顺序来依次获得锁
异常 synchronized在发生异常时,会自动释放线程占有的锁,因此不会导致死锁现象发生 Lock在发生异常时,如果没有主动通过unLock()去释放锁,则很可能造成死锁现象,因此使用Lock时需要在finally块中释放锁

除非需要使用 ReentrantLock 的高级功能,否则优先使用 synchronized。这是因为 synchronized 是 JVM 实现的一种锁机制,JVM 原生地支持它,而 ReentrantLock 不是所有的 JDK 版本都支持。并且使用 synchronized 不用担心没有释放锁而导致死锁问题,因为 JVM 会确保锁的释放。

协作

当多个线程可以一起工作去解决某个问题时,如果某些部分必须在其它部分之前完成,那么就需要对线程进行协调。

join()

在线程中调用另一个线程的 join() 方法,会将当前线程挂起,而不是忙等待,直到目标线程结束。
对于以下代码,虽然 b 线程先启动,但是因为在 b 线程中调用了 a 线程的 join() 方法,b 线程会等待 a 线程结束才继续执行,因此最后能够保证 a 线程的输出先于 b 线程的输出。

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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
public class JoinExample {




private class A extends Thread {



@Override
public void run() {



System.out.println("A");
}
}

private class B extends Thread {




private A a;

B(A a) {



this.a = a;
}

@Override
public void run() {



try {



a.join(); // b 线程等待a先执行完毕再接着执行
} catch (InterruptedException e) {



e.printStackTrace();
}
System.out.println("B");
}
}

public void test() {



A a = new A();
B b = new B(a);
b.start();
a.start();
}
}
1
2
3
4
5
6
7
public static void main(String[] args) {



JoinExample example = new JoinExample();
example.test();
}
1
2
A
B

wait() 、notify()、 notifyAll()(结合synchronized)

调用 wait() 使得线程等待某个条件满足,线程在等待时会被挂起,当其他线程的运行使得这个条件满足时,其它线程会调用 notify() 或者 notifyAll() 来唤醒挂起的线程。

  • 它们都属于 Object 的一部分,而不属于 Thread。
  • 只能用在同步方法或者同步控制块中使用,否则会在运行时抛出 IllegalMonitorStateException(确保调用wait()方法的线程必须拥有锁)
  • 使用 wait() 挂起期时:
    线程会释放锁,并进入 wait set中,状态为WAITING。
  • 使用notify()唤醒线程时:
    ①如果多个线程在等待,通知对象的wait set中一个线程释放。这种选择是随意的,和具体实现有关。(若使用notifyAll()则通知对象的wait set中所有线程释放)
    ②被唤醒的线程是不能被执行的,需要等到当前线程放弃这个对象的锁,当前线程会在方法执行完毕后释放锁。若线程获取锁则为RUNNABLE状态,否则为BLOCKED状态。
方法 说明
wait()方法 让当前线程进入等待,并释放锁
wait(long) 让当前线程进入等待,并释放锁,不过等待时间为long,超过这个时间没有对当前线程进行唤醒,将自动唤醒
notify() 让当前线程通知那些处于等待状态的线程,当前线程执行完毕后释放锁,并从其他线程中唤醒其中一个继续执行
notifyAll() 让当前线程通知那些处于等待状态的线程,当前线程执行完毕后释放锁,将唤醒所有等待状态的线程
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
26
27
28
29
30
31
public class WaitNotifyExample {




public synchronized void before() {



System.out.println("before");
notifyAll();
}

public synchronized void after() {



try {



wait();
} catch (InterruptedException e) {



e.printStackTrace();
}
System.out.println("after");
}
}
1
2
3
4
5
6
7
8
9
public static void main(String[] args) {



ExecutorService executorService = Executors.newCachedThreadPool();
WaitNotifyExample example = new WaitNotifyExample();
executorService.execute(() -> example.after());
executorService.execute(() -> example.before());
}
1
2
before
after

wait() 和 sleep() 的区别

  • wait() 是 Object 的方法,而 sleep() 是 Thread 的静态方法;
  • wait() 会释放锁,sleep() 不会。

await() 、signal()、 signalAll()(结合Lock)

java.util.concurrent 类库中提供了 Condition 类来实现线程之间的协调,可以在 Condition 上调用 await() 方法使线程等待某个条件满足,其它线程运行满足这个条件后,调用 signal() 或 signalAll() 方法唤醒等待的线程。

  • 相比于 wait() 这种等待方式,await() 可以指定等待的条件,因此更加灵活。
  • 使用 Lock 来获取一个 Condition 对象。(需结合Lock使用)
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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
public class AwaitSignalExample {




private Lock lock = new ReentrantLock();
private Condition condition = lock.newCondition();

public void before() {



lock.lock();
try {



System.out.println("before");
condition.signalAll();
} finally {



lock.unlock();
}
}

public void after() {



lock.lock();
try {



condition.await();
System.out.println("after");
} catch (InterruptedException e) {



e.printStackTrace();
} finally {



lock.unlock();
}
}
}
1
2
3
4
5
6
7
8
9
public static void main(String[] args) {



ExecutorService executorService = Executors.newCachedThreadPool();
AwaitSignalExample example = new AwaitSignalExample();
executorService.execute(() -> example.after());
executorService.execute(() -> example.before());
}
1
2
before
after

生产者/消费者 模式实现

生产者

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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
public class Producer implements Runnable{



private List<Integer> container;
public Consumer(List<> container){



this.container = container;
}
// 生产者生产产品
private void produce() throws InterruptedException{



synchronized(container){



if(container.size >= MAX_CAPACITY){



// 容器已满,停止生产
container.wait();
}
// 模拟1秒生产一个产品
Integer p = container.add(new Random().nextInt(100));
TimeUnit.MILLISECONDS.sleep(1000);
container.notifyAll();
}
}
@override
public void run(){



while(true){



try{



produce();
}catch(InterruptedException e){


e.printStackTrace();}
}
}
}

消费者

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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
public class Consumer implements Runnable{



private List<Integer> container;
public Consumer(List<> container){



this.container = container;
}
// 消费者消费产品
private void consume() throws InterruptedException{



synchronized(container){



if(container.isEmpty()){



// 容器为空,停止消费
container.wait();
}
// 模拟1秒消费一个产品
Integer p = container.remove(0);
TimeUnit.MILLISECONDS.sleep(1000);
container.notifyAll();
}
}
@override
public void run(){



while(true){



try{



consume();
}catch(InterruptedException e){


e.printStackTrace();}
}
}
}

实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class ProducerConsumerTest {



public static void main(String[] args) {



List<Integer> container = new ArrayList<>();
Thread producer = new Thread(new Producer(container));
Thread consumer = new Thread(new Consumer(container));
producer.start();
consumer.start();
}
}

为什么使用notifyAll()唤醒?
多个生产者和消费者线程。当全部运行后,生产者线程生产数据后,可能唤醒的同类即生产者线程。此时可能会出现如下情况:所有生产者线程进入等待状态,然后消费者线程消费完数据后,再次唤醒的还是消费者线程,直至所有消费者线程都进入等待状态,此时将进入“假死”。

将notify()或signal()方法改为notifyAll()或signalAll()方法,这样就不怕因为唤醒同类而进入“假死”状态了。

JUC

java.util.concurrent(J.U.C)大大提高了并发性能,AQS 被认为是 J.U.C 的核心

AQS

Semaphore

Semaphore 类似于操作系统中的信号量,可以控制对互斥资源的访问线程数。
以下代码模拟了对某个服务的并发请求,每次只能有 3 个客户端同时访问,请求总数为 10。(资源数为10的互斥操作)

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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
public class SemaphoreExample {




public static void main(String[] args) {



final int clientCount = 3;
final int totalRequestCount = 10;
Semaphore semaphore = new Semaphore(clientCount);
ExecutorService executorService = Executors.newCachedThreadPool();
for (int i = 0; i < totalRequestCount; i++) {



executorService.execute(()->{



try {



semaphore.acquire(); // 信号量--
System.out.print(semaphore.availablePermits() + " ");
} catch (InterruptedException e) {



e.printStackTrace();
} finally {



semaphore.release(); // 信号量++
}
});
}
executorService.shutdown();
}
}
1
2 1 2 2 2 2 2 1 2 2

CountDownLatch

CyclicBarrier

其他组件

FutureTask

在介绍 Callable 时我们知道它可以有返回值,返回值通过 Future 进行封装。FutureTask 实现了 RunnableFuture 接口,该接口继承自 Runnable 和 Future 接口,这使得 FutureTask 既可以当做一个任务执行,也可以有返回值。

1
public class FutureTask<V> implements RunnableFuture<V>
1
public interface RunnableFuture<V> extends Runnable, Future<V>

FutureTask 可用于异步获取执行结果或取消执行任务的场景。当一个计算任务需要执行很长时间,那么就可以用 FutureTask 来封装这个任务,主线程在完成自己的任务之后再去获取结果。

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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
public class FutureTaskExample {




public static void main(String[] args) throws ExecutionException, InterruptedException {



FutureTask<Integer> futureTask = new FutureTask<Integer>(new Callable<Integer>() {



@Override
public Integer call() throws Exception {



int result = 0;
for (int i = 0; i < 100; i++) {



Thread.sleep(10);
result += i;
}
return result;
}
});

Thread computeThread = new Thread(futureTask);
computeThread.start();

Thread otherThread = new Thread(() -> {



System.out.println("other task is running...");
try {



Thread.sleep(1000);
} catch (InterruptedException e) {



e.printStackTrace();
}
});
otherThread.start();
System.out.println(futureTask.get());
}
}
1
2
other task is running...
4950

ForkJoin

Executor && ThreadPoolExecutor

  • Executor 执行器
    Executor 管理多个异步任务的执行,而无需程序员显式地管理线程的生命周期。这里的异步是指多个任务的执行互不干扰,不需要进行同步操作。

主要有三种 Executor:

  • CachedThreadPool:一个任务创建一个线程;
  • FixedThreadPool:所有任务只能使用固定大小的线程;
  • SingleThreadExecutor:相当于大小为 1 的 FixedThreadPool。
1
2
3
4
5
6
7
8
9
10
11
12
13
public static void main(String[] args) {



ExecutorService executorService = Executors.newCachedThreadPool();
for (int i = 0; i < 5; i++) {



executorService.execute(new MyRunnable());
}
executorService.shutdown();
}

Extecutor是一个接口,它是Executor框架的基础,它将任务的提交与任务的执行分离开来。
ThreadPoolExecutor是线程池的核心实现类,用来执行被提交的任务。
pic_1b3d3d30.png

  • ThreadPoolExecutor 线程池
    [第十章 线程 / 进程 通信 —— 线程池 ThreadPoolExecutor][_ _ _ _ _ _ ThreadPoolExecutor]
  • 作用
  • 降低系统资源消耗,通过重用已存在的线程,降低线程创建和销毁造成的消耗;
  • 提高系统响应速度,当有任务到达时,无需等待新线程的创建便能立即执行;
  • 方便线程并发数的管控,线程若是无限制的创建,不仅会额外消耗大量系统资源,更是占用过多资源而阻塞系统或oom等状况,从而降低系统的稳定性。线程池能有效管控线程,统一分配、调优,提供资源使用率;
  • 更强大的功能,线程池提供了定时、定期以及可控线程数等功能的线程池,使用方便简单。
  • 执行流程
    pic_9b512bdf.png
    ① 如果在线程池中的线程数量没有达到核心的线程数量,这时候就回启动一个核心线程来执行任务。
    ② 如果线程池中的线程数量已经超过核心线程数,这时候任务就会被插入到任务队列中排队等待执行。
    ③ 由于任务队列已满,无法将任务插入到任务队列中。这个时候如果线程池中的线程数量没有达到线程池所设定的最大值,那么这时候就会立即启动一个非核心线程来执行任务。
    ④ 如果线程池中的数量达到了所规定的最大值,那么就会拒绝执行此任务,这时候就会调用RejectedExecutionHandler中的rejectedExecution方法来通知调用者。
  • 四种线程池类
    Java中四种具有不同功能常见的线程池。他们都是直接或者间接配置ThreadPoolExecutor来实现他们各自的功能。这四种线程池分别是:
  1. newFixedThreadPool:一种线程数量固定的线程池。
  2. newCachedThreadPool
  3. newScheduledThreadPool
  4. newSingleThreadExecutor
  • 线程池使用技巧
    需要针对具体情况而具体处理,不同的任务类别应采用不同规模的线程池,任务类别可划分为CPU密集型任务、IO密集型任务和混合型任务。(N代表CPU个数)
任务 说明
CPU密集型任务 线程池中线程个数应尽量少,如配置N+1个线程的线程池
IO密集型任务 由于IO操作速度远低于CPU速度,那么在运行这类任务时,CPU绝大多数时间处于空闲状态,那么线程池可以配置尽量多些的线程,以提高CPU利用率,如2*N
混合型任务 可以拆分为CPU密集型任务和IO密集型任务,当这两类任务执行时间相差无几时,通过拆分再执行的吞吐率高于串行执行的吞吐率,但若这两类任务执行时间有数据级的差距,那么没有拆分的意义

Java 内存模型

内存模型 & 特性

Java内存模型规定了所有的变量都存储在主内存中。每条线程中还有自己的工作内存,线程的工作内存中保存了被该线程所使用到的变量(这些变量是从主内存中拷贝而来)。线程对变量的所有操作(读取,赋值)都必须在工作内存中进行。不同线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成。
当数据从主内存复制到工作存储时,必须出现两个动作:
第一,由主内存执行的读(read)操作;
第二,由工作内存执行的相应的load操作;
当数据从工作内存拷贝到主内存时,也出现两个操作:
第一,由工作内存执行的存储(store)操作;
第二,由主内存执行的相应的写(write)操作
每一个操作都是原子的,即执行期间不会被中断。
pic_a57d793f.png
在有些场景下多线程访问程序变量会表现出与程序制定的顺序不一样。因为编译器可以以优化的名义改变每个独立线程的顺序,从而使处理器不按原来的顺序执行线程。一个Java程序在从源代码到最终实际执行的指令序列之间,会经历一系列的重排序过程。
对于多线程共享同一内存区域这一情况,使得每个线程不知道其他线程对数据做了怎样的修改(数据修改位于线程的私有内存中,具有不可见性),从而导致执行结果不正确。因此必须要解决这一同步问题。(并发一致性带来的问题:丢失修改、不可重复读、脏数据)
pic_2f0e2962.png
Java内存模型需要保证三特性:原子性、可见性、有序性。

  1. 原子性
    即一个操作或者多个操作,要么全部执行,并且执行的过程不会被任何因素打断,要么就都不执行。Java实现方式包括:
  • 原子类,如AtomicInteger等
  • synchronized 互斥锁
  1. 可见性
    可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。Java实现方式包括:
  • volatile
  • synchronized,对一个变量执行 unlock 操作之前,必须把变量值同步回主内存。
  • final,被 final 关键字修饰的字段在构造器中一旦初始化完成,并且没有发生 this 逃逸(其它线程通过 this 引用访问到初始化了一半的对象),那么其它线程就能看见 final 字段的值。
  1. 有序性
    有序性:即程序执行的顺序按照代码的先后顺序执行。
  • volatile 关键字:通过添加内存屏障的方式来禁止指令重排,即重排序时不能把后面的指令放到内存屏障之前。
  • synchronized:保证每个时刻只有一个线程执行同步代码,相当于是让线程顺序执行同步代码。

Volatile

  1. 原理
    对于非volatile变量进行读写时,每个线程先从主存拷贝变量到线程缓存中,执行完操作再保存到主存中。需要进行load/save操作。
    而volatile变量保证每次读写变量都是不经过缓存而是直接从内存读写数据。省去了load/save操作。volatile变量不会将对该变量的操作与其他内存操作一起重排序,能及时更新到主存;且因该变量存储在主存上,所以总会返回最新写入的值。
  2. 三大特性
    他能保证:
  • 可见性
    如果对声明了volatile变量进行写操作时,JVM会向处理器发送一条Lock前缀的指令,将这个变量所在缓存行的数据写会到系统内存。 这一步确保了如果有其他线程对声明了volatile变量进行修改,则立即更新主内存中数据。
  • 有序性
    Lock前缀指令实际上相当于一个内存屏障(也成内存栅栏),它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成。
  1. 应用 —— 单例模式
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
26
27
28
29
30
31
32
class Singleton {



private volatile static Singleton instance = null;

private Singleton() {




}

public static Singleton getInstance() {



if (instance == null) {



synchronized (Singleton.class) {



if (instance == null)
instance = new Singleton();
}
}
return instance;
}
}

为什么要使用volatile 修饰instance?
主要在于instance = new Singleton()这句,这并非是一个原子操作,事实上在 JVM 中这句话大概做了下面 3 件事情:
1.给 instance 分配内存
2.调用 Singleton 的构造函数来初始化成员变量
3.将instance对象指向分配的内存空间(执行完这步 instance 就为非 null 了)。
但是在 JVM 的即时编译器中存在指令重排序的优化。也就是说上面的第二步和第三步的顺序是不能保证的,最终的执行顺序可能是 1-2-3 也可能是 1-3-2。如果是后者,则在 3 执行完毕、2 未执行之前,被线程二抢占了,这时 instance 已经是非 null 了(但却没有初始化),所以线程二会直接返回 instance,然后使用,然后顺理成章地报错。
本质上使用Volatile关键字,可以防止产生指令的重排序问题

线程安全

多个线程不管以何种方式访问某个类,并且在主调代码中不需要进行同步,都能表现正确的行为。

线程安全有以下几种实现方式:

不可变

不可变(Immutable)的对象一定是线程安全的,不需要再采取任何的线程安全保障措施。只要一个不可变的对象被正确地构建出来,永远也不会看到它在多个线程之中处于不一致的状态。多线程环境下,应当尽量使对象成为不可变,来满足线程安全。

不可变的类型:

  • final 关键字修饰的基本数据类型
  • String
  • 枚举类型
  • Number 部分子类,如 Long 和 Double 等数值包装类型,BigInteger 和 BigDecimal 等大数据类型。但同为 Number 的原子类 AtomicInteger 和 AtomicLong 则是可变的。

对于集合类型,可以使用 Collections.unmodifiableXXX() 方法来获取一个不可变的集合。

1
2
3
4
5
6
7
8
9
10
11
12
13
public class ImmutableExample {



public static void main(String[] args) {



Map<String, Integer> map = new HashMap<>();
Map<String, Integer> unmodifiableMap = Collections.unmodifiableMap(map);
unmodifiableMap.put("a", 1);
}
}
1
2
3
Exception in thread "main" java.lang.UnsupportedOperationException
at java.util.Collections$UnmodifiableMap.put(Collections.java:1457)
at ImmutableExample.main(ImmutableExample.java:9)

Collections.unmodifiableXXX() 先对原始的集合进行拷贝,需要对集合进行修改的方法都直接抛出异常。

1
2
3
4
5
6
public V put(K key, V value) {



throw new UnsupportedOperationException();
}

互斥同步(锁)

synchronized 和 ReentrantLock。

非阻塞同步(CAS)

加锁是一种悲观的策略,它总是认为每次访问共享资源的时候,总会发生冲突,所以宁愿牺牲性能(时间)来保证数据安全。
无锁是一种乐观的策略,它假设线程访问共享资源不会发生冲突,所以不需要加锁,因此线程将不断执行,不需要停止。一旦碰到冲突,就重试当前操作直到没有冲突为止。

随着硬件指令集的发展,我们可以使用基于冲突检测的乐观并发策略:先进行操作,如果没有其它线程争用共享数据,那操作就成功了,否则采取补偿措施(不断地重试,直到成功为止)。这种乐观的并发策略的许多实现都不需要将线程阻塞,因此这种同步操作称为非阻塞同步。

1. CAS

乐观锁需要操作和冲突检测这两个步骤具备原子性,这里就不能再使用互斥同步来保证了,只能靠硬件来完成。
无锁的策略使用一种叫做比较交换的技术(CAS Compare And Swap)来鉴别线程冲突,一旦检测到冲突产生,就重试当前操作直到没有冲突为止。
硬件支持的原子性操作最典型的是:比较并交换(Compare-and-Swap,CAS)。
核心算法:执行函数:CAS(V,E,N)

  • V表示准备要被更新的变量
  • E表示我们提供的 期望的值
  • N表示新值 ,准备更新V的值

算法思路:V是共享变量,我们拿着自己准备的这个E,去跟V去比较,如果E == V ,说明当前没有其它线程在操作,所以,我们把N 这个值 写入对象的 V 变量中。如果 E != V ,说明我们准备的这个E,已经过时了,所以我们要重新准备一个最新的E ,去跟V 比较,比较成功后才能更新 V的值为N。
pic_2574a564.png

2. AtomicInteger

J.U.C 包里面的整数原子类 AtomicInteger 的方法调用了 Unsafe 类的 CAS 操作。

以下代码使用了 AtomicInteger 执行了自增的操作。

1
2
3
4
5
6
7
8
private AtomicInteger cnt = new AtomicInteger();

public void add() {



cnt.incrementAndGet();
}

以下代码是 incrementAndGet() 的源码,它调用了 Unsafe 的 getAndAddInt() 。

1
2
3
4
5
6
public final int incrementAndGet() {



return unsafe.getAndAddInt(this, valueOffset, 1) + 1;
}

以下代码是 getAndAddInt() 源码,var1 指示对象内存地址,var2 指示该字段相对对象内存地址的偏移,var4 指示操作需要加的数值,这里为 1。通过 getIntVolatile(var1, var2) 得到旧的预期值,通过调用 compareAndSwapInt() 来进行 CAS 比较,如果该字段内存地址中的值等于 var5,那么就更新内存地址为 var1+var2 的变量为 var5+var4。

可以看到 getAndAddInt() 在一个循环中进行,发生冲突的做法是不断的进行重试。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public final int getAndAddInt(Object var1, long var2, int var4) {



int var5;
do {



var5 = this.getIntVolatile(var1, var2);
} while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));

return var5;
}

无同步方案

要保证线程安全,并不是一定就要进行同步。如果一个方法本来就不涉及共享数据,那它自然就无须任何同步措施去保证正确性。

1. 线程本地存储(Thread Local Storage)

如果一段代码中所需要的数据必须与其他代码共享,那就看看这些共享数据的代码是否能保证在同一个线程中执行。如果能保证,我们就可以把共享数据的可见范围限制在同一个线程之内,这样,无须同步也能保证线程之间不出现数据争用的问题。

符合这种特点的应用并不少见,大部分使用消费队列的架构模式(如“生产者-消费者”模式)都会将产品的消费过程尽量在一个线程中消费完。其中最重要的一个应用实例就是经典 Web 交互模型中的“一个请求对应一个服务器线程”(Thread-per-Request)的处理方式,这种处理方式的广泛应用使得很多 Web 服务端应用都可以使用线程本地存储来解决线程安全问题。

可以使用 java.lang.ThreadLocal 类来实现线程本地存储功能。

对于以下代码,thread1 中设置 threadLocal 为 1,而 thread2 设置 threadLocal 为 2。过了一段时间之后,thread1 读取 threadLocal 依然是 1,不受 thread2 的影响。

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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
public class ThreadLocalExample {



public static void main(String[] args) {



ThreadLocal threadLocal = new ThreadLocal();
Thread thread1 = new Thread(() -> {



threadLocal.set(1);
try {



Thread.sleep(1000);
} catch (InterruptedException e) {



e.printStackTrace();
}
System.out.println(threadLocal.get());
threadLocal.remove();
});
Thread thread2 = new Thread(() -> {



threadLocal.set(2);
threadLocal.remove();
});
thread1.start();
thread2.start();
}
}
1
1

为了理解 ThreadLocal,先看以下代码:

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
26
27
28
public class ThreadLocalExample1 {



public static void main(String[] args) {



ThreadLocal threadLocal1 = new ThreadLocal();
ThreadLocal threadLocal2 = new ThreadLocal();
Thread thread1 = new Thread(() -> {



threadLocal1.set(1);
threadLocal2.set(1);
});
Thread thread2 = new Thread(() -> {



threadLocal1.set(2);
threadLocal2.set(2);
});
thread1.start();
thread2.start();
}
}

它所对应的底层结构图为:
pic_cb39e8b6.png
每个 Thread 都有一个 ThreadLocal.ThreadLocalMap 对象。

1
2
3
/* ThreadLocal values pertaining to this thread. This map is maintained
* by the ThreadLocal class. */
ThreadLocal.ThreadLocalMap threadLocals = null;

当调用一个 ThreadLocal 的 set(T value) 方法时,先得到当前线程的 ThreadLocalMap 对象,然后将 ThreadLocal->value 键值对插入到该 Map 中。

1
2
3
4
5
6
7
8
9
10
11
public void set(T value) {



Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null)
map.set(this, value);
else
createMap(t, value);
}

get() 方法类似。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public T get() {



Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null) {



ThreadLocalMap.Entry e = map.getEntry(this);
if (e != null) {



@SuppressWarnings("unchecked")
T result = (T)e.value;
return result;
}
}
return setInitialValue();
}

ThreadLocal 从理论上讲并不是用来解决多线程并发问题的,因为根本不存在多线程竞争。

在一些场景 (尤其是使用线程池) 下,由于 ThreadLocal.ThreadLocalMap 的底层数据结构导致 ThreadLocal 有内存泄漏的情况,应该尽可能在每次使用 ThreadLocal 后手动调用 remove(),以避免出现 ThreadLocal 经典的内存泄漏甚至是造成自身业务混乱的风险。

2. 可重入代码(Reentrant Code)

这种代码也叫做纯代码(Pure Code),可以在代码执行的任何时刻中断它,转而去执行另外一段代码(包括递归调用它本身),而在控制权返回后,原来的程序不会出现任何错误。

可重入代码有一些共同的特征,例如不依赖存储在堆上的数据和公用的系统资源、用到的状态量都由参数中传入、不调用非可重入的方法等。

并发集合

BlockingQueue(JUC)

  1. BlockingQueue
    java.util.concurrent.BlockingQueue 接口有以下阻塞队列的实现:
  • FIFO 队列 :LinkedBlockingQueue、ArrayBlockingQueue(固定长度)
  • 优先级队列 :PriorityBlockingQueue

提供了阻塞的 take() 和 put() 方法:

  • take:如果队列为空 take() 将线程阻塞,直到队列中有内容;
  • put:如果队列为满 put() 将阻塞,直到队列有空闲位置。

BlockingQueue虽然比起Queue在操作上提供了更多的支持,但是它在使用有如下的几点:

  • BlockingQueue中是不允许添加null的,该接受在声明的时候就要求所有的实现类在接收到一个null的时候,都应该抛出NullPointerException。
  • BlockingQueue主要应用于生产者与消费者的模型中,其元素的添加和获取都是极具规律性的。
    使用 BlockingQueue 实现生产者消费者问题
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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
public class ProducerConsumer {




private static BlockingQueue<String> queue = new ArrayBlockingQueue<>(5);

private static class Producer extends Thread {



@Override
public void run() {



try {



queue.put("product");
} catch (InterruptedException e) {



e.printStackTrace();
}
System.out.print("produce..");
}
}

private static class Consumer extends Thread {




@Override
public void run() {



try {



String product = queue.take();
} catch (InterruptedException e) {



e.printStackTrace();
}
System.out.print("consume..");
}
}
}
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
26
public static void main(String[] args) {



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



Producer producer = new Producer();
producer.start();
}
for (int i = 0; i < 5; i++) {



Consumer consumer = new Consumer();
consumer.start();
}
for (int i = 0; i < 3; i++) {



Producer producer = new Producer();
producer.start();
}
}
1
produce..produce..consume..consume..produce..consume..produce..consume..produce..consume..
  1. ArrayBlockingQueue & LinkedBlockingQueue
    ArrayBlockingQueue由于其底层基于数组,并且在创建时指定存储的大小,在完成后就会立即在内存分配固定大小容量的数组元素,因此其存储通常有限,故其是一个“有界“的阻塞队列;
    而LinkedBlockingQueue可以由用户指定最大存储容量,也可以无需指定,如果不指定则最大存储容量将是Integer.MAX_VALUE,即可以看作是一个“无界”的阻塞队列,由于其节点的创建都是动态创建,并且在节点出队列后可以被GC所回收,因此其具有灵活的伸缩性。但是由于ArrayBlockingQueue的有界性,因此其能够更好的对于性能进行预测,而LinkedBlockingQueue由于没有限制大小,当任务非常多的时候,不停地向队列中存储,就有可能导致内存溢出的情况发生。
    其次,ArrayBlockingQueue中在入队列和出队列操作过程中,使用的是同一个lock,所以即使在多核CPU的情况下,其读取和操作的都无法做到并行,而LinkedBlockingQueue的读取和插入操作所使用的锁是两个不同的lock,它们之间的操作互相不受干扰,因此两种操作可以并行完成,故LinkedBlockingQueue的吞吐量要高于ArrayBlockingQueue。
  2. 选择LinkedBlockingQueue 的理由
1
2
3
4
5
6
7
8
9
10
11
12
/**
下面的代码是Executors创建固定大小线程池的代码,其使用了
LinkedBlockingQueue来作为任务队列。
*/
public static ExecutorService newFixedThreadPool(int nThreads) {



return new ThreadPoolExecutor(nThreads, nThreads,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<Runnable>());
}

JDK中选用LinkedBlockingQueue作为阻塞队列的原因就在于其无界性。因为线程大小固定的线程池,其线程的数量是不具备伸缩性的,当任务非常繁忙的时候,就势必会导致所有的线程都处于工作状态,如果使用一个有界的阻塞队列来进行处理,那么就非常有可能很快导致队列满的情况发生,从而导致任务无法提交而抛出RejectedExecutionException,而使用无界队列由于其良好的存储容量的伸缩性,可以很好的去缓冲任务繁忙情况下场景,即使任务非常多,也可以进行动态扩容,当任务被处理完成之后,队列中的节点也会被随之被GC回收,非常灵活。

ConcurrentHashMap

[Java并发集合 —— ConcurrentHashMap][Java_ _ ConcurrentHashMap]
利用CAS+Synchronized来保证并发更新的安全,底层依然采用数组+链表+红黑树的存储结构。
pic_60251108.png
put操作
假设table已经初始化完成,put操作采用CAS+synchronized实现并发插入或更新操作,具体实现如下。

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
26
27
28
final V putVal(K key, V value, boolean onlyIfAbsent) {



if (key == null || value == null) throw new NullPointerException();
int hash = spread(key.hashCode());
int binCount = 0;
for (Node<K,V>[] tab = table;;) {



Node<K,V> f; int n, i, fh;
if (tab == null || (n = tab.length) == 0)
tab = initTable();
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {



if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value, null)))
break; // no lock when adding to empty bin
}
else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f);
...省略部分代码
}
addCount(1L, binCount);
return null;
}

(1)使用hash算法,并在table中定位索引位置(n 为table大小)
(2)获取table中对应索引的元素f。

  1. 如果f为null,说明table中这个位置第一次插入元素,利用Unsafe.compareAndSwapObject方法插入Node节点。
  • 如果CAS成功,说明Node节点已经插入,随后addCount(1L, binCount)方法会检查当前容量是否需要进行扩容。
    如果CAS失败,说明有其它线程提前插入了节点,自旋重新尝试在这个位置插入节点。
  1. 如果f的hash值为-1,说明当前f是ForwardingNode节点,意味有其它线程正在扩容,则一起进行扩容操作。
  2. 其余情况把新的Node节点按链表或红黑树的方式插入到合适的位置,这个过程采用同步内置锁实现并发。

多线程开发良好的实践

  • 给线程起个有意义的名字,这样可以方便找 Bug。
  • 缩小同步范围,从而减少锁争用。例如对于 synchronized,应该尽量使用同步块而不是同步方法。
  • 多用同步工具少用 wait() 和 notify()。首先,CountDownLatch, CyclicBarrier, Semaphore 和 Exchanger 这些同步类简化了编码操作,而用 wait() 和 notify() 很难实现复杂控制流;其次,这些同步类是由最好的企业编写和维护,在后续的 JDK 中还会不断优化和完善。
  • 使用 BlockingQueue 实现生产者消费者问题。
  • 多用并发集合少用同步集合,例如应该使用 ConcurrentHashMap 而不是 Hashtable。
  • 使用本地变量和不可变类来保证线程安全。
  • 使用线程池而不是直接创建线程,这是因为创建线程代价很高,线程池可以有效地利用有限的线程来启动任务。

实例 —— 多线程断点续传

(一)多线程下载

  1. 原理
    多线程断点续传是把整个文件分割成几个部分,每个部分由一条线程执行下载,而每一条下载线程都要实现断点续传功能。多线程下载模型:
    pic_d34a1a08.png
    在多线程断点续传下载中,有一点需要特别注意: 由于文件是分成多个部分是被不同的线程的同时下载的,这就需要,每一条线程都分别需要有一个断点记录,和一个线程完成状态的记录;
    pic_0a3c155d.png
    只有所有线程的下载状态都处于完成状态时,才能表示文件已经下载完成。
    多线程下载核心逻辑:
1
2
3
获取文件总大小,进行分割,并计算文件的开始位置和结束位置
fileLength = httpURLconnection.getContentLength();
每一条线程下载大小 = fileLength / THREAD_NUM;

(二)多线程断点续传
所谓断点续传就是从停止的地方重新下载。

  • 断点:线程停止的位置。即,当前线程已经下载完成的数据长度。
  • 续传:从停止的位置重新下载。即,向服务器请求上次线程停止位置之后的数据。

实现:每当线程停止时就把已下载的数据长度写入记录文件,当重新下载时,从记录文件读取已经下载了的长度。而这个长度就是所需要的断点。
断点续传核心逻辑:

1
2
3
4
5
6
7
每次下载前从文件中获取需要的断点
lastPositionStr = new BufferedReader().readLine(new InputStreamReader(fileInputStream));
通过设置网络参数,请求服务器从指定的位置开始读取数据。
conn.setRequestProperty("Range", "bytes=" + startIndex + "-" + endIndex);
获取到下载数据时,还需要将数据写入文件,而普通发File对象并不提供从指定位置写入数据的功能,这个时候,就需要使用到RandomAccessFile来实现从指定位置给文件写入数据的功能。
raFile.seek(100);
raf.write(buffer,0,length);

(三)多线程断点续传实现
MultiDownLoad.java

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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
public class MultiDownLoad {




// 1. 定义下载路径
static String path = "http://127.0.0.1:8080/img/test.jpg";
private static int threadCount = 3;//假设开3个线程

public static void main(String[] args) {




// 2. 获取服务器文件的大小,计算每个线程下载的开始位置和结束位置
try {



URL url = new URL(path);
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setRequestMethod("GET"); // 发送GET请求
conn.setConnectTimeout(5000); // 设置网络超时时间
int code = conn.getResponseCode();
if(code == 200) {



int length = conn.getContentLength(); // 获取服务器文件的大小
// 3. 创建一个大小和服务器一样的文件,目的是申请出空间
// RandomAccessFile 支持随机访问文件的读取和写入
// 随机访问文件的行为类似存储在文件系统中的一个大型byte数组。存在指向该隐含数组的光标或索引,称为文件索引。
// seek(long pos) 设置到此文件开头测量到的文件指针偏移量,在该位置发生下一个读取或写入操作
RandomAccessFile rafAccessFile = new RandomAccessFile("test_download.jpg","rw"); // 创建文件,用读写方式打开
rafAccessFile.setLength(length);

// 3.计算每个线程下载的开始位置和结束位置
int blockSize = length/threadCount;
for(int i=0;i<threadCount;i++) {



int startIndex = i * blockSize; // 每个线程下载开始位置
int endIndex = (i+1)*blockSize;
if(i == threadCount-1) endIndex = length -1;// 每个线程下载的结束位置(最后一个线程特殊处理)
// 4.开启线程去服务器下载文件
DownLoadThread downLoadThread = new DownLoadThread(i+1,startIndex,endIndex);
downLoadThread.start();
}
}

}catch(Exception e) {


e.printStackTrace();}

}
}

DownloadThread.java

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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
public class DownLoadThread extends Thread{




private int threadId; // 线程I
private int startIndex; // 开始位置
private int endIndex; // 结束位置

public DownLoadThread( int threadId,int startIndex,int endIndex) {



this.threadId = threadId;
this.startIndex = startIndex;
this.endIndex = endIndex;
}

// 定义线程去服务器下载文件
@Override
public void run() {



try {



URL url = new URL(MultiDownLoad.path);
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setRequestMethod("GET"); // 发送GET请求
conn.setConnectTimeout(5000); // 设置网络超时时间
int code = conn.getResponseCode();
// 如果中间被中断,则从上一次下载的位置重新下载
// 从文件中读取上次下载的位置
File file = new File(threadId+".txt");
if(file.exists() && file.length()>0) {



FileInputStream fis = new FileInputStream(file);
BufferedReader bufr = new BufferedReader(new InputStreamReader(fis));
String lastPositionStr = bufr.readLine();
int lastPosition = Integer.parseInt(lastPositionStr);
System.out.println("当前线程"+threadId+"下载的位置:"+lastPosition);
// 更改startIndex位置(加载位置从上一次下载的位置开始)
startIndex = lastPosition;
fis.close();
}
// *多线程下载的核心
// 设置一个请求头Range,告诉服务器每个线程下载的开始位置和结束位置
conn.setRequestProperty("Range", "bytes="+startIndex+"-"+endIndex);
if(code == 206) {



// 返回值200 请求获取服务器全部资源成功
// 返回值206 请求部分资源成功
// 创建随机读写文件对象
RandomAccessFile raf = new RandomAccessFile("test_download.jpg","rw"); // 创建文件,用读写方式打开
// 每个线程从自己的开始位置开始写
raf.seek(startIndex);
// 获取的是文件 [startIndex..endIndex]
InputStream in = conn.getInputStream();
// 将数据写入文件中
int length = -1;
byte[] buffer = new byte[1024 * 1024];
int total = 0; // 当前线程下载的大小
while((length = in.read(buffer))!=-1) {



raf.write(buffer,0,length);
// * 实现断点续传的核心
// 把当前线程下载的位置存起来,下次下载时按照上次下载的位置继续下载
// 将当前下载位置存入txt文本
total += length;
int currentThreadPosition = startIndex + total;
RandomAccessFile raf_position = new RandomAccessFile(threadId+".txt","rwd");// 可直接同步数据到底层硬盘
raf_position.write(String.valueOf(currentThreadPosition).getBytes());
raf_position.close();
}
raf.close(); // 关闭流 释放资源
System.out.println("线程" + threadId + ":下载完成");
}
} catch (Exception e) {



e.printStackTrace();
}

}
}

(三)优雅的实现
pic_54e2a2b4.png
IDownloadListener.java

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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
package com.arialyy.frame.http.inf;
import java.net.HttpURLConnection;

/**
* 在这里面编写你的业务逻辑
*/
public interface IDownloadListener {



/**
* 取消下载
*/
public void onCancel();

/**
* 下载失败
*/
public void onFail();

/**
* 下载预处理,可通过HttpURLConnection获取文件长度
*/
public void onPreDownload(HttpURLConnection connection);

/**
* 下载监听
*/
public void onProgress(long currentLocation);

/**
* 单一线程的结束位置
*/
public void onChildComplete(long finishLocation);

/**
* 开始
*/
public void onStart(long startLocation);

/**
* 子程恢复下载的位置
*/
public void onChildResume(long resumeLocation);

/**
* 恢复位置
*/
public void onResume(long resumeLocation);

/**
* 停止
*/
public void onStop(long stopLocation);

/**
* 下载完成
*/
public void onComplete();
}

该类是下载监听接口
DownloadListener.java

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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
import java.net.HttpURLConnection;

/**
* 下载监听
*/
public class DownloadListener implements IDownloadListener {




@Override
public void onResume(long resumeLocation) {




}

@Override
public void onCancel() {




}

@Override
public void onFail() {




}

@Override
public void onPreDownload(HttpURLConnection connection) {




}

@Override
public void onProgress(long currentLocation) {




}

@Override
public void onChildComplete(long finishLocation) {




}

@Override
public void onStart(long startLocation) {




}

@Override
public void onChildResume(long resumeLocation) {




}

@Override
public void onStop(long stopLocation) {




}

@Override
public void onComplete() {




}
}
下载参数实体
/**
* 子线程下载信息类
*/
private class DownloadEntity {



//文件总长度
long fileSize;
//下载链接
String downloadUrl;
//线程Id
int threadId;
//起始下载位置
long startLocation;
//结束下载的文章
long endLocation;
//下载文件
File tempFile;
Context context;

public DownloadEntity(Context context, long fileSize, String downloadUrl, File file, int threadId, long startLocation, long endLocation) {



this.fileSize = fileSize;
this.downloadUrl = downloadUrl;
this.tempFile = file;
this.threadId = threadId;
this.startLocation = startLocation;
this.endLocation = endLocation;
this.context = context;
}
}

该类是下载信息配置类,每一条子线程的下载都需要一个下载实体来配置下载信息。
下载任务线程

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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
/**
* 多线程下载任务类
*/
private class DownLoadTask implements Runnable {



private static final String TAG = "DownLoadTask";
private DownloadEntity dEntity;
private String configFPath;

public DownLoadTask(DownloadEntity downloadInfo) {



this.dEntity = downloadInfo;
configFPath = dEntity.context.getFilesDir().getPath() + "/temp/" + dEntity.tempFile.getName() + ".properties";
}

@Override
public void run() {



try {



L.d(TAG, "线程_" + dEntity.threadId + "_正在下载【" + "开始位置 : " + dEntity.startLocation + ",结束位置:" + dEntity.endLocation + "】");
URL url = new URL(dEntity.downloadUrl);
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
//在头里面请求下载开始位置和结束位置
conn.setRequestProperty("Range", "bytes=" + dEntity.startLocation + "-" + dEntity.endLocation);
conn.setRequestMethod("GET");
conn.setRequestProperty("Charset", "UTF-8");
conn.setConnectTimeout(TIME_OUT);
conn.setRequestProperty("User-Agent", "Mozilla/4.0 (compatible; MSIE 8.0; Windows NT 5.2; Trident/4.0; .NET CLR 1.1.4322; .NET CLR 2.0.50727; .NET CLR 3.0.04506.30; .NET CLR 3.0.4506.2152; .NET CLR 3.5.30729)");
conn.setRequestProperty("Accept", "image/gif, image/jpeg, image/pjpeg, image/pjpeg, application/x-shockwave-flash, application/xaml+xml, application/vnd.ms-xpsdocument, application/x-ms-xbap, application/x-ms-application, application/vnd.ms-excel, application/vnd.ms-powerpoint, application/msword, */*");
conn.setReadTimeout(2000); //设置读取流的等待时间,必须设置该参数
InputStream is = conn.getInputStream();
//创建可设置位置的文件
RandomAccessFile file = new RandomAccessFile(dEntity.tempFile, "rwd");
//设置每条线程写入文件的位置
file.seek(dEntity.startLocation);
byte[] buffer = new byte[1024];
int len;
//当前子线程的下载位置
long currentLocation = dEntity.startLocation;
while ((len = is.read(buffer)) != -1) {



if (isCancel) {



L.d(TAG, "++++++++++ thread_" + dEntity.threadId + "_cancel ++++++++++");
break;
}

if (isStop) {



break;
}

//把下载数据数据写入文件
file.write(buffer, 0, len);
synchronized (DownLoadUtil.this) {



mCurrentLocation += len;
mListener.onProgress(mCurrentLocation);
}
currentLocation += len;
}
file.close();
is.close();

if (isCancel) {



synchronized (DownLoadUtil.this) {



mCancelNum++;
if (mCancelNum == THREAD_NUM) {



File configFile = new File(configFPath);
if (configFile.exists()) {



configFile.delete();
}

if (dEntity.tempFile.exists()) {



dEntity.tempFile.delete();
}
L.d(TAG, "++++++++++++++++ onCancel +++++++++++++++++");
isDownloading = false;
mListener.onCancel();
System.gc();
}
}
return;
}

//停止状态不需要删除记录文件
if (isStop) {



synchronized (DownLoadUtil.this) {



mStopNum++;
String location = String.valueOf(currentLocation);
L.i(TAG, "thread_" + dEntity.threadId + "_stop, stop location ==> " + currentLocation);
writeConfig(dEntity.tempFile.getName() + "_record_" + dEntity.threadId, location);
if (mStopNum == THREAD_NUM) {



L.d(TAG, "++++++++++++++++ onStop +++++++++++++++++");
isDownloading = false;
mListener.onStop(mCurrentLocation);
System.gc();
}
}
return;
}

L.i(TAG, "线程【" + dEntity.threadId + "】下载完毕");
writeConfig(dEntity.tempFile.getName() + "_state_" + dEntity.threadId, 1 + "");
mListener.onChildComplete(dEntity.endLocation);
mCompleteThreadNum++;
if (mCompleteThreadNum == THREAD_NUM) {



File configFile = new File(configFPath);
if (configFile.exists()) {



configFile.delete();
}
mListener.onComplete();
isDownloading = false;
System.gc();
}
} catch (MalformedURLException e) {



e.printStackTrace();
isDownloading = false;
mListener.onFail();
} catch (IOException e) {



FL.e(this, "下载失败【" + dEntity.downloadUrl + "】" + FL.getPrintException(e));
isDownloading = false;
mListener.onFail();
} catch (Exception e) {



FL.e(this, "获取流失败" + FL.getPrintException(e));
isDownloading = false;
mListener.onFail();
}
}

这个是每条下载子线程的下载任务类,子线程通过下载实体对每一条线程进行下载配置,由于在多断点续传的概念里,停止表示的是暂停状态,而恢复表示的是线程从记录的断点重新进行下载,所以,线程处于停止状态时是不能删除记录文件的。
下载入口

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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
/**
* 多线程断点续传下载文件,暂停和继续
*
* @param context 必须添加该参数,不能使用全局变量的context
* @param downloadUrl 下载路径
* @param filePath 保存路径
* @param downloadListener 下载进度监听 {@link DownloadListener}
*/
public void download(final Context context, @NonNull final String downloadUrl, @NonNull final String filePath,
@NonNull final DownloadListener downloadListener) {



isDownloading = true;
mCurrentLocation = 0;
isStop = false;
isCancel = false;
mCancelNum = 0;
mStopNum = 0;
final File dFile = new File(filePath);
//读取已完成的线程数
final File configFile = new File(context.getFilesDir().getPath() + "/temp/" + dFile.getName() + ".properties");
try {



if (!configFile.exists()) {


//记录文件被删除,则重新下载
newTask = true;
FileUtil.createFile(configFile.getPath());
} else {



newTask = false;
}
} catch (Exception e) {



e.printStackTrace();
mListener.onFail();
return;
}
newTask = !dFile.exists();
new Thread(new Runnable() {



@Override
public void run() {



try {



mListener = downloadListener;
URL url = new URL(downloadUrl);
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setRequestMethod("GET");
conn.setRequestProperty("Charset", "UTF-8");
conn.setConnectTimeout(TIME_OUT);
conn.setRequestProperty("User-Agent", "Mozilla/4.0 (compatible; MSIE 8.0; Windows NT 5.2; Trident/4.0; .NET CLR 1.1.4322; .NET CLR 2.0.50727; .NET CLR 3.0.04506.30; .NET CLR 3.0.4506.2152; .NET CLR 3.5.30729)");
conn.setRequestProperty("Accept", "image/gif, image/jpeg, image/pjpeg, image/pjpeg, application/x-shockwave-flash, application/xaml+xml, application/vnd.ms-xpsdocument, application/x-ms-xbap, application/x-ms-application, application/vnd.ms-excel, application/vnd.ms-powerpoint, application/msword, */*");
conn.connect();
int len = conn.getContentLength();
if (len < 0) {


//网络被劫持时会出现这个问题
mListener.onFail();
return;
}
int code = conn.getResponseCode();
if (code == 200) {



int fileLength = conn.getContentLength();
//必须建一个文件
FileUtil.createFile(filePath);
RandomAccessFile file = new RandomAccessFile(filePath, "rwd");
//设置文件长度
file.setLength(fileLength);
mListener.onPreDownload(conn);
//分配每条线程的下载区间
Properties pro = null;
pro = Util.loadConfig(configFile);
int blockSize = fileLength / THREAD_NUM;
SparseArray<Thread> tasks = new SparseArray<>();
for (int i = 0; i < THREAD_NUM; i++) {



long startL = i * blockSize, endL = (i + 1) * blockSize;
Object state = pro.getProperty(dFile.getName() + "_state_" + i);
if (state != null && Integer.parseInt(state + "") == 1) {


//该线程已经完成
mCurrentLocation += endL - startL;
L.d(TAG, "++++++++++ 线程_" + i + "_已经下载完成 ++++++++++");
mCompleteThreadNum++;
if (mCompleteThreadNum == THREAD_NUM) {



if (configFile.exists()) {



configFile.delete();
}
mListener.onComplete();
isDownloading = false;
System.gc();
return;
}
continue;
}
//分配下载位置
Object record = pro.getProperty(dFile.getName() + "_record_" + i);
if (!newTask && record != null && Long.parseLong(record + "") > 0) {


//如果有记录,则恢复下载
Long r = Long.parseLong(record + "");
mCurrentLocation += r - startL;
L.d(TAG, "++++++++++ 线程_" + i + "_恢复下载 ++++++++++");
mListener.onChildResume(r);
startL = r;
}
if (i == (THREAD_NUM - 1)) {



endL = fileLength;//如果整个文件的大小不为线程个数的整数倍,则最后一个线程的结束位置即为文件的总长度
}
DownloadEntity entity = new DownloadEntity(context, fileLength, downloadUrl, dFile, i, startL, endL);
DownLoadTask task = new DownLoadTask(entity);
tasks.put(i, new Thread(task));
}
if (mCurrentLocation > 0) {



mListener.onResume(mCurrentLocation);
} else {



mListener.onStart(mCurrentLocation);
}
for (int i = 0, count = tasks.size(); i < count; i++) {



Thread task = tasks.get(i);
if (task != null) {



task.start();
}
}
} else {



FL.e(TAG, "下载失败,返回码:" + code);
isDownloading = false;
System.gc();
mListener.onFail();
}
} catch (IOException e) {



FL.e(this, "下载失败【downloadUrl:" + downloadUrl + "】\n【filePath:" + filePath + "】" + FL.getPrintException(e));
isDownloading = false;
mListener.onFail();
}
}
}).start();
}

需要注意两点

  • 恢复下载时:已下载的文件大小 = 该线程的上一次断点的位置 - 该线程起始下载位置;
  • 为了保证下载文件的完整性,只要记录文件不存在就需要重新进行下载;