架构师内功心法,只是单纯听说过的原型模式详解

一、原型模式的应用场景

你一定遇到过这样的代码场景,有大量的getter、setter赋值的场景。例如这样的代码片段:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
private SafetyStockMessage createKafkaMessage(SafetyStock safetyStock, HttpServletRequest request) {
SafetyStockMessage safetyStockMessage = new SafetyStockMessage();
safetyStockMessage.setId(safetyStock.getId());
safetyStockMessage.setProvinceCode(safetyStock.getProvinceCode());
safetyStockMessage.setRequestId(CodeConstants.REQUEST_ID);
safetyStockMessage.setRequestIp(CodeConstants.REQUEST_IP);
safetyStockMessage.setSerial(IdMakerUtil.make32Id());
safetyStockMessage.setStockMax(safetyStock.getStockMax());
safetyStockMessage.setStockMin(safetyStock.getStockMin());
safetyStockMessage.setProvince(safetyStock.getProvince());
safetyStockMessage.setCategoryName(safetyStock.getCategoryName());
safetyStockMessage.setUpdateTime(new Date());
safetyStockMessage.setUpdateBy(getLoginUser(request));
return safetyStockMessage;
}

代码看起来非常工整,命名也很规范,大家觉得这样的代码优雅吗?这样的代码属于纯体力劳动。如果使用原型模式,可以帮助我们解决这样的问题。

原型模式(Prototype Pattern)是指原型实例指定创建对象的种类,并且通过拷贝这样原型创建新的对象。

原型模式主要适用于以下场景:

1、类初始化消耗的资源较多;

2、new产生的一个对象需要非常繁琐的过程(数据准备、访问权限等);

3、构造函数比较复杂;

4、循环体中生产大量对象。

原型模型的类结构图:

二、简单克隆

一个标准的原型模式代码,应该是这样的设计的。先创建原型Prototype接口:

1
2
3
public interface Prototype {
Prototype clone();
}

创建具体需要克隆的对象ConcretePrototype:

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 ConcretePrototype implements Prototype {

private String name;
private int age;
private List<String> hobbies;

public String getName() {
return name;
}

public void setName(String name) {
this.name = name;
}

public int getAge() {
return age;
}

public void setAge(int age) {
this.age = age;
}

public List<String> getHobbies() {
return hobbies;
}

public void setHobbies(List<String> hobbies) {
this.hobbies = hobbies;
}

@Override
public ConcretePrototype clone() {
ConcretePrototype concretePrototype = new ConcretePrototype();
concretePrototype.setName(this.name);
concretePrototype.setAge(this.age);
concretePrototype.setHobbies(this.hobbies);
return concretePrototype;
}
}

创建Client对象:

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

private Prototype prototype;

public Client(Prototype prototype) {
this.prototype = prototype;
}

public Prototype startClone(Prototype concretePrototype) {
return (Prototype)concretePrototype.clone();
}

}

测试代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public static void main(String[] args) {
//创建一个具体的需要克隆的对象
ConcretePrototype concretePrototype = new ConcretePrototype();
//填充属性,准备测试
concretePrototype.setName("Kevin");
concretePrototype.setAge(18);
List<String> hobbies = new ArrayList<>();
concretePrototype.setHobbies(hobbies);
System.out.println(concretePrototype);

//创建Client对象,准备进行克隆
Client client = new Client(concretePrototype);
ConcretePrototype concretePrototypeClone = (ConcretePrototype)
client.startClone(concretePrototype);
System.out.println(concretePrototypeClone);

System.out.println("克隆对象中的引用类型地址值是:" +
concretePrototypeClone.getHobbies());
System.out.println("原对象中的引用类型地址值是:" +
concretePrototype.getHobbies());
System.out.println("对象地址比较:" + (concretePrototypeClone.getHobbies() ==
concretePrototype.getHobbies()));
}

运行结果:

从测试结果看出hobbies的引用地址是相同的,意味着不是复制值,而复制的是引用的地址。如果我们修改任何一个对象的属性值,concretePrototype和concretePrototypeClone的hobbies的值都会改变。这就是我们常说的浅克隆。只是完整复制了值类型数据,没有复制引用对象。换言之,所有的引用对象还是指向原来的对象,显然不是我们想要的结果。

下面我们继续改造代码,使用深度克隆。

三、深度克隆

我们来换一个场景,大家都知道齐天大圣孙悟空。首先它是一只猴子,有着七十二般变化,把一根毫毛放在嘴里一吹就变出千万个泼猴,手里还拿着金箍棒,金箍棒可变大变小。这就是我们耳熟能详的原型模式的经典体现。

创建原型猴子 Monkey 类:

1
2
3
4
5
6
7
public class Monkey {

public int height;
public int weight;
public Date birthday;

}

创建引用对象金箍棒GoldenCudgel类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class GoldenCudgel implements Serializable {

public float h = 100f;

public float d = 10f;

public void changeBig() {
this.d *= 2;
this.h *= 2;
}

public void changeSmall() {
this.d /= 2;
this.h /= 2;
}

}

创建具体的对象齐天大圣孙悟空MonkeyKing类:

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
public class MonkeyKing extends Monkey implements Cloneable, Serializable {

public GoldenCudgel goldenCudgel;

public MonkeyKing() {
this.birthday = new Date();
this.goldenCudgel = new GoldenCudgel();
}

@Override
protected Object clone() {
return this.deepClone();
}

/**
* 深克隆
* @return
*/
protected Object deepClone() {

try {
ByteArrayOutputStream bos = new ByteArrayOutputStream();
ObjectOutputStream oos = new ObjectOutputStream(bos);
oos.writeObject(this);

ByteArrayInputStream bis = new ByteArrayInputStream(bos.toByteArray());
ObjectInputStream ois = new ObjectInputStream(bis);

MonkeyKing copy = (MonkeyKing) ois.readObject();
copy.birthday = new Date();
return copy;

} catch (IOException e) {
e.printStackTrace();
return null;
} catch (ClassNotFoundException e) {
e.printStackTrace();
return null;
}
}

/**
* 浅克隆
*/
public MonkeyKing shallowClone(MonkeyKing target) {
MonkeyKing monkeyKing = new MonkeyKing();
monkeyKing.height = target.height;
monkeyKing.weight = target.weight;

monkeyKing.goldenCudgel = target.goldenCudgel;
monkeyKing.birthday = new Date();

return monkeyKing;
}
}

测试代码:

1
2
3
4
5
6
7
8
9
10
 public static void main(String[] args) throws Exception {
MonkeyKing monkeyKing = new MonkeyKing();

MonkeyKing clone = (MonkeyKing)monkeyKing.clone();
System.out.println("深克隆:" + (monkeyKing.goldenCudgel == clone.goldenCudgel));

MonkeyKing shallow = new MonkeyKing();
MonkeyKing newMonkeyKing = shallow.shallowClone(shallow);
System.out.println("浅克隆" + (shallow.goldenCudgel = newMonkeyKing.goldenCudgel));
}

运行结果:

  • 克隆破坏单例模式

如果我们克隆的目标是单例模式创建的对象,那么意味着深克隆会破坏单例模式。如何防止克隆破坏单例,禁止深克隆便可。我们在单例的类中不实现Cloneable接口,在重写clone()方法中返回单例对象即可,代码如下:

1
2
3
4
@Override
protected Object clone() throws CloneNotSupportedException {
return INSTANCE;
}
  • Cloneable 源码分析

先看我们常用的 ArrayList 就实现了 Cloneable 接口,来看代码clone()方法的实现:

1
2
3
4
5
6
7
8
9
10
11
public Object clone() {
try {
ArrayList<?> v = (ArrayList<?>) super.clone();
v.elementData = Arrays.copyOf(elementData, size);
v.modCount = 0;
return v;
} catch (CloneNotSupportedException e) {
// this shouldn't happen, since we are Cloneable
throw new InternalError(e);
}
}

架构师内功心法,经典高频面试的单例模式详解

一、单例模式应用场景

单例模式(Single Pattern)是指确保一个类在任何情况下绝对只是一个实例,并提供一个全局的访问点。 单例模式在现实生活中的应用也很广泛。例如国家总统、公司CEO、部门经理等。在java标准中,ServletContext、ServletContextConfig等;在Spring框架中ApplicationCotext;数据库对应的连接池也都是单例形势的。

二、单例模式分类

2.1 饿汉式单例

饿汉式单例是在类加载的时候就立即初始化了,并且创建了单例对象。绝对的线程安全,在线程还没出现以前就实例化了,不可能存在访问安全问题。

优点:没有加任何的锁,执行效率高,在用户体验上,比懒汉式更好。
缺点:类加载的时候就初始化了,不管用与不用都占空间,浪费了内存,有可能占着茅坑不拉屎。

Spring中的IOC容器ApplicationContext本身就是典型的饿汉式单例。案例代码:

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

/**
* 先静态后动态
* 先属性后方法
* 先上后下
*/
private static final HungrySingleton hungrySingleton = new HungrySingleton();

private HungrySingleton() {
}

public static HungrySingleton getInstance() {
return hungrySingleton;
}
}

还有一种写法,是利用静态代码块机制:

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

private static final HungrySingleton hungrySingleton;

static {
hungrySingleton = new HungrySingleton();
}

private HungrySingleton() {}

private HungrySingleton getInstance() {
return hungrySingleton;
}
}

这两种写法都很简单,也很容易理解。饿汉式使用在单例对象较少的情况下。 下面来看下性能更优的写法。

2.2 懒汉式单例

懒汉式单例是指被外部调用的时候才会进行加载。示例代码:

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

/**
* 懒汉式单例
* 在外部需要使用的时候才进行实例化
*/
private LazySingleton() {}

private static LazySingleton lazySingleton = null;

public static LazySingleton getInstance() {
if(lazySingleton == null) {
lazySingleton = new LazySingleton();
}
return lazySingleton;
}
}

编写一个线程类ExectorThread:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class ExectorThread implements Runnable {
@Override
public void run() {
LazySingleton lazySingleton = LazySingleton.getInstance();
System.out.println(Thread.currentThread().getName() + ":" + lazySingleton);
}

public static void main(String[] args) {
System.out.println("--------begin-------");
Thread t1 = new Thread(new ExectorThread());
Thread t2 = new Thread(new ExectorThread());
t1.start();
t2.start();
System.out.println("--------end---------");
}
}

查看main方法多次运行的结果发现:


有一定几率会出现创建两个不同结果的情况,意味着上门的单例创建代码存在线程安全隐患。我们通过对代码进行debug调试,发现通过不断切换线程,并观测其内存状态,发现在线程环境下LazySingleton被实例化了两次。有时候我们得到的运行结果可能是相同的两个对象,实际上是被后面的执行线程给覆盖了,看到了一个假象,线程安全隐患依然存在。这样我们需要在线程安全的环境下运行懒汉单例代码。给getIntance()方法加上Synchronized关键字,使这个方法变成线程同步方法:

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

/**
* 懒汉式单例
* 在外部需要使用的时候才进行实例化
*/
private LazySingleton() {}

private static LazySingleton lazySingleton = null;

public synchronized static LazySingleton getInstance() {
if(lazySingleton == null) {
lazySingleton = new LazySingleton();
}
return lazySingleton;
}
}

添加synchronized关键字使用锁,在线程数量比较多的情况下,如果CPU分配压力上升,会导致大批线程出现阻塞,从而导致程序运行性能大幅度下降。那么,有木有一种更好的方式,既兼顾线程的安全性又提升程序性能呢?答案是肯定的。我们会使用双重检查锁的单例模式:

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

private volatile static LazyDoubleCheckSingletion lazy = null;

private LazyDoubleCheckSingletion() {}

public static LazyDoubleCheckSingletion getInstance() {
if(lazy == null) {
synchronized(LazyDoubleCheckSingletion.class) {
if(lazy == null) {
lazy = new LazyDoubleCheckSingletion();
}
}
}
return lazy;
}
}

当第一个线程调用 getInstance()方法时,第二个线程也可以调用getInstance()。当第一个线程执行到 synchronized 时会上锁,第二个线程就会变成 MONITOR状态,出现阻塞。此时,阻塞并不是基于整个 LazySingleton 类的阻塞,而是在 getInstance()方法内部阻塞,只要逻辑不是太复杂,对于调用者而言感知不到。

但是,用到 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
public class LazyInnerClassSingleton {

/**
* 这种形式兼顾饿汉式的内存浪费,也兼顾 synchronized 性能问题
* 完美地屏蔽了这两个缺点
*/
//如果没使用的话,内部类是不加载的
private LazyInnerClassSingleton() {}

/**
* 每一个关键字都不是多余的
* static 是为了使单例的空间共享
* 保证这个方法不会被重写,重载
* @return
*/
public static final LazyInnerClassSingleton getInstance() {
//在返回结果以前,一定会先加载内部类
return LazyHolder.LAZY;
}

//默认不加载
private static class LazyHolder{
private static final LazyInnerClassSingleton LAZY =
new LazyInnerClassSingleton();
}
}

这种形式兼顾饿汉式的内存浪费,也兼顾synchronized性能问题。内部类一定是要在方法调用之前初始化,巧妙地避免了线程安全问题。

  • 反射破坏单例

上面一些介绍单例模式的构造方法除了加上private以外,没有做任何处理。如果使用反射来调用其构造方法,然后再调用getInstance()方法,应该就会有两个不同的实例。还是以LazyInnerClassSingleton为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public static void main(String[] args) {
Class<?> clazz = LazyInnerClassSingleton.class;
try {
//通过反射机制拿到私有的构造方法
Constructor c = clazz.getDeclaredConstructor(null);
//强制访问
c.setAccessible(true);
////暴力初始化
Object o1 = c.newInstance();
//调用了两次构造方法,相当于 new 了两次
Object o2 = c.newInstance();

System.out.println(o1 == o2);

} catch (NoSuchMethodException e) {
e.printStackTrace();
} catch (IllegalAccessException e) {
e.printStackTrace();
} catch (InstantiationException e) {
e.printStackTrace();
} catch (InvocationTargetException 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
/**
* 这种形式兼顾饿汉式的内存浪费,也兼顾 synchronized 性能问题
* 完美地屏蔽了这两个缺点
*/
//如果没使用的话,内部类是不加载的
private LazyInnerClassSingleton() {
if(LazyHolder.LAZY != null) {
throw new RuntimeException("Multiple instances are not allowed to be created!");
}
}

/**
* 每一个关键字都不是多余的
* static 是为了使单例的空间共享
* 保证这个方法不会被重写,重载
* @return
*/
public static final LazyInnerClassSingleton getInstance() {
//在返回结果以前,一定会先加载内部类
return LazyHolder.LAZY;
}

//默认不加载
private static class LazyHolder{
private static final LazyInnerClassSingleton LAZY =
new LazyInnerClassSingleton();
}

再次运行结果:

至此,史上最牛 B 的单例写法便大功告成。

  • 序列化破坏单例

当我们将一个单例对象创建好后,有时候需要将对象序列化后写入到磁盘,下次使用的时候再从磁盘中读取到对象,反序列化为内存对象。反序列化后的对象会重新分配内存,即重新创建。那么如果序列化的目标的对象为单例对象,就违背了单例模式的初衷,相当于破坏了单例,来看一下代码:

1
2
3
4
5
6
7
8
9
10
11
public class SerializeSingleton implements Serializable {

public static final SerializeSingleton INSTANCE = new SerializeSingleton();

private SerializeSingleton() {}

public static SerializeSingleton getInstance() {
return INSTANCE;
}

}

编写测试代码:

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
public static void main(String[] args) {

SerializeSingleton s1 = null;
SerializeSingleton s2 = SerializeSingleton.getInstance();

FileOutputStream fos = null;
try {
fos = new FileOutputStream("SerializeSingleton.obj");
ObjectOutputStream oos = new ObjectOutputStream(fos);
oos.writeObject(s2);
oos.flush();
oos.close();

FileInputStream fis = new FileInputStream("SerializeSingleton.obj");
ObjectInputStream ois = new ObjectInputStream(fis);
s1 = (SerializeSingleton)ois.readObject();
ois.close();

System.out.println(s1);
System.out.println(s2);
System.out.println(s1 == s2);

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

运行main方法结果:

运行结果可以看出反序列化后的对象和手动创建的对象不一致,实例化了两次,违背了单例的设计初衷。那么,如何保证序列化的情况下也能够实现单例呢? 其实很简单,只需要增加readResolve() 方法即可。来看一下优化后的代码:

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

public static final SerializeSingleton INSTANCE = new SerializeSingleton();

private SerializeSingleton() {}

public static SerializeSingleton getInstance() {
return INSTANCE;
}

private Object readResolve() {
return INSTANCE;
}

}

再次运行结果:

为什么要这么写呢?我们来一起看下JDK的源码实现吧,进入ObjectInputStream类的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
public final Object readObject()
throws IOException, ClassNotFoundException
{
if (enableOverride) {
return readObjectOverride();
}

// if nested read, passHandle contains handle of enclosing object
int outerHandle = passHandle;
try {
Object obj = readObject0(false);
handles.markDependency(outerHandle, passHandle);
ClassNotFoundException ex = handles.lookupException(passHandle);
if (ex != null) {
throw ex;
}
if (depth == 0) {
vlist.doCallbacks();
}
return obj;
} finally {
passHandle = outerHandle;
if (closed && depth == 0) {
clear();
}
}
}

我们发现在readObject()方法中又调用了我们重写的readObject0()方法。进入readObject0()方法,代码如下:

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
private Object readObject0(boolean unshared) throws IOException {
boolean oldMode = bin.getBlockDataMode();
if (oldMode) {
int remain = bin.currentBlockRemaining();
if (remain > 0) {
throw new OptionalDataException(remain);
} else if (defaultDataEnd) {
/*
* Fix for 4360508: stream is currently at the end of a field
* value block written via default serialization; since there
* is no terminating TC_ENDBLOCKDATA tag, simulate
* end-of-custom-data behavior explicitly.
*/
throw new OptionalDataException(true);
}
bin.setBlockDataMode(false);
}

byte tc;
while ((tc = bin.peekByte()) == TC_RESET) {
bin.readByte();
handleReset();
}

depth++;
totalObjectRefs++;
try {
switch (tc) {
case TC_NULL:
return readNull();

case TC_REFERENCE:
return readHandle(unshared);

case TC_CLASS:
return readClass(unshared);

case TC_CLASSDESC:
case TC_PROXYCLASSDESC:
return readClassDesc(unshared);

case TC_STRING:
case TC_LONGSTRING:
return checkResolve(readString(unshared));

case TC_ARRAY:
return checkResolve(readArray(unshared));

case TC_ENUM:
return checkResolve(readEnum(unshared));

case TC_OBJECT:
return checkResolve(readOrdinaryObject(unshared));

case TC_EXCEPTION:
IOException ex = readFatalException();
throw new WriteAbortedException("writing aborted", ex);

case TC_BLOCKDATA:
case TC_BLOCKDATALONG:
if (oldMode) {
bin.setBlockDataMode(true);
bin.peek(); // force header read
throw new OptionalDataException(
bin.currentBlockRemaining());
} else {
throw new StreamCorruptedException(
"unexpected block data");
}

case TC_ENDBLOCKDATA:
if (oldMode) {
throw new OptionalDataException(true);
} else {
throw new StreamCorruptedException(
"unexpected end of block data");
}

default:
throw new StreamCorruptedException(
String.format("invalid type code: %02X", tc));
}
} finally {
depth--;
bin.setBlockDataMode(oldMode);
}
}

我们在源码中看到了TC_OBJECT中判断,调用了readOrdinaryObject()方法,继续看源码:

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
private Object readOrdinaryObject(boolean unshared)
throws IOException
{
if (bin.readByte() != TC_OBJECT) {
throw new InternalError();
}

ObjectStreamClass desc = readClassDesc(false);
desc.checkDeserialize();

Class<?> cl = desc.forClass();
if (cl == String.class || cl == Class.class
|| cl == ObjectStreamClass.class) {
throw new InvalidClassException("invalid class descriptor");
}

Object obj;
try {
obj = desc.isInstantiable() ? desc.newInstance() : null;
} catch (Exception ex) {
throw (IOException) new InvalidClassException(
desc.forClass().getName(),
"unable to create instance").initCause(ex);
}

passHandle = handles.assign(unshared ? unsharedMarker : obj);
ClassNotFoundException resolveEx = desc.getResolveException();
if (resolveEx != null) {
handles.markException(passHandle, resolveEx);
}

if (desc.isExternalizable()) {
readExternalData((Externalizable) obj, desc);
} else {
readSerialData(obj, desc);
}

handles.finish(passHandle);

if (obj != null &&
handles.lookupException(passHandle) == null &&
desc.hasReadResolveMethod())
{
Object rep = desc.invokeReadResolve(obj);
if (unshared && rep.getClass().isArray()) {
rep = cloneArray(rep);
}
if (rep != obj) {
// Filter the replacement object
if (rep != null) {
if (rep.getClass().isArray()) {
filterCheck(rep.getClass(), Array.getLength(rep));
} else {
filterCheck(rep.getClass(), -1);
}
}
handles.setObject(passHandle, obj = rep);
}
}

return obj;
}

发现调用了 ObjectStreamClass 的 isInstantiable()方法,而isInstantiable()里面的代码如下:

1
2
3
4
boolean isInstantiable() {
requireInitialized();
return (cons != null);
}

代码看起来很简单,就是判断了以下构造方法是否为空,构造方法不为空就返回true。这样意味着,只要有无参构造方法就会实例化。

这个时候,其实还是没找到为什么加上readResolve()方法就避免了单例被破坏的真正原因。再回到ObjectInputStream的readOrdinaryObject()方法继续往下看:

判断无参构造方法是否存在之后,又调用了hasReadResolveMethod()方法,来看代码:

1
2
3
4
boolean hasReadResolveMethod() {
requireInitialized();
return (readResolveMethod != null);
}

逻辑非常简单,就是判断 readResolveMethod 是否为空,不为空就返回 true。那么
readResolveMethod 是在哪里赋值的呢?通过全局查找找到了赋值代码在私有方法
ObjectStreamClass()方法中给 readResolveMethod 进行赋值,来看代码:

1
2
readResolveMethod = getInheritableMethod(
cl, "readResolve", null, Object.class);

上面的逻辑其实就是通过反射找到一个无参的 readResolve()方法,并且保存下来。现在再回到 ObjectInputStream 的 readOrdinaryObject() 方法继续往下看,如果readResolve()存在则调用 invokeReadResolve()方法,来看代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
Object invokeReadResolve(Object obj)
throws IOException, UnsupportedOperationException
{
requireInitialized();
if (readResolveMethod != null) {
try {
return readResolveMethod.invoke(obj, (Object[]) null);
} catch (InvocationTargetException ex) {
Throwable th = ex.getTargetException();
if (th instanceof ObjectStreamException) {
throw (ObjectStreamException) th;
} else {
throwMiscException(th);
throw new InternalError(th); // never reached
}
} catch (IllegalAccessException ex) {
// should not occur, as access checks have been suppressed
throw new InternalError(ex);
}
} else {
throw new UnsupportedOperationException();
}
}

可以看到在invokeReadResolve()方法中用反射调用了readResolveMethod方法。
通过 JDK 源码分析我们可以看出,虽然,增加readResolve()方法返回实例,解决了单例被破坏的问题。但是,我们通过分析源码以及调试,我们可以看到实际上实例化了两次,只不过新创建的对象没有被返回而已。那如果,创建对象的动作发生频率增大,就意味着内存分配开销也就随之增大,难道真的就没办法从根本上解决问题吗?下面我们来注册式单例也许能帮助到你。

2.3 注册式单例

注册式单例又称登记式单例,就是将每一个实例都登记到一个地方,使用唯一标识获取实例。注册的方式有两种:一种为容器缓存,一种为枚举登记。先来看下枚举式单例的写法,创建EnumSingleton类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public enum EnumSingleton {
INSTANCE;

private Object data;

public Object getData() {
return data;
}

public void setData(Object data) {
this.data = data;
}

public static EnumSingleton getInstance() {
return INSTANCE;
}
}

编写测试main方法;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
 public static void main(String[] args) {
try {
EnumSingleton instance1 = null;
EnumSingleton instance2 = EnumSingleton.getInstance();
instance2.setData(new Object());
FileOutputStream fos = new FileOutputStream("EnumSingleton.obj");
ObjectOutputStream oos = new ObjectOutputStream(fos);
oos.writeObject(instance2);
oos.flush();
oos.close();
FileInputStream fis = new FileInputStream("EnumSingleton.obj");
ObjectInputStream ois = new ObjectInputStream(fis);
instance1 = (EnumSingleton) ois.readObject();
ois.close();
System.out.println(instance1.getData());
System.out.println(instance2.getData());
System.out.println(instance1.getData() == instance2.getData());
}catch (Exception e){
e.printStackTrace();
}
}

运行结果是:

没有做任何的处理,我们发现运行的结果和我们预期的一样。那么枚举式单例如此神奇,它的神秘之处体现在哪呢?下面我们就通过分析源码来揭开它的神秘面纱。
我们使用jad反编译工具(https://varaneckas.com/jad/) 生成的EnumSingleton.jad文件,打开这个文件发现这一段代码:

1
2
3
4
5
6
7
static
{
INSTANCE = new EnumSingleton("INSTANCE", 0);
$VALUES = (new EnumSingleton[] {
INSTANCE
});
}

发现枚举单例在静态模块中就给INSTANCE进行了赋值,是饿汉式单例的实现。 我们回想序列化能否破坏枚举式单例呢?再回到之前的源码ObjectInputStream的readObject0()方法:

1
2
3
4
5
private Object readObject0(boolean unshared) throws IOException {
...
case TC_ENUM:
return checkResolve(readEnum(unshared));
...

我们看到在 readObject0()中调用了 readEnum()方法,来看readEnum()中代码实现:

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
private Enum<?> readEnum(boolean unshared) throws IOException {
if (bin.readByte() != TC_ENUM) {
throw new InternalError();
}

ObjectStreamClass desc = readClassDesc(false);
if (!desc.isEnum()) {
throw new InvalidClassException("non-enum class: " + desc);
}

int enumHandle = handles.assign(unshared ? unsharedMarker : null);
ClassNotFoundException resolveEx = desc.getResolveException();
if (resolveEx != null) {
handles.markException(enumHandle, resolveEx);
}

String name = readString(false);
Enum<?> result = null;
Class<?> cl = desc.forClass();
if (cl != null) {
try {
@SuppressWarnings("unchecked")
Enum<?> en = Enum.valueOf((Class)cl, name);
result = en;
} catch (IllegalArgumentException ex) {
throw (IOException) new InvalidObjectException(
"enum constant " + name + " does not exist in " +
cl).initCause(ex);
}
if (!unshared) {
handles.setObject(enumHandle, result);
}
}

handles.finish(enumHandle);
passHandle = enumHandle;
return result;
}

发现枚举类型其实是通过类名和class对象找到一个唯一的枚举对象。 因此,枚举对象不可能被加载器加载多次。那么反射能破坏枚举式单例吗?来看一下测试代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public static void main(String[] args) {
try {
Class clazz = EnumSingleton.class;
Constructor c = clazz.getDeclaredConstructor();
c.newInstance();
} catch (NoSuchMethodException e) {
e.printStackTrace();
} catch (IllegalAccessException e) {
e.printStackTrace();
} catch (InstantiationException e) {
e.printStackTrace();
} catch (InvocationTargetException e) {
e.printStackTrace();
}
}

运行结果:

报的是 java.lang.NoSuchMethodException 异常,意思是没找到无参的构造方法。我们打开 java.lang.Enum 的源码代码,查看它的构造方法,只有一个 protected的构造方法,代码如下:

1
2
3
4
protected Enum(String name, int ordinal) {
this.name = name;
this.ordinal = ordinal;
}

那我们再做这样一个测试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public static void main(String[] args) {
try {
Class clazz = EnumSingleton.class;
Constructor c = clazz.getDeclaredConstructor(String.class, int.class);
c.setAccessible(true);
EnumSingleton enumSingleton = (EnumSingleton)c.newInstance("Kevin", 123);
} catch (NoSuchMethodException e) {
e.printStackTrace();
} catch (IllegalAccessException e) {
e.printStackTrace();
} catch (InstantiationException e) {
e.printStackTrace();
} catch (InvocationTargetException e) {
e.printStackTrace();
}
}

运行结果是:

这时错误已经非常明显了,告诉我们 Cannot reflectively create enum objects,不能用反射来创建枚举类型。还是看下JDK源码,看下Constructor类的newInstance()方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public T newInstance(Object ... initargs)
throws InstantiationException, IllegalAccessException,
IllegalArgumentException, InvocationTargetException
{
if (!override) {
if (!Reflection.quickCheckMemberAccess(clazz, modifiers)) {
Class<?> caller = Reflection.getCallerClass();
checkAccess(caller, clazz, null, modifiers);
}
}
if ((clazz.getModifiers() & Modifier.ENUM) != 0)
throw new IllegalArgumentException("Cannot reflectively create enum objects");
ConstructorAccessor ca = constructorAccessor; // read volatile
if (ca == null) {
ca = acquireConstructorAccessor();
}
@SuppressWarnings("unchecked")
T inst = (T) ca.newInstance(initargs);
return inst;
}

在newInstance()方法中做了强制性的判断,如果修饰符Modifier.ENUM枚举类型,直接抛出异常。到此为止,我们应该非常清晰明了了。

枚举类型单例也是《Effective Java》书中非常推荐的一种单例的实现写法。在 JDK 枚举的语法特殊性,以及反射也为枚举保驾护航,让枚举式单例成为一种比较优雅的实现。

注册式单例还有另外一种写法,容器缓存的写法,创建ContainerSingleton类:

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 class ContainerSingleton {

private ContainerSingleton() {}

private static Map<String, Object> ioc = new ConcurrentHashMap<String, Object>();

public static Object getBean(String className) {
synchronized (ioc) {
if(!ioc.containsKey(className)) {
Object object = null;
try {
object = Class.forName(className).newInstance();
ioc.put(className, object);
} catch (InstantiationException e) {
e.printStackTrace();
} catch (IllegalAccessException e) {
e.printStackTrace();
} catch (ClassNotFoundException e) {
e.printStackTrace();
}
return object;
}else {
return ioc.get(className);
}
}
}
}

容器式写法适用于创建实例非常多的情况,便于管理。但是,是非线程安全的。到此,注册式单例介绍完毕。

来看看 Spring 中的容器式单例 的实现代码:

1
2
3
4
public abstract class AbstractAutowireCapableBeanFactory extends AbstractBeanFactory implements AutowireCapableBeanFactory {
/** Cache of unfinished FactoryBean instances: FactoryBean name --> BeanWrapper */
private final Map<String, BeanWrapper> factoryBeanInstanceCache = new ConcurrentHashMap<>(16);
}

2.4 ThreadLocal 线程单例

ThreadLocal不能保证其创建的对象是全局唯一的,但是能保证在单个线程中是唯一的,天生的线程安全。 下面来看下示例代码:

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

private static final ThreadLocal<ThreadLocalSingleton> instance = new
ThreadLocal<ThreadLocalSingleton>() {
@Override
protected ThreadLocalSingleton initialValue() {
return new ThreadLocalSingleton();
}
};

private ThreadLocalSingleton() {}

public static ThreadLocalSingleton getInstance() {
return instance.get();
}
}

写一下测试代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public static void main(String[] args) {
System.out.println("begin");
System.out.println(ThreadLocalSingleton.getInstance());
System.out.println(ThreadLocalSingleton.getInstance());
System.out.println(ThreadLocalSingleton.getInstance());
System.out.println(ThreadLocalSingleton.getInstance());
System.out.println(ThreadLocalSingleton.getInstance());

Thread t1 = new Thread(new ExectorThread());
Thread t2 = new Thread(new ExectorThread());
t1.start();
t2.start();
System.out.println("end");
}

运行结果:

在主线程 main 中无论调用多少次,获取到的实例都是同一个,都在两个子线程中分别获取到了不同的实例。那么ThreadLocal是如果实现这样的效果的呢?我们知道上面的单例模式为了达到线程安全的目的,给方法上锁,以时间换空间。 ThreadLocal将所有的对象全部放在ThreadLocalMap中,为每个线程都提供一个对象,实际上是以空间换时间来实现线程间隔离的。

三、单例模式总结

单例模式可以保证内存里只有一个实例,减少了内存开销;可以避免对资源的多重占用。单例模式看起来非常简单,实现起来其实也非常简单。

架构师内功心法,经典框架都在用的工厂模式详解

一、经典框架都在用设计模式解决问题

Spring就是一个把设计模式用的淋漓尽致的经典框架,其实从类的名称就能够看出来,我们来一一列举一下:

特别需要说明的是,设计模式从来都不是单个设计模式独立使用的。 在通常情况下,经常是多个设计模式混合使用,你中有我,我中有你。所有的设计模式讲解都会围绕Spring的IOC、AOP、JDBC、MVC来进行展开。设计模式根据设计类型进行分类如下:

二、工厂模式详解

2.1 工厂模式的由来

在我们的现实生活当中,原始社会自给自足(没有工厂)、农耕社会的小作坊(简单工厂,民间酒坊)、工业革命流水线(工厂方法,自产自销)、现代产业链工厂(抽象工厂,富士康)




从现实生活联想到我们项目中的代码同样也是由简而繁一步一步迭代而来的,但是对于调用者确是越来越简单化。

2.2 简单工厂模式(Simple Factory Pattern)

简单工厂模式是指由一个工厂对象决定创建出哪一种产品的实例,但它不属于GOF,23设计模式。

参考资料维基百科地址:https://en.wikipedia.org/wiki/Design_Patterns#Patterns_by_Type

简单工厂模式适用于工厂类负责创建的对象较少的场景,且客户端只需要传入工厂类的参数,对于如何创建对象的逻辑不需要关系。

接下来我们来举例,以高中学校课程为例。语文、数学、英语等多门学科。我们可以定义一个课程标准ICourse接口:

1
2
3
4
5
6
7
public interface ICourse {

/**
* 学习课程
*/
public void study();
}

创建一个语文课的实现ChineseCourse类:

1
2
3
4
5
6
7
8
9
10
11
public class ChineseCourse implements ICourse {
@Override
public void study() {
System.out.println("学习语文课");
}

public static void main(String[] args) {
ICourse course = new ChineseCourse();
course.study();
}
}

看上面的main方法中,应用层的代码需要依赖ChineseCourse,如果业务扩展,会继续增加MathCourse甚至更多,这样的话客户端的依赖会越来越臃肿的。所以我们需要对创建代码的细节进行隐藏,我们使用简单工厂模式对代码进行优化。先添加MathCourse类:

1
2
3
4
5
6
public class MathCourse implements ICourse {
@Override
public void study() {
System.out.println("学习数学课");
}
}

创建CourseFactory工厂类:

1
2
3
4
5
6
7
8
9
10
11
public class CourseFactory {
public ICourse create(String name) {
if("chinese".equals(name)) {
return new ChineseCourse();
}else if("math".equals(name)) {
return new MathCourse();
}else {
return null;
}
}
}

mian方法调用:

1
2
3
4
 public static void main(String[] args) {
CourseFactory courseFactory = new CourseFactory();
courseFactory.create("chinese");
}

客户端调用是简单了,但是我们的业务继续扩展,需要增加英文课,那么工厂中的create()方法要根据增加的业务每次都修改代码逻辑,不符合开闭原则。因此,我们还需要对简单工厂进行优化,利用反射技术:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class CourseFactory {

public ICourse create(String className) {
try {
if(!(null == className || "".equals(className))) {
return (ICourse) Class.forName(className).newInstance();
}
} catch (InstantiationException e) {
e.printStackTrace();
} catch (IllegalAccessException e) {
e.printStackTrace();
} catch (ClassNotFoundException e) {
e.printStackTrace();
}
return null;
}

public static void main(String[] args) {
CourseFactory courseFactory = new CourseFactory();
ICourse course = courseFactory.create("com.sfp.ChineseCourse");
course.study();
}
}

优化之后,课程不断增加不需要修改CourseFactory中的代码了。但是,方法参数是字符串,可控性有待提高,而且还需进行强制转换。再次修改代码:

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

public ICourse create(Class<? extends ICourse> clazz) {
try {
if(null != clazz) {
return clazz.newInstance();
}
} catch (InstantiationException e) {
e.printStackTrace();
} catch (IllegalAccessException e) {
e.printStackTrace();
}
return null;
}

public static void main(String[] args) {
CourseFactory courseFactory = new CourseFactory();
ICourse course = courseFactory.create(ChineseCourse.class);
course.study();
}
}

简单工厂模式的例子无处不在,现在我们来看JDK当中的类使用简单工厂模式的例子,例如Calendar类,其中Calendar.getInstance()方法,我们查看源码具体的实现步骤:

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
private static Calendar createCalendar(TimeZone zone,
Locale aLocale)
{
CalendarProvider provider =
LocaleProviderAdapter.getAdapter(CalendarProvider.class, aLocale)
.getCalendarProvider();
if (provider != null) {
try {
return provider.getInstance(zone, aLocale);
} catch (IllegalArgumentException iae) {
// fall back to the default instantiation
}
}

Calendar cal = null;

if (aLocale.hasExtensions()) {
String caltype = aLocale.getUnicodeLocaleType("ca");
if (caltype != null) {
switch (caltype) {
case "buddhist":
cal = new BuddhistCalendar(zone, aLocale);
break;
case "japanese":
cal = new JapaneseImperialCalendar(zone, aLocale);
break;
case "gregory":
cal = new GregorianCalendar(zone, aLocale);
break;
}
}
}
if (cal == null) {
// If no known calendar type is explicitly specified,
// perform the traditional way to create a Calendar:
// create a BuddhistCalendar for th_TH locale,
// a JapaneseImperialCalendar for ja_JP_JP locale, or
// a GregorianCalendar for any other locales.
// NOTE: The language, country and variant strings are interned.
if (aLocale.getLanguage() == "th" && aLocale.getCountry() == "TH") {
cal = new BuddhistCalendar(zone, aLocale);
} else if (aLocale.getVariant() == "JP" && aLocale.getLanguage() == "ja"
&& aLocale.getCountry() == "JP") {
cal = new JapaneseImperialCalendar(zone, aLocale);
} else {
cal = new GregorianCalendar(zone, aLocale);
}
}
return cal;
}

还有一个大家经常使用的logback,我们可以看到LoggerFactory中有多个重载的方法getLogger():

1
2
3
4
5
6
7
public static Logger getLogger(String name) {
ILoggerFactory iLoggerFactory = getILoggerFactory();
return iLoggerFactory.getLogger(name);
}
public static Logger getLogger(Class clazz) {
return getLogger(clazz.getName());
}

简单工厂的缺点:工厂的职责相对过重,不易于扩展过于复杂的代码结构。

2.3 工厂方法模式(Factory Method Pattern)

工厂方法模式是指定义一个创建对象的接口,但让实现这个接口的类来决定实例化哪个类,工厂方法让类的实例化推迟到子类中进行。 在工厂方法模式中只关心所需产品对应的工厂,无心关注创建细节,而加入新的产品符合开闭原则。

工厂方式模式主要解决产品扩展的问题,根据单一职责原则将职能进行拆分,专人干专事。语文课由语文工厂创建,数据课由数学工厂创建,对工厂本身做一个抽象。示例代码如下:

创建ICourseFactory接口:

1
2
3
4
public interface ICourseFacotry {

ICourse create();
}

再分别创建子工厂,ChineseCourseFactory类:

1
2
3
4
5
6
public class ChineseCourseFactory implements ICourseFacotry {
@Override
public ICourse create() {
return new ChineseCourse();
}
}

MathCourseFactory类:

1
2
3
4
5
6
public class MathCourseFactory implements ICourseFacotry {
@Override
public ICourse create() {
return new MathCourse();
}
}

执行main方法:

1
2
3
4
5
6
7
8
9
public static void main(String[] args) {
ICourseFacotry chineseCourseFactory = new ChineseCourseFactory();
ICourse chineseCourse = chineseCourseFactory.create();
chineseCourse.study();

ICourseFacotry mathCourseFactory = new MathCourseFactory();
ICourse mathCourse = mathCourseFactory.create();
mathCourse.study();
}

2.4 抽象工厂模式(Abstract Factory Pattern)

抽象工厂模式是指提供一个创建一系列相关或相互依赖对象的接口,无需指定它们具体的类。 客户端(应用层)不依赖于产品实例如何被创建、实现等细节,强调的是一系列相关的产品对象(属于同一产品族)一起使用创建对象需要大量重复的代码。需要提供一个产品库的类,所有的产品以同样的接口出现,从而使客户端不依赖于具体实现。

我们还是以课程的例子为例,现在是新冠状病毒疫情的时期,高中学生只能在家利用互联网进行在线直播上课,每个课程不仅要提供课程的录播视频,而且还要提供老师的课堂笔记。相当于现在的业务变更为同一个课程不单纯是课程信息,同时要包括录播视频、课堂笔记等才是一个完整的课程。在产品等级中增加两个产品接口Ivideo录播视频和INote课堂笔记。

Ivideo接口:

1
2
3
4
public interface IVideo {

void record();
}

INote接口:

1
2
3
4
public interface INote {

void edit();
}

创建抽象工厂类CourseFactory类:

1
2
3
4
5
6
public interface CourseFactory {

IVideo createVideo();

INote createNote();
}

创建语文课产品族,语文课视频的ChineseVideo类:

1
2
3
4
5
6
public class ChineseVideo implements IVideo {
@Override
public void record() {
System.out.println("录制语文课视频!");
}
}

创建语文课课堂笔记的ChineseNote类:

1
2
3
4
5
6
public class ChineseNote implements INote {
@Override
public void edit() {
System.out.println("编写语文课笔记!");
}
}

创建语文课产品族的具体工厂类ChineseCourseFactory:

1
2
3
4
5
6
7
8
9
10
11
public class ChineseCourseFactory implements CourseFactory {
@Override
public IVideo createVideo() {
return new ChineseVideo();
}

@Override
public INote createNote() {
return new ChineseNote();
}
}

然后再创建数学产品,Math视频MathVideo类:

1
2
3
4
5
6
public class MathVideo implements IVideo {
@Override
public void record() {
System.out.println("录制数学课视频!");
}
}

创建数学课堂笔记的MathNote类:

1
2
3
4
5
6
public class MathNote implements INote {
@Override
public void edit() {
System.out.println("编写数学课笔记!");
}
}

创建数学课产品族的具体工厂类MathCourseFactory:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class MathCourseFactory implements CourseFactory {
@Override
public IVideo createVideo() {
return new MathVideo();
}

@Override
public INote createNote() {
return new MathNote();
}

public static void main(String[] args) {
MathCourseFactory mathCourseFactory = new MathCourseFactory();
mathCourseFactory.createNote().edit();
mathCourseFactory.createVideo().record();
}
}

上面的案例代码完整的描述了两个产品族语文课程和数学课程,也描述了两个产品等级的视频和课堂笔记。抽象工厂非常完美清晰地描述了这一层复杂的关系。如果我们再升级扩展产品等级,将课堂作业也加入到课程中,我们的代码需要从抽象工厂,到具体的工厂都要进行调整,很显然不符合开闭原则。所以抽象工厂也是有缺点的:

1、规定了所有可能被创建的产品集合,产品族中扩展新的产品困难,需要修改抽象工厂的接口。

2、增加了系统的抽象性和理解难度。

三、工厂模式总结

架构师内功心法,软件架构设计的七大原则精选案例

一、软件架构设计的七大原则简介

1.1 开闭原则(Open-Closed Principle,OCP)

开闭原则是一个软件实体如类、模块和函数应该对扩展开放,对修改关闭。所谓的开闭也是对扩展和修改两个行为的一个原则。强调是用抽象扩展架构,用实现扩展细节。可以提高软件系统的可复用性及可维护性。开闭原则,是面向对象设计中最基础的设计原则。它指导我们如何建立稳定灵活的系统,例如:版本更新,我们尽可能不修改代码,但是可以增加新功能。

在现实生活中也有很多开闭原则的例子,比如,很多互联网公司都实行弹性的工作时间,规定每天工作8小时。意思就是说,对于工作日每天工作8小时这个规定是关闭的,但是你什么时候来,什么时候走是开放的。早来早走,晚来晚走。

实现开闭原则的核心思想就是面向抽象编程,以某新华书店的图书为例,首先创建一个课程接口类:

1
2
3
4
5
public class IBook {
Integer getId();
String getName();
Double getPrice();
}

我们来创建一个计算机图书的类ComputerBook:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class ComputerBook implements IBook {
private Integer id;
private String name;
protected Double price;
public ComputerBook(Integer id, String name, Double price) {
this.id = id;
this.name = name;
this.price = price;
}

public Integer getId() {
return this.id;
}

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

public Double getPrice() {
return this.price;
}
}

现在我们要给计算机类的图书做活动,价格优惠。如果修改ComputerBook类的getPrice()方法,则会存在一定的风险,可能会影响其它地方的调用结果。我们如何在不修改原有代码的前提下,实现价格优惠这个功能呢?现在,我们再写一个处理优惠逻辑的类,ComputerDiscountBook类:

1
2
3
4
5
6
7
8
9
10
11
12
13
public class ComputerDiscountBook extends ComputerBook {
public ComputerDiscountBook(Integer id, String name, Double price) {
super(id, name, price);
}

public Double getOriginPrice() {
return super.price;
}

public Double getPrice() {
return super.price * 0.8;
}
}

1.2 依赖倒置原则(Dependence Inversion Principle,DIP)

依赖倒置原则是指设计代码结构时,高层模块不应该依赖底层模块,二者都应该依赖其抽象。抽象不应该依赖细节;细节应该依赖抽象。通过依赖倒置,可以减少类与类之间的耦合性,提高系统的稳定性,提高代码的可读性和可维护性,并能够降低修改程序所造成的风险。

还是以课程为例,创建一个类Kevin:

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

public void readComputerBook() {
System.out.println("Kevin正在阅读电脑书籍");
}

public void readScienceBook() {
System.out.println("Kevin正在阅读科学书籍");
}

public static void main(String[] args) {
Kevin kevin = new Kevin();
kevin.readComputerBook();
kevin.readScienceBook();
}

}

Kevin正在阅读电脑书籍和科学书籍。大家知道电脑书籍的种类很多,现在人工智能AI这么火热,Kevin想学习关于人工智能方面的电脑书籍。这个时候,业务需要扩展,我们需要从底层到调用层一次修改代码来满足业务要求。在Kevin中增加readAIBook()的方法,在调用层也要追加调用。这样一来,系统发布的时候,实际上不是很稳定。如何优化我们的代码,创建一个书籍的抽象IBook接口:

1
2
3
4
public interface IBook {

void read();
}

然后写ComputerBook类:

1
2
3
4
5
6
public class ComputerBook implements IBook {
@Override
public void read() {
System.out.println("Kevin正在阅读电脑书籍");
}
}

再写ScienceBook类:

1
2
3
4
5
6
public class ScienceBook implements IBook {
@Override
public void read() {
System.out.printf("Kevin正在阅读科学书籍");
}
}

修改Kevin类:

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

public void read(IBook book) {
book.read();
}

public static void main(String[] args) {
Kevin kevin = new Kevin();
kevin.read(new ComputerBook());
kevin.read(new ScienceBook());
}

}

我们来看main方法调用,无论Kevin想读哪些类的新书,只需要创建一个类,通过传参的方式告诉Kevin,而不需要修改底层代码来实现。这种实现方式叫做依赖注入。注入的方式有构造器注入和setter注入两种方式。

构造器注入实现方式:

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

public IBook book;

public Kevin(IBook book) {
this.book = book;
}

public void read() {
book.read();
}

public static void main(String[] args) {
Kevin kevin = new Kevin(new ComputerBook());
kevin.read();
}

}

根据构造器注入,在调用时,每次都需要创建实例。如果Kevin是全局单例的话,则只能选择Setter方式来注入,继续修改Kevin类的代码:

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

public IBook book;

public void setBook(IBook book) {
this.book = book;
}

public void read() {
book.read();
}

public static void main(String[] args) {
Kevin kevin = new Kevin();
kevin.setBook(new ComputerBook());
kevin.read();

kevin.setBook(new ScienceBook());
kevin.read();
}

}

以抽象为基准比以细节为基准搭建起来的代码架构要稳定得多,因此拿到需求任务后,要面向接口编程,先设计顶层再琢磨细节来设计代码结构。

1.3 单一职责原则(Simple Responsibility Principle,SRP)

单一职责原则是值不要存在多于一个导致类变更的原因。假设我们有一个类要负责两个职责,一旦发生需求变更,修改其中一个职责的逻辑代码,有可能会导致另一个职责的功能发生故障。这样一来,这个类存在两个导致类变更的原因。怎么来解决这个问题呢?就需要给两个职责分别用两个类来实现,进行解耦。后期需求维护相互不受影响。这样的设计,可以降低类的复杂度,提高类的可读性,提高系统的可维护性,降低变更引起的风险。总体来说就是一个class/interface/mothod只负责一项职责。
这里用在线直播课程的案例来举例,课程有在线直播课和录播课。直播课不能快进和后退,录播课程可以任意的反复观看,功能职责不一样。创建一个Course类:

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

public static final String COURSE_NAME = "直播课";

public void study(String courseName) {
if(COURSE_NAME.equals(courseName)) {
System.out.println(courseName + "不能快进和快退");
}else {
System.out.println(courseName + "可以反复观看");
}
}

public static void main(String[] args) {
Course course = new Course();
course.study("直播课");
course.study("录播课");
}
}

从上面的代码看,Course类承担了两种逻辑。现在需要对课程进行加密,那么直播课和录播课的加密逻辑是不一样的,必须要修改代码。而修改代码逻辑必然会相互影响并且容易造成风险。我们对职责进行解耦,分别创建LiveCourse和ReplayCourse两个类:

LiveCourse类:

1
2
3
4
5
6
public class LiveCourse {

public void study(String courseName) {
System.out.println(courseName + "不能快进和快退");
}
}

ReplayCourse类:

1
2
3
4
5
6
7
public class ReplayCourse {

public void study(String courseName) {
System.out.println(courseName + "可以反复观看");
}

}

调用main方法:

1
2
3
4
5
6
public static void main(String[] args) {
LiveCourse liveCourse = new LiveCourse();
liveCourse.study("直播课");
ReplayCourse replayCourse = new ReplayCourse();
replayCourse.study("录播课");
}

随着业务的发展,课程也要做权限。没有付费的vip会员可以获取课程基本信息,已经付费的vip会员可以获得视频流,即获得视频观看权限。对于控制课程层面上至少有两个职责。我们可以把展示职责和管理职责分离出来,实现同一个抽象依赖。设计一个顶层接口,创建ICourse接口:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public interface ICourse {

/**
* 获得课程信息
* @return
*/
String getCourseName();

/**
* 获得视频流
* @return
*/
byte[] getCourseVideo();

/**
* 学习课程
*/
void studyCourse();

/**
* 退款
*/
void refundCourse();
}

可以把这个接口拆成两个接口,创建一个接口ICourseInfo和ICourseManager:

接口ICourseInfo:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public interface ICourseInfo {

/**
* 获得课程信息
* @return
*/
String getCourseName();

/**
* 获得视频流
* @return
*/
byte[] getCourseVideo();
}

接口ICourseManager:

1
2
3
4
5
6
7
8
9
10
11
12
public interface ICourseManager {

/**
* 学习课程
*/
void studyCourse();

/**
* 退款
*/
void refundCourse();
}

下面来看下方法层面的单一职责的代码设计。有时候我们为了偷懒,通常会把方法写成下面这样子的:

1
2
3
4
private void mofifyUserInfo(String userName, String address) {
userName = "Kevin";
address = "Beijing";
}

还可能写成这样子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
private void modifyUserInfo(String userName, String... fields) {
userName = "Kevin";

}

private void modifyUserInfo(String userName, String address, boolean bool) {
if(bool) {

}else {

}
userName = "Kevin";
address = "Beijing";
}

显然上面的modifyUserInfo()方法中承担了多个职责,既可以修改userName,也可以修改address,甚至更多的值,明显不符合单一职责。这样我们需要对代码进行修改,把这个方法拆分成两个方法:

1
2
3
4
5
6
7
private void modifyUserName(String userName) {
userName = "Kevin";
}

private void modifyAddress(String address) {
address = "Beijing";
}

修改之后的代码看起来简单,且维护起来更加容易。但是,我们在实际开发项目过程中,项目直接会相互依赖,组合、聚和这些关系,还有项目的规模,周期,技术人员的水平,对进度的把控,很多类都不符合单一职责。但是,我们在编码的过程中尽量做到单一职责,这样对我们项目的后期维护是有很大的帮助的。

1.4 接口隔离原则 (Interface Segregation Principle,ISP)

接口隔离原则是指用多个专门的接口,而不使用单一的总接口,客户端不应该依赖它不需要的接口。这个原则指导我们在设计接口的时候应该注意以下几点:

1、一个类对一类的依赖应该建立在最小的接口之上;

2、建立单一接口,不要建立庞大臃肿的接口;

3、尽量细化接口,接口中的方法尽量少(不是越少越好,要适度)。

接口隔离原则符合我们常说的高内聚低耦合的设计思想,从而使得类具有很好的可读性、可扩展性以及可维护性。在设计接口的时候,要多花时间去思考,要考虑业务模型,包括以后有可能发生变更的地方还要做一些预判。所以对于抽象,对于业务模型的理解是非常重要的。下面举例来看一个动物行为的抽象接口:

IAnimal接口:

1
2
3
4
5
public interface IAnimal {
void eat();
void fly();
void swim();
}

Bird 类实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class Bird implements IAnimal {

@Override
public void eat() {

}

@Override
public void fly() {

}

@Override
public void swim() {

}
}

Dog 类实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class Dog implements IAnimal {
@Override
public void eat() {

}

@Override
public void fly() {

}

@Override
public void swim() {

}
}

可以看出,Bird 的 swim()方法可能只能空着,Dog 的fly()方法显然不可能的。这时候,我们针对不同动物行为来设计不同的接口,分别设计 IEatAnimal,IFlyAnimal 和ISwimAnimal 接口,来看代码:

IEatAnimal接口:

1
2
3
public interface IEatAnimal {
void eat();
}

IFlyAnimal接口:

1
2
3
public interface IFlyAnimal {
void fly();
}

ISwimAnimal接口:

1
2
3
public interface ISwimAnimal {
void swim();
}

Dog 只实现 IEatAnimal 和 ISwimAnimal 接口:

1
2
3
4
5
6
7
8
9
10
11
12
public class Dog implements IEatAnimal, ISwimAnimal {

@Override
public void eat() {

}

@Override
public void swim() {

}
}

1.5 迪米特法则(Law of Demeter, LoD)

迪米特法则是指一个对象应该对其他对象保持最少的了解,又叫最少知道原则(Least Knowledge Principle, LKP),尽量降低类与类之间的耦合。主要强调只和朋友说话,不和陌生人说话。出现在成员变量、方法输入、输出参数中的类都可以称之为成员朋友类,而出现在方法体内部的类不属于朋友类。

现在来设计一个权限系统,老板需要查看目前发布到线上的课程数量。这个老板找到项目负责人去进行统计,项目负责人再把统计结果告诉老板。来看一下代码示例:

Course类:

1
2
public class Course {
}

TeamLeader类:

1
2
3
4
5
public class TeamLeader {
public void checkNumberOfCourses(List<Course> courseList){
System.out.println("目前已发布的课程数量是:"+courseList.size());
}
}

Boss类:

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

public void commandCheckNumber(TeamLeader teamLeader){
//模拟 Boss 一页一页往下翻页,TeamLeader 实时统计
List<Course> courseList = new ArrayList<Course>();
for (int i= 0; i < 10 ;i ++){
courseList.add(new Course());
}
teamLeader.checkNumberOfCourses(courseList);
}

public static void main(String[] args) {
Boss boss = new Boss();
TeamLeader teamLeader = new TeamLeader();
boss.commandCheckNumber(teamLeader);
}
}

上面的代码根据迪米特原则,老板Boss只想要结果,不需要跟Course产生直接交流。而课程负责人统计要引用Course对象。Boss和Course并不是朋友,我们需要修改代码:

TeamLeader类:

1
2
3
4
5
6
7
8
9
10
11
public class TeamLeader {

public void checkNumberOfCourses(){
List<Course> courseList = new ArrayList<Course>();
for(int i = 0 ;i < 10;i++){
courseList.add(new Course());
}
System.out.println("目前已发布的课程数量是:"+courseList.size());
}

}

Boss类:

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

public void commandCheckNumber(TeamLeader teamLeader){
teamLeader.checkNumberOfCourses();
}

public static void main(String[] args) {
Boss boss = new Boss();
TeamLeader teamLeader = new TeamLeader();
boss.commandCheckNumber(teamLeader);
}
}

得到的:学习软件设计原则,千万不能形成强迫症。碰到业务复杂的场景,我们需要随机应变。

1.6 里氏替换原则(Liskov Substitution Principle,LSP)

里氏替换原则是指如果对每一个类型T1的对象o1,都有类型为T2的对象o2,使得以T1定义的所有程序P在所有的对象o1都替换成o2时,程序P的行为没有发生变化,那么类型T2是类型T1的子类型。

定义看上去还是比较抽象,可以理解为如果适应一个父类的话,那一定是适用于子类,所有的引用父类的地方必须能透明的使用子类的对象,子类对象能够替换父类对象,而程序逻辑不变。 总结一下:

引申含义:子类可以扩展父类的功能,但不能改变父类原有的功能。

1、子类可以实现父类的抽象方法,但不能覆盖父类的抽象方法;

2、子类中可以增加自己特有的方法;

3、当子类的方法覆盖父类的方法时,方法的前置条件(即方法的输入/入参)要比父类方法的输入参数更宽松;

4、当子类的方法实现父类的方法时(重写/重载或实现抽象方法),方法的后置条件(即方法的输出/返回值)要比父类更严格或者相等。

我们在开闭原则的代码案例中在获取折后时覆盖了父类的getPrice()方法,增加了一个获取优惠价格后的方法getOriginPrice(),显然就违背了里氏替换原则。接下来我们不覆盖getPrice()方法,增加getDiscountBook()方法:

1
2
3
4
5
6
7
8
9
public class ComputerDiscountBook extends ComputerBook {
public ComputerDiscountBook(Integer id, String name, Double price) {
super(id, name, price);
}

public Double getDiscountBook() {
return super.getPrice() * 0.8;
}
}

使用里氏替换原则有以下几个优点:

1、约束继承泛滥,开闭原则的一种体现;

2、加强程序的健壮性,同时变更时可以做到很好的兼容性,提高程序的维护性、扩展性。降低需求变更时引入的风险。

现在用正方形、矩形和四边形来说明里氏替换原则,我们都知道正方形是一个特殊的长方形,创建一个长方形父类Rectangle:

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

private long width;

private long height;

public long getWidth() {
return width;
}

public void setWidth(long width) {
this.width = width;
}

public long getHeight() {
return height;
}

public void setHeight(long height) {
this.height = height;
}
}

创建正方形Square类继承Rectangle长方形类,

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 class Square extends Rectangle {

private long length;
public long getLength() {
return length;
}
public void setLength(long length) {
this.length = length;
}

@Override
public long getWidth() {
return getLength();
}
@Override
public long getHeight() {
return getLength();
}
@Override
public void setHeight(long height) {
setLength(height);
}
@Override
public void setWidth(long width) {
setLength(width);
}
}

在测试类中创建 resize()方法,根据逻辑长方形的宽应该大于等于高,我们让高一直自增,知道高等于宽变成正方形:

1
2
3
4
5
6
7
8
public static void resize(Rectangle rectangle){
while (rectangle.getWidth() >= rectangle.getHeight()){
rectangle.setHeight(rectangle.getHeight() + 1);
System.out.println("width:"+rectangle.getWidth() + ",height:"+rectangle.getHeight());
}
System.out.println("resize 方法结束" +
"\nwidth:"+rectangle.getWidth() + ",height:"+rectangle.getHeight());
}

测试代码:

1
2
3
4
5
6
 public static void main(String[] args) {
Rectangle rectangle = new Rectangle();
rectangle.setWidth(20);
rectangle.setHeight(10);
resize(rectangle);
}

运行结果:

修改代码把长方形Rectangle替换成它的子类正方形Square:

1
2
3
4
5
public static void main(String[] args) {
Square square = new Square();
square.setLength(10);
resize(square);
}

这时候我们运行的时候就出现了死循环,违背了里氏替换原则,将父类替换为子类后,程序运行结果没有达到预期。因此,我们的代码设计是存在一定风险的。里氏替换原则只存在父类与子类之间,约束继承泛滥。再来创建一个基于长方形与正方形共同抽象的四边形Quadrangle接口:

1
2
3
4
5
6
public interface Quadrangle {

long getWidth();

long getHeight();
}

修改长方形 Rectangle 类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class Rectangle implements Quadrangle {

private long height;
private long width;
@Override
public long getWidth() {
return width;
}
public long getHeight() {
return height;
}
public void setHeight(long height) {
this.height = height;
}
public void setWidth(long width) {
this.width = width;
}
}

修改正方形类 Square 类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class Square implements Quadrangle {

private long length;
public long getLength() {
return length;
}
public void setLength(long length) {
this.length = length;
}
@Override
public long getWidth() {
return length;
}
@Override
public long getHeight() {
return length;
}
}

此时,如果我们把 resize()方法的参数换成四边形 Quadrangle类,方法内部就会报错。因为正方形 Square 已经没有了 setWidth()和 setHeight()方法了。因此,为了约束继承泛滥,resize()的方法参数只能用 Rectangle 长方形。

1.7 合成复用原则 (Composite/Aggregate Reuse Principle,CARP)

合成复用原则是指尽量使用对象组合/聚和,而不是用继承关系达到对象复用的目的。这样可以使系统更加灵活,降低类与类之间的耦合度,一个类的变化对其它类造成的影响也较少。

继承又叫做白箱复用,相当于把所有的实现细节暴露给子类。组合/聚和也成为黑箱复用,对类以外的对象是无法获取到实现细节的。 需要根据具体的业务场景来设计代码,其实也要遵循OOP模型。这里以数据库操作为例,先创建DBConnection类:

1
2
3
4
5
6
public class DBConnection {

public String getConnection(){
return "MySQL 数据库连接";
}
}

创建 ProductDao 类:

1
2
3
4
5
6
7
8
9
10
11
public class ProductDao {

private DBConnection dbConnection;
public void setDbConnection(DBConnection dbConnection) {
this.dbConnection = dbConnection;
}
public void addProduct(){
String conn = dbConnection.getConnection();
System.out.println("使用"+conn+"增加产品");
}
}

这就是非常典型的合成复用原则的应用场景。但是DBConnection还不是一种抽象,不便于系统扩展。目前系统只支持Mysql数据库连接,假设业务发生变化,数据库操作层要支持Oralce数据库。当然,可以在DBConnection中增加对Oracle数据库支持的方法。但是违背了开闭原则。我们可以不修改Dao的代码,将DBConnection改为abstract,代码如下:

1
2
3
public abstract class DBConnection {
public abstract String getConnection();
}

然后,将 MySQL 的逻辑抽离:

1
2
3
4
5
6
public class MySqlConnection extends DBConnection {
@Override
public String getConnection() {
return "MySQL 数据库连接";
}
}

再创建 Oracle 支持的逻辑:

1
2
3
4
5
6
public class OracleConnection extends DBConnection {
@Override
public String getConnection() {
return "Oracle 数据库连接";
}
}

二、 设计原则总结

学习设计原则,学习设计模式的基础。在实际开发过程中,并不是所有的代码都要遵循设计原则。我们需要考虑人力、时间、成本、质量,不是刻意的追求完美,要在适当的场景下遵循设计原则,体现的是一种平衡的取舍,帮助我们设计出更加优美的代码结构。

如何优雅的学习JVM,终结篇(六)

一、如何计算一个对象占用的内存大小?

我们在编写代码的过程中会创建大量的对象,有没有考虑到底某个对象占用了多少内存呢?

在C/C++中,我们可以通过sizeof()函数来计算一个变量或者类型所占用的大小,然而在Java中并没有这样的系统调用,那么在Java中如何实现类似的计算对象占用的内存大小呢?Java对象的内存结构,我们在前面的章节也进行了学习,它包括对象头(标记位、对象指针)、实例数据、对齐填充。那么我们只要按照顺序计算出各个区域所占用的内存并求和就可以了。其实仔细想想肯定没有这么简单,其中还有很多细节问题需要考虑。

  • 对象头

在不开启JVM对象头压缩的情况下:

32位 JRE中一个对象头大小是8个字节(4 + 4)

64位 JRE中则是16个字节(8 + 8)

  • 实例数据

成员变量主要包括两种:基本类型和引用类型,非静态成员变量所占用的数据。在确定JRE运行环境中,基本类型和引用类型占用的内存大小都是确定的,因此需要简单通过反射做个加法似乎就可以了。但是实际情况不是你想的这样简单,让我们来写代码测试一下吧。

通过jol工具可以查看到一个对象的实际内存布局,我们使用OpenJDK,提供了JOL包,官网: http://openjdk.java.net/projects/code-tools/jol/

POM依赖:

1
2
3
4
5
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.8</version>
</dependency>

创建一个如下示例代码的测试类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import org.openjdk.jol.info.ClassLayout;

public class Pojo {
public int a;
public String b;
public int c;
public boolean d;
private long e; // e设置为私有的,后面讲解为什么
public Object f;
Pojo() { e = 1024L;}

public static void main(String[] args) {
Pojo pojo = new Pojo();
System.out.println(ClassLayout.parseInstance(pojo).toPrintable());
}
}

使用 jol 工具查看其内存布局如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
 OFFSET  SIZE               TYPE DESCRIPTION                               VALUE
0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) 05 c1 00 f8 (00000101 11000001 00000000 11111000) (-134168315)
12 4 int Pojo.a 0
16 8 long Pojo.e 1024
24 4 int Pojo.c 0
28 1 boolean Pojo.d false
29 3 (alignment/padding gap)
32 4 java.lang.String Pojo.b null
36 4 java.lang.Object Pojo.f null
Instance size: 40 bytes
Space losses: 3 bytes internal + 0 bytes external = 3 bytes total

由此可以看出对象头所占用的大小为12字节。从这个内存布局表上不难看出,成员变量在实际分配内存时,并不是按照声明的顺序来储存的,此外在变量d之后,还出现了一块用于对齐内存的padding gap,这说明计算对象实际数据所占用的内存大小时,并不是简单的求和就可以的。

在上面的内存布局表中,可以看到OFFSET一列,这就是对应变量的偏移地址,如同C++中的指针,其实就是告诉了CPU要从什么位置取出对应的数据。举个例子,假设 Pojo 类的一个对象pojo存放在以 0x0010 开始的内存空间中,我们需要获取它的成员变量 b ,由于其偏移地址是 32(转换成十六进制为20),占用大小是 4 ,那么实际储存变量b的内存空间就是 0 x0030 ~ 0x0033 ,根据这个 CPU 就可以很容易地获取到变量了。

实际上在反射中,正式通过这样的方式来获取指定属性的值,具体实际上则需要借助强大的Unsafe工具。Unsafe可以实现系统底层不可思议的操作(比如修改变量的可见性,分配和回收堆外内存等),不过正因为其功能的强大性,随意使用有可能引发程序崩溃,所以官方不建议在除系统之外(如反射等)以外的场景使用,使用Unsafe如何通过变量偏移地址来获取一个变量。

示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Test
public void testUnsafe() throws Exception {
Class<?> unsafeClass = null;
Unsafe unsafe = null;
try {
unsafeClass = Class.forName("sun.misc.Unsafe");
final Field unsafeField = unsafeClass.getDeclaredField("theUnsafe");
unsafeField.setAccessible(true);
unsafe = (Unsafe) unsafeField.get(null);
} catch (Exception e) {
e.printStackTrace();
}
Pojo pojo = new Pojo();//上面案例的测试实体类
Field field = Pojo.class.getDeclaredField("e");
long offset = unsafe.objectFieldOffset(field);
if(offset > 0L) {
long eVal = unsafe.getLong(pojo, offset);
System.out.println(eVal);
}
}

运行结果打印:1024

出于安全起见,一般情况下在正常的代码中是无法直接获取 Unsafe的实例的,这里我们通过反射的方式hack了一把来拿到 unsafe实例。接着通过调用 objectFieldOffset 方法获取到成员变量 e的地址偏移为 16(和 jol 中的结果一致),最终我们通过 getLong() 方法,传入e 的地址偏移量,便获取到了 e 的值。可以看到尽管 Pojo 类中 e是一个私有属性,通过这种方法依然是可以获取到它的值的

有了调用Unsafe的objectFieldOffset,我们可以通过代码精确的计算一个对象在内存中所占用的空间大小了,递归遍历对象中所有的引用并计算他们指向的实际对象的浅内存占用,最终求和即可。考虑到会有大量重复的类出现,我们可以使用一个数组来缓存已经计算过浅内存占用的class,避免重复计算。

如果引用指向了数组或者集合类型,那么只需要计算其基本元素的大小,然后乘以数组长度/集合大小即可。

具体实现代码在此不过多赘述,可以直接参考源代码( from Apache luence ,入口方法为 sizeOf ( Object ))。

源代码:
https://github.com/MarkLux/Java-Memory-Monitor/blob/master/src/main/java/cn/marklux/memory/RamUsageEstimator.java

需要注意的是,这种计算对象内存的方法并不是毫无代价的,由于使用了递归、反射和缓存,在性能和空间上都会有一定的消耗。

二、性能优化经验总结

2.1 性能优化的背景

大家平时一定遇到过这样的问题:单机的线程池队列爆满,使用集群扩容增加集群;系统内存占用高,高峰时段OOM,重启就分分钟解决了等一系列的问题。如果临时性的补救措施只能是给应用埋雷,同时只能解决部分问题,治标不治本。

添加硬件资源并不一定能够解决系统的性能问题,反而有时候会造成资源的浪费,得不偿失。因此对系统进行合理的性能优化,可以在系统稳定性、成本核算获得很大的收益。

假设现在我们的系统已经出现了性能问题,需要准备开始进行优化工作,那么在这个优化过程中,潜在的痛点有哪些呢?

1、对性能优化的流程不是很清晰。 最终需要解决的问题其实是一个浅层次的性能瓶颈,真实的问题的根源并不能触达。

2、对性能瓶颈点的分析思路不是很清晰。 网路、CPU、内存等这么多的性能指标,到底该关注什么,应该从哪一块开始入手呢?

3、对性能优化的工具不是很了解。 遇到问题后,不清楚使用哪个工具,不知道通过工具得到的指标代表着什么。

2.2 性能优化的流程

1、准备阶段: 通过性能测试,了解系统的概况、瓶颈的大概方向,明确优化目标;

2、分析阶段: 通过各种工具或手段,初步定为性能瓶颈点;

3、调优阶段: 根据定位到的瓶颈点,进行系统性能调优。

4、测试阶段: 让调优后的系统进行性能测试,与准备阶段的各项指标进行对比,观察其是否属于预期,如果瓶颈点没有消除或者性能指标不符合预期,则重复2和3步骤。

  • 准备阶段详解:

    对性能问题进行粗略评估, 比如线上应用日志级别不合理,可能会在大流量的时候导致CPU和磁盘的负载过高,这种情况需要调整日志级别;

    了解应用的总体架构, 应用依赖的外部接口和本身核心接口有哪些,使用了哪些框架和组件,哪些接口,模块的使用率较高,上下游的数据链路是怎么样的;

    了解应用对应的服务器信息, 服务器所在的集群信息、服务器的CPU、内存等信息,安装的操作系统Linux版本信息,服务器是容器还是虚拟机,所在的宿主机混部(把集群混合起来,将不同类型的任务调度到相同的物理资源上,通过调度,资源隔离等控制手段 , 在保障 SLO(服务等级目标) 的基础上,充分使用资源能力,极大降低成本)后是否当前应用有影响等。

    a、通过压测工具或者压测平台(如果公司有的话),对应用进行压力测试,获取当前应用的宏观指标。也可以结合当前的实际业务和过往的监控数据,去统计一些核心业务指标,比如午高峰的服务TPS。

* 响应时间


* 吞吐量


* TPS


* QPS


* 消费速率(对于使用MQ的中间件)


b、可以Linux基准测试工具,得到文件系统、磁盘I/O、网络等性能报告,还有GC、Web服务器、网卡流量等信息。


* jmeter


* ab


* loadrunnerwrk


* wrk
  • 测试阶段详解:

    性能瓶颈点通常呈现2/8分布, 即80%的性能问题通常是由20%的性能瓶颈点导致的,2/8原则也以为着并不是所有的性能问题都值得去优化;

    不要过度追求应用的单机性能, 如果单机表现良好,则应该从架构的角度去思考;比如过滤追求CPU的性能而忽略了内存方面的瓶颈;

    整个应用的优化,应与线上系统隔离, 新的代码上线应该有降级方案。

2.3 工具箱

工欲善其事必先利其器,我们该如何选择合适的工具呢?先来看看Linux性能工具图吧。

上面的这张图非常经典,是做性能优化时候非常好的参考资料,但是事实上,我们在实际运用的时候,会发现可能并不合适。所以需要给出一张更为实用的图,该图从系统层、应用层(含组件层)的角度,列举我们在分析性能问题需要关注的各项指标,这些点是最有可能出现性能瓶颈的地方。

下面针对不同层次的核心性能指标做如下分析,同时也会介绍如何初步根据这些指标,判断系统或者应用是否存在性能瓶颈点。

2.3.1 网络

这里说的网络指的是应用层的网络,通常指的是:

  • 网络带宽:表示链路的最大传输速率;
  • 网络吞吐:表示单位时间内成功传输的数据量大小;
  • 网络延时:表示从网络从请求发出后直到收到远端响应,所需要的时间;
  • 网络连接数和错误数。

应用层的网络瓶颈有如下几类:

  • 网络出现分区;
  • 集群或机器所在的机房的网络带宽饱和,影响应用的TPS/QPS的提升;
  • 网络吞吐出现异常,如接口存在大量的数据传输,造成贷款占用过高;
  • 网络连接出现异常或错误。

带宽和网络吞吐两个指标,一般会关注整个应用的,并通过监控系统可以直接得到,如一段时间内出现了明显的指标上升,说明存在网络瓶颈。对于单机,可以使用sar命令得到网络接口,进程的网络吞吐。

使用pinghping3可以得到是否出现网络分区、网络具体时延。整个应用链路的时延,可以通过中间件埋点后输出的trace日志得到链路上各个环节的时延信息。

使用netstat、sssar可以获取网络连接数或网络错误数。系统可以支撑的网络连接数是有限的,一是会占用文件描述符,二是会占用缓存

2.3.2 磁盘和文件

磁盘以及文件系统主要关注的指标有,常用命令有iostat(用于真个系统)和pidstat(用于具体的I/O进程):

  • 磁盘I/O利用率:是指磁盘处理I/O的时间百分比;
  • 磁盘吞吐量:是指每秒的I/O请求大小,单位为KB;
  • I/O响应时间:是指I/O请求从发出到收到响应的间隔,包含在队列中等待的时间和处理时间;
  • IOPS(Input/Output Per Second):每秒I/O请求数;
  • I/O等待队列大小:是指平均I/O队列长度,队列长度越短越好。
1
2
3
4
5
[root@iz2zea13o0oyywo7z5hawlz ~]# iostat -dx
Linux 3.10.0-693.2.2.el7.x86_64 (iz2zea13o0oyywo7z5hawlz) 02/16/2020 _x86_64_ (1 CPU)

Device: rrqm/s wrqm/s r/s w/s rkB/s wkB/s avgrq-sz avgqu-sz await r_await w_await svctm %util
vda 0.00 0.11 0.01 0.29 0.23 2.61 18.68 0.00 5.97 4.32 6.03 0.16 0.00

%util: 磁盘的I/O利用率,同CPU利用率一样,这个值也可能超过100%(存在并行I/O);

rKB/s、wKB/s: 每秒从磁盘读取和写入的数据量,即吞吐量,单位为KB;

r_await、w_await: 读和写请求处理的响应时间;

r/s、w/s: 每秒发送给磁盘的读请求数和写请求数;

svctm: 该指标废弃,表示处理I/O所需的平均时间;

pidstat 的输出大部分和 iostat 类似,区别在于它可以实时查看每个进程的 I/O 情况。

如何判断磁盘的指标出现了异常?

1、当 %util (磁盘利用率)长时间超过80%,或者响应时间过大(SSD,从0.0x毫秒到1.x毫秒不等,机械硬盘一般为5毫秒到10毫秒),通常意味着磁盘I/O存在性能瓶颈。

2、如果 %util 很大,而rKB/s和wKB/s很小,一般因为存在较多的磁盘随机读写,最好把随机读写优化成顺序读写,可以通过straceblktrace观察I/O是否连续判断是否是顺序读写行为,随机读写应该关注IOPS指标,顺序读写可以关注吞吐量指标。

3、如果avgqu-sz 比较大,说明有很多I/O在请求队列中等待。如果单块磁盘的队列长度持续超过2,一般认为该磁盘存在I/O性能问题。

2.3.3 CPU和线程

CPU关注的指标主要有以下几个。常用的命令有top、ps、uptime、vmstat、pidstat等。

  • CPU利用率(CPU Utilization)
  • CPU平均负载(Load Average)
  • 上下文切换次数(Context Switch)
1
2
3
4
5
6
7
8
9
top - 14:58:36 up 18 days,  1:31,  1 user,  load average: 0.00, 0.01, 0.05
Tasks: 63 total, 1 running, 62 sleeping, 0 stopped, 0 zombie
%Cpu(s): 0.3 us, 0.3 sy, 0.0 ni, 99.3 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st
KiB Mem : 1883420 total, 109840 free, 187712 used, 1585868 buff/cache
KiB Swap: 0 total, 0 free, 0 used. 1494900 avail Mem

PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND
1008 root 10 -10 132300 14672 9420 S 1.0 0.8 210:54.23 AliYunDun
1 root 20 0 43264 3708 2500 S 0.0 0.2 0:12.20 systemd

第一行显示内容:当前时间、系统运行时间以及正在登录的用户数。load average后的三个数字,依次表示过去1分钟、5分钟、15分钟的平均负载。CPU的平均负载和CPU的使用率没有直接的关系

第三行显示内容:表示CPU利用率,计算公式:CPU利用率 = 1 - CPU空闲时间/CPU总的时间。 注:top工具显示的CPU利用率是把所有的CPU核的数值加起来,即8核CPU的利用率最大可以达到800%,可以用(htop命令代替top进行查看)。

查看CPU上下文切换次数可以用vmstat命令进行查看:

1
2
3
4
[root@iz2zea13o0oyywo7z5hawlz ~]# vmstat
procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu-----
r b swpd free buff cache si so bi bo in cs us sy id wa st
2 0 0 109592 141200 1445092 0 0 0 3 12 22 0 0 99 0 0

上面的 cs(Context Swtich) 就是每秒上下文切换次数,过多的上下文切换次数,会把CPU时间消耗在寄存器、内核栈以及虚拟内存等数据保存和恢复上,从而缩短进程真正的运行时间,导致系统的整体性能大幅下降。

us、sy分别是用户态和内核态的CPU利用率。

vmstat给出的是系统总体的上下文切换情况,想要查看每个进程的上下文切换详情,需要使用pidstat命令,该命令还可以查看某个进程用户态和内核态的CPU利用率。

CPU相关指标异常的分析思路是什么呢?

1、CPU利用率: 观察某段时间系统或者应用进程的CPU利用率一直很高(单个超过80%),可以多次使用jstack命令dump应用线程栈查看热点线程代码。

2、CPU平均负载: 平均负载高与CPU数量70%,意味着系统存在瓶颈,通过监控系统检测平均负载的变化趋势,更容易定位问题,有时候加载大文件的时候,也会导致平均负载瞬时升高。如果1分钟/5分钟/15分钟的三个值相差不是很大,则说明系统负载很平稳,如果这三个值逐渐降低,说明负载在逐渐升高,需要重点关注。

3、CPU上下文切换次数: 这个指标取决于系统本身的CPU性能,以及当前应用的工作情况。如果系统或者应用的上下文切换次数出现数量级的增长的时候,则说明有很大概率存在性能问题,如果是非自愿的上下文切换次数大幅度上升,说明有太多的线程竞争CPU。

这三个指标是密切相关的,如频繁的CPU上下文切换,可能会导致平均负载升高。

关于线程,可关注的异常有:

  • 线程的总数是否过多。 线程过多,就会在CPU上频繁的进行上下文切换,同时线程过多也会消耗内存,线程的总数大小和应用本身和机器配置相关。
  • 线程的状态是否异常。 观察WAITING/BLOCKED 状态线程是否过多(线程数量设置过多或锁竞争剧烈),综合应用内部锁使用的情况进行进一步分析。
  • 结合CPU利用率,观察是否存在大量消耗CPU的线程。

2.3.4 内存和堆

和内存相关的指标主要有以下几个,常用命令是top、free、vmstat、pidstat以及JDK自带的一些工具。

  • 系统内存使用情况,包括剩余内存、已用内存、可用内存、缓存/缓冲区;
  • 进程(包含Java进程)的虚拟内存、常驻内存、共享内存;
  • 进程的缺页异常数、包含主缺页异常和次缺页异常;
  • Swap换入和换出的内存大小、Swap参数配置;
  • JVM堆的分配、JVM启动参数;
  • JVM堆的回收、GC情况。

使用free查看系统内存使用情况和Swap分区使用情况。

1
2
3
4
[root@iz2zea13o0oyywo7z5hawlz ~]# free -h
total used free shared buff/cache available
Mem: 1.8G 182M 106M 364K 1.5G 1.4G
Swap: 0B 0B 0B

Swap:把一个本地文件或者一块磁盘的空间作为内存来使用,包括换入和换出两个过程。Swap分区的升高一般和磁盘的使用强相关,具体分析,需要结合缓存的使用情况,swappiness阈值以及匿名页和文件页的活跃情况综合分析。

buff/cache:缓存和缓冲区大小。缓存(cache): 从磁盘读取的文件或者向磁盘写文件的临时存储数据,面向文件。使用cachestat可以查看真个系统缓存的读写命中情况,使用cachetop可以观察每个进程缓存的读写命中情况。 缓冲区(buff): 写入磁盘数据或者从磁盘直接读取的数据的临时存储,面向块设备。free 命令的输出中,这两个指标是加在一起的,使用 vmstat 命令可以区分缓存和缓冲区,还可以看到 Swap 分区换入和换出的内存大小。

性能优化中常见的内存问题有哪些呢?

1、系统剩余内存/可用内存不足(某个进程占用太多、系统本身内存不足),内存溢出;

2、内存回收异常:内存泄漏(进程在一段时间内内存使用持续走高),GC频率异常;

3、缓存使用过大(大文件读取或写入)、缓存命中率不高;

4、缺页异常过多(频繁的I/O读);

5、Swap分区使用异常(使用过大)。

内存相关指标异常后,分析的思路是怎么样的?

1、使用free发现缓存/缓冲区占用不大,排序缓存/缓冲区对内存的影响;

2、使用vmstat或者sar观察一下各个进程内存使用变化的趋势,发现某个进程的内存使用持续走高;

3、Java应用,使用jmap/VisualVM/heap dump等分析工具观察对象内存的分配,或者通过jstat观察GC后应用的内存变化;

4、结合业务场景,定位内存泄漏/GC参数配置不合理/业务代码异常等。

2.4 使用总结

有一些工具频繁出现,总结如下:

  • CPU:top、vmstat、pidstat、sar、perf、jstack、jstat;

  • 内存:top、free、vmstat、cachetop、cachestat、sar、jmap;

  • 磁盘:top、iostat、vmstat、pidstat、du/df;

  • 网络:netstat、sar、dstat、tcpdump;

  • 应用:profiler、dump。

三、 性能优化思路总结

如何优雅的学习JVM,综合篇(五)

一、重新认识JVM

在之前的我们画过一张图,是从class文件到类装载器,再到运行时数据区的过程。今天把这张图进行升级,画一张完整的JVM大体的物理结构图。

二、 GC优化

内存被使用了之后,难免会有不够用或者达到设定值的时候,就需要对内存空间进行垃圾回收。

2.1 垃圾收集发生的时间

GC由JVM自动完成的,根据JVM系统环境而定的,所以时机是不确定的。当然,我们也可以进行手动垃圾回收,比如调用System.gc()方法通知JVM进行一次垃圾回收,但是具体什么时刻运行也无法控制。也就是System.gc()只是通知要回收,什么时候回收由JVM自行决定。但是不建议手动进行垃圾回收,因为消耗的资源比较大。

一般以下几种情况会进行垃圾回收

  • 当Eden区或者S区不够用
  • 老年代空间不够用
  • 方法区看空间不够用
  • System.gc()

2.2 项目环境准备

此次案例我们使用SpringBoot来创建项目,然后配置对应的参数。

2.3 GC日志文件

要分析日志的信息,首先得拿到GC日志文件才行,所以得配置一下启动参数

1
-XX:+PrintGCDetails -XX:+PrintGCTimeStamps -XX:+PrintGCDateStamps -Xloggc:gc.log

然后启动项目

可以看到默认使用的是ParallelGC

1、ParallelGC日志

  • 吞吐量优先,日志分析
1
2020-02-13T16:01:45.974+0800: 3.592: [GC (Allocation Failure) [PSYoungGen: 65536K【Young区回收前】->8294K【Young区回收后】(76288K【Young区总大小】)] 65536K【整个堆回收前】->8310K【整个堆回收后】(251392K【整个堆总大小】), 0.0253276 secs] [Times: user=0.00 sys=0.00, real=0.03 secs]

如果回收的差值有出入,则说明这部分空间是Old区释放出来的

2、CMS GC日志

  • 停顿时间优先

参数设置:-XX:+UseConcMarkSweepGC -Xloggc:cms-gc.log

3、G1 GC日志

  • 停顿时间优先

参数设置:-XX:+UseG1GC -Xloggc:g1-gc.log

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
-XX:+UseG1GC  # 使用了G1垃圾收集器

#什么时候发生的GC,相对的时间刻,GC发生的区域young,总共花费的时间,0.00444s

2020-02-13T16:31:01.099+0800: 2.230: [GC pause (G1 Evacuation Pause) (young), 0.0044422 secs]

#多少个垃圾回收线程,并行的时间

[Parallel Time: 3.4 ms, GC Workers: 4]

#GC线程开始相对于上面的0.2230的时间刻
[GC Worker Start (ms): Min: 2230.2, Avg: 2230.2, Max: 2230.3, Diff: 0.1]

#每个工作线程扫描根的时间

[Ext Root Scanning (ms): Min: 0.5, Avg: 0.7, Max: 1.0, Diff: 0.5, Sum: 2.9]

#每个线程更新记忆集所花费的时间

[Update RS (ms): Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.0]

理解G1日志格式: https://blogs.oracle.com/poonam/understanding-g1-gc-logs

2.4 GC日志文件分析工具

1、gceasy

官网: https://gceasy.io/

可以比较不同的垃圾收集器的吞吐量停顿时间,比如打开cms-gc.log和g1-gc.log

2、GCViewer

2.5 G1调优与最佳指南

是否选用G1垃圾收集器的判断依据,官网: https://docs.oracle.com/javase/8/docs/technotes/guides/vm/G1.html#use_cases

  • 50%以上的堆被存活对象占用
  • 对象分配和晋升的速度变化非常大
  • 垃圾回收时间比较长

思考:使用G1 GC增加堆使用率
https://blogs.oracle.com/poonam/increased-heap-usage-with-g1-gc

1、使用G1GC垃圾收集器: -XX:+UseG1GC

修改配置参数,获取到gc日志,使用GCViewer分析吞吐量和响应时间

1
2
Throughput    Min Pause    Max Pause   Avg Pause    GC count
99.16%      0.00016s     0.0137s    0.00559s     12

2、调整内存大小再获取gc日志分析

1
2
3
-XX:MetaspaceSize=100M
-Xms300M
-Xmx300M

比如设置堆内存的大小,获取到gc日志,使用GCViewer分析吞吐量和响应时间

1
2
Throughput    Min Pause    Max Pause   Avg Pause    GC count
98.89%      0.00021s     0.01531s    0.00538s      12

3、调整最大停顿时间

-XX:MaxGCPauseMillis=20 设置最大GC停顿时间指标

比如设置最大停顿时间,获取到gc日志,使用GCViewer分析吞吐量和响应时间

1
2
3

Throughput    Min Pause    Max Pause   Avg Pause    GC count
98.96%      0.00015s     0.01737s    0.00574s     12

4、启动并发GC时堆内存占用百分比

-XX:InitiatingHeapOccupancyPercent=45

G1用它来触发并发GC周期,基于整个堆的使用率,而不只是某一代内存的
使用比例。值为 0 则表示“一直执行GC循环)’. 默认值为 45 (例如, 全部的 45% 或者使用了45%).

比如设置该百分比参数,获取到gc日志,使用GCViewer分析吞吐量和响应时间

1
2
Throughput    Min Pause    Max Pause   Avg Pause    GC count
98.11%      0.00406s     0.00532s    0.00469s     12

最佳指南

官网建议: https://docs.oracle.com/javase/8/docs/technotes/guides/vm/gctuning/g1_gc_tuning.html#recommendations

1、不要手动设置新生代和老年代的大小,只要设置整个堆的大小

G1收集器在运行过程中,会自己调整新生代和老年代大小,其实是通过adapt代的大小调整对象晋升的速度和年龄,从而到达为收集器设置的暂停时间目标,如果是手动设置了大小就意味着放弃了G1的自动调优

2、不断调优暂停时间目标

一般情况下这个时间值设置到100ms或者200ms都是可以的(不同情况会不一样),但是如果设置成50ms就太不合理了。暂停时间设置的太短,就会出现G1 GC跟不上垃圾生产的速度,最终退化成Full GC。所以对这个参数的调优是一个持续的过程,逐步调整到最佳状态。暂停时间只是一个目标,并不是总能得到满足。

3、使用-XX:ConcGCThreads=n来增加标记线程的数量

IHOP如果阀值设置过高,可能会遇到转移失败的风险,比如对象进行转移时空间不足。如果阀值设置过低,就会使标记周期运行过于频繁,并且有可能混合收集期回收不到空间。IHOP值如果设置合理,但是在并发周期时间过长时,可以尝试增加并发线程数,调高ConcGCThreads。

4、MixedGC调优

1
2
3
4
-XX:InitiatingHeapOccupancyPercent
-XX:G1MixedGCLiveThresholdPercent
-XX:G1MixedGCCountTarger
-XX:G1OldCSetRegionThresholdPercent

5、适当增加堆内存大小

三、高并发场景案例分析

四、JVM性能优化指南

五、常见问题的思考与解决

5.1 内存泄漏与内存溢出的区别

内存泄漏:对象无法得到及时的回收,持续占用内存空间,从而造成内存空间的浪费。

内存溢出:内存泄漏到一定的程度就会导致内存溢出,但是内存溢出也有可能是大对象导致的。

5.2 young gc会有stw吗?

不管什么 GC,都会有 stop-the-world,只是发生时间的长短。

5.3 major gc和full gc的区别

major gc指的是老年代的gc,而full gc等于young+old+metaspace的gc。

5.4 G1与CMS的区别是什么

CMS 用于老年代的回收,而 G1 用于新生代和老年代的回收。
G1 使用了 Region 方式对堆内存进行了划分,且基于标记整理算法实现,整体减少了垃圾碎片的产生。

5.5 什么是直接内存

直接内存是在java堆外的、直接向系统申请的内存空间。通常访问直接内存的速度会优于Java堆。因此出于性能的考
虑,读写频繁的场合可能会考虑使用直接内存。

5.6不可达的对象一定要被回收吗?

即使在可达性分析法中不可达的对象,也并非是“非死不可”的,这时候它们暂时处于“缓刑阶段”,要真正宣告一个对象死亡,至少要经历两次标记过程;可达性分析法中不可达的对象被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行 finalize 方法。当对象没有覆盖 finalize 方法,或 finalize方法已经被虚拟机调用过时,虚拟机将这两种情况视为没有必要执行。被判定为需要执行的对象将会被放在一个队列中进行第二次标记,除非这个对象与引用链上的任何一个对象建立关联,否则就会被真的回收。

5.7 方法区中的无用类回收

方法区主要回收的是无用的类,那么如何判断一个类是无用的类的呢?判定一个常量是否是“废弃常量”比较简单,而要判定一个类是否是“无用的类”的条件则相对苛刻许多。类需要同时满足下面 3 个条件才能算是 “无用的类” :

该类所有的实例都已经被回收,也就是 Java 堆中不存在该类的任何实例。

加载该类的 ClassLoader 已经被回收。

该类对应的 java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。虚拟机可以对满足上述 3 个条件的无用类进行回收,这里说的仅仅是“可以”,而并不是和对象一样不使用了就会必然被回收。

5.8 不同的引用

JDK1.2以后,Java对引用进行了扩充:强引用、软引用、弱引用和虚引用

如何优雅的学习JVM,实战篇(四)

一、JVM参数

1.1 标准参数

1
2
3
4
-version
-help
-server
-cp

1.2 -X参数

非标准参数,也就是在JDK各个版本中可能会变动

1
2
3
-Xint   解释执行
-Xcomp  第一次使用就编译成本地代码
-Xmixed  混合模式,JVM自己来决定

1.3 -XX参数

使用得最多的参数类型,非标准化参数,相对不稳定,主要用于JVM调优和Debug

1
2
3
4
5
6
7
a.Boolean类型
格式:-XX:[+-]<name>      +或-表示启用或者禁用name属性
比如:-XX:+UseConcMarkSweepGC  表示启用CMS类型的垃圾回收器
-XX:+UseG1GC       表示启用G1类型的垃圾回收器
b.非Boolean类型
格式:-XX<name>=<value> 表示name属性的值是value
比如:-XX:MaxGCPauseMillis=500

1.4 其它参数

1
2
3
-Xms1000等价于 -XX:InitialHeapSize=1000
-Xmx1000等价于 -XX:MaxHeapSize=1000
-Xss1000等价于 -XX:ThreadStackSize=1000

这一块内容也相当于-XX类型的参数

1.5 查看参数

java -XX:+PrintFlagsFinal -version > flags.txt

上图值得注意的是“=”表示默认值,“:=”表示被用户或JVM修改后的值

一般需要设置参数,可以先查下当前参数是什么,然后再进行修改

1.6 设置参数的方式

  • 开发工具中进行设置IDEA、eclipse

  • 运行jar包的时候:java -XX:+UseG1GC xxx.jar

  • Web容器比如Tomcat,可以在脚本中进行设置
  • 通过jinfo命令实时调整某个java进程的参数(参数只有被标记位manageable的flags可以被实时修改

1.7 单位换算和实践

1
2
3
4
5
1Byte(字节)=8bit(位)
1KB=1024Byte(字节)
1MB=1024KB
1GB=1024MB
1TB=1024GB

1、设置堆内存大小和参数打印

-Xmx100M -Xms100M -XX:+PrintFlagsFinal

2、查询+PrintFlagsFinal

:=true

3、查询堆内存大小MaxHeapSize

:= 104857600

4、换算

1
2
104857600(Byte)/1024=102400(KB)
102400(KB)/1024=100(MB)

5、结论

104857600是字节单位

1.8 常用参数的含义

二、常用命令

2.1 jps

查看java进程

2.2 jinfo

1、实时查看

jinfo -flag name PID 查看某个java进程的name属性的值

1
2
jinfo -flag MaxHeapSize PID
jinfo -flag UseG1GC PID

2、调整JVM配置参数

参数只有被标记为manageable的flags可以被实时修改

1
2
jinfo -flag [+|-] PID
jinfo -flag = PID

3、查看曾经赋过值的一些参数

jinfo -flags PID

2.3 jstat

1、查看虚拟机性能统计信息

2、查看java进程的类装载信息

jstat -class PID 1000 10 查看某个java进程的类装载信息,每1000毫秒输出一次,共输出10 次

3、查看垃圾收集信息

jstat -gc PID 1000 10

2.4 jstack

1、查看线程堆栈信息

jstack PID

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 class DeadLockDemo {

public static void main(String[] args) {
DeadLock deadLock1 = new DeadLock(true);
DeadLock deadLock2 = new DeadLock(false);
Thread thread1 = new Thread(deadLock1);
Thread thread2 = new Thread(deadLock2);
thread1.start();
thread2.start();
}

static class MyLock {
public static Object obj1 = new Object();
public static Object obj2 = new Object();
}

static class DeadLock implements Runnable {
private boolean flag;

DeadLock(boolean flag) {
this.flag = flag;
}

public void run() {
if(flag) {
while (true) {
synchronized (MyLock.obj1) {
System.out.println(Thread.currentThread().getName() + "如果获得obj1的锁");
synchronized (MyLock.obj2) {
System.out.println(Thread.currentThread().getName() + "如果获得obj2的锁");
}
}
}
}else {
while (true) {
synchronized (MyLock.obj2) {
System.out.println(Thread.currentThread().getName() + "否则获得obj2的锁");
synchronized (MyLock.obj1) {
System.out.println(Thread.currentThread().getName() + "否则获得obj1的锁");
}
}
}
}
}
}
}

运行main方法的结果

jstack分析

把打印信息拉到最后可以发现

2.5 jmap

1、生成堆转存储快照

2、打印出堆内存相关信息

1
2
-XX:+PrintFlagsFinal -Xms300M -Xmx300M
jmap -heap PID

3、dump堆内存相关信息

1
2
jmap -dump:format=b,file=heap.hprof PID
jmap -dump:format=b,file=heap.hprof 44808

4、要是能在发生堆内存溢出的时候,能自动dump出该文件就好了

一般在开发过程中,JVM参数可以加上下面两句,这样在程序内存溢出的时候,会自动dump该文件

-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=heap.hprof

三、常用工具

3.1 jconsole

JConsole工具是JDK自带的可视化监控工具。查看java应用程序的运行概况、监控堆信息、永久区使用
情况、类加载情况等。

命令行中输入:jconsole

3.2 jvisualvm

1、可以监控本地的java进程的CPU,类,线程等

2、可以监控远端tomcat,演示部署在阿里云服务器上的tomcat

(1)在visualvm中选中“远程”,右击“添加”
(2)主机名上写服务器的ip地址,比如39.105.32.236,然后点击“确定”
(3)右击该主机“39.105.32.236”,添加“JMX”[也就是通过JMX技术具体监控远端服务器哪个Java进程]
(4)要想让服务器上的tomcat被连接,需要改一下 bin/catalina.sh 这个文件

注意下面的8998不要和服务器上其他端口冲突

1
JAVA_OPTS="$JAVA_OPTS -Dcom.sun.management.jmxremote -Djava.rmi.server.hostname=39.105.32.236 -Dcom.sun.management.jmxremote.port=8998 -Dcom.sun.management.jmxremote.ssl=false -Dcom.sun.management.jmxremote.authenticate=true -Dcom.sun.management.jmxremote.access.file=../conf/jmxremote.access -Dcom.sun.management.jmxremote.password.file=../conf/jmxremote.password"

(5)在 ../conf 文件中添加两个文件jmxremote.access和jmxremote.password

jmxremote.access 文件

1
2
guest readonly
manager readwrite

jmxremote.password 文件

1
2
guest guest
manager manager

授予权限 : chmod 600 jmxremot

(6)将连接服务器地址改为公网ip地址

hostname -i 查看输出情况
172.17.6.246 172.17.0.1
vim /etc/hosts
172.17.6.246 339.105.32.236

(7)设置上述端口对应的阿里云安全策略和防火墙策略

1
2
3
firewall-cmd --add-port=8080/tcp --permanent
firewall-cmd --add-port=8998/tcp --permanent
systemctl restart firewalld

(8)启动tomcat,来到bin目录

./startup.sh

(9)查看tomcat启动日志以及端口监听

1
2
tail -f ../logs/catalina.out
lsof -i tcp:8080

(10)查看8998监听情况,可以发现多开了几个端口

1
2
lsof -i:8998  得到PID
netstat -antup | grep PID

(11)在刚才的JMX中输入8998端口,并且输入用户名和密码则登录成功

1
2
3
端口:8998
用户名:manager
密码:manager

3.3 阿里的Arthas

Arthas是阿里巴巴开源的Java诊断工具,采用命令行交互模式,是排查jvm相关问题的利器。

github: https://github.com/alibaba/arthas

下载arthas-boot.jar,然后用java -jar的方式启动:

1
2
curl -O https://alibaba.github.io/arthas/arthas-boot.jar
java -jar arthas-boot.jar

打印帮助信息:

java -jar arthas-boot.jar -h

常用命令

具体每个命令怎么使用,大家可以自己查阅资料!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
version:查看arthas版本号
help:查看命名帮助信息
cls:清空屏幕
session:查看当前会话信息
quit:退出arthas客户端
---
dashboard:当前进程的实时数据面板
thread:当前JVM的线程堆栈信息
jvm:查看当前JVM的信息
sysprop:查看JVM的系统属性
---
sc:查看JVM已经加载的类信息
dump:dump已经加载类的byte code到特定目录
jad:反编译指定已加载类的源码
---
monitor:方法执行监控
watch:方法执行数据观测
trace:方法内部调用路径,并输出方法路径上的每个节点上耗时
stack:输出当前方法被调用的调用路径
......

3.4 MAT

Java堆分析器,用于查找内存泄漏

Heap Dump,称为堆转储文件,是Java进程在某个时间内的快照

下载地址: https://www.eclipse.org/mat/downloads.php

1、Dump信息包含的内容

  • All Objects

Class, fields, primitive values and references

  • All Classes

Classloader, name, super class, static fields

  • Garbage Collection Roots

Objects defined to be reachable by the JVM

  • Thread Stacks and Local Variables

The call-stacks of threads at the moment of the snapshot, and per-frame information about local
objects

2、获取Dump文件

  • 手动

jmap -dump:format=b,file=heap.hprof 44808

自动

-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=heap.hprof

3、使用

  • Histogram

Histogram可以列出内存中的对象,对象的个数及其大小

1
2
3
4
Class Name:类名称,java类名
Objects:类的对象的数量,这个对象被创建了多少个
Shallow Heap:一个对象内存的消耗大小,不包含对其他对象的引用
Retained Heap:是shallow Heap的总和,即该对象被GC之后所能回收到内存的总和

右击类名--->List Objects--->with incoming references--->列出该类的实例

右击Java对象名--->Merge Shortest Paths to GC Roots--->exclude all ...--->找到GC Root以及原因

  • Leak Suspects

查找并分析内存泄漏的可能原因

Reports--->Leak Suspects--->Details

  • Top Consumers

列出大对象

3.5 GC日志分析工具

要想分析日志的信息,得先拿到GC日志文件才行,所以得先配置一下

根据前面参数的学习,下面的配置很容易看懂

1
2
-XX:+PrintGCDetails -XX:+PrintGCTimeStamps -XX:+PrintGCDateStamps
-Xloggc:gc.log
  • 在线工具

https://gceasy.io/

  • GCViewer

如何优雅的学习JVM,升华篇(三)

此次内容和大家分享JVM中关于垃圾回收(Garbage Collect)的相关知识。

一、如何确定一个对象是垃圾?

要想进行垃圾回收,得先知道什么样的对象是垃圾。

1.1 引用计数法

对于某个对象而言,只要应用程序中持有该对象的引用,就说明该对象不是垃圾,如果一个对象没有任何指针对其引用,它就是垃圾。

注意: 如果A、B两个对象互相持有引用,会导致永远不能被回收。

1.2 可达性分析

通过GC Root的对象,可以开始向下寻找,看某个对象是否可达。
可以作为GC Root:类加载器、Thread、虚拟机栈的本地变量表、静态成员、常量引用、本地方法栈的变量等。

二、垃圾回收算法

2.1 标记-清除(Mark-Sweep)

标记(Mark)

找出内存中需要回收的对象,并把它们标记出来。此时堆中所有的对象都会被扫描一遍,从而才能确定需要回收的对象,比较耗时。

清除(Sweep)

清除掉被标记需要回收的对象,释放出对应的内存空间。

不足

1、标记和清除两个过程都比较耗时,效率不高

2、会产生大量的不连续的内存碎片,空间碎片太多可能会导致在以后程序运行的过程中需要分配较大的对象时,无法找到足够的连续内存而不得不提前触发一次垃圾回收动作。

2.2 复制(Copying)

将内存划分为两块相等的区域,每次只使用其中一块,如下图

当其中一块内存使用完了,就将还存活的对象复制到另一块上面,然后把已经使用过的内存空间一次清除掉。

不足

空间利用率降低。

2.3 标记-整理(Mark-Compact)

标记的过程仍然与“标记-清除”的算法一样,但是后续的步骤不是直接对回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。

让所有存活的对象都向一端移动,清掉边界外的内存。

三、分代收集算法

上面介绍了三种垃圾回收算法,那么在堆内存中到底用哪一种呢?

1.Yonug区:复制算法(对象被分配之后,可能生命周期比较短,此区的复制效率比较高)

2、Old区:标记清除或标记整理(Old区对象存活时间比较长,复制来复制去没必要,不如做个标记再清理)

四、 垃圾收集器

4.1 Serial收集器

在JDK1.3.1之前,曾经是虚拟机新生代收集的唯一选择,它是最基本、发展历史最悠久的收集器。

它是一种单线程收集器,不仅仅意味着它只会使用一个CPU或者一条收集线程去完成垃圾收集工作,更重要的是其在进行收集的同时需要暂停其它线程。

优点:简单高效,拥有很高的单线程收集效率

缺点:收集过程中需要暂停其它所有的线程

算法:复制算法

使用范围:新生代

应用:client模式下的默认新生代收集器

4.2 ParNew收集器

可以把ParNew收集器当作多线程版本的Serial收集器。

优点:在多CPU下,比Serial收集器效率高

缺点:收集过程中需要暂停其它所有的线程,单CPU时比Serial效率差

算法:复制算法

使用范围:新生代

应用:运行在server模式下的虚拟机首选新生代收集器

4.3 Parallel Scavenge收集器

它是一个新生代收集器,使用复制算法,又是并行多线程收集器,看上去和ParNew一样,但是更多关注的是系统的吞吐量

吞吐量 = 运行用户程序的时间 / (运行用户程序时间 + 垃圾收集时间)

例如:虚拟机总共运行了100m,垃圾收集用了1m,吞吐量 =(100 - 1) / 100 = 99%

吞吐量越大,则垃圾收集时间越短,则用户程序可以充分利用CPU资源,尽快完成程序的运算任务。

1
2
-XX:MaxGCPauseMillis 控制最大的垃圾收集停顿时间,
-XX:GC Time Ratio 直接设置吞吐量的大小。

4.4 Serial Old收集器

它是Serial收集器的老年代版本,也是一个单线程收集器,不同的是采用“标记-整理”的算法,运行过程和Serial收集器一样。

4.5 Parallel Old收集器

Parallel Old收集器是Parallel Scavenge收集器的老年代版本,使用“标记-整理”的算法进行垃圾回收。吞吐量优先。

4.6 CMS(Concurrent Mark Sweep)收集器

CMS收集器是一种以获取最短回收停顿时间为目标的收集器。采用“标记-清除”算法,整个过程分为4步:

1、初始标记(CMS initial mark),标记GC Roots能关联到的对象,Stop The World–>速度很快

2、并发标记(CMS ConCurrent mark),进行GC Roots Tracing

3、重新标记(CMS remark),修改并发标记因用户程序变动的内容,Stop The World

4、并发清除(CMS Concurrent Sweep)

由于整个过程,并发标记和并发清除,收集器线程可以和用户线程一起工作,所以总体上来说,CMS收集器的内存回收过程是和用户线程一起并发执行的


优点:并发收集、低停顿

缺点:产生大量空间碎片,并发阶段会降低吞吐量

4.7 G1收集器

使用G1收集器时,Java堆的内存布局与其它收集器的差别很大,它将整个Java堆划分为多个大小相等的独立区域(Region),虽然还保留有新生代和老年代的概念,但新生代和老年代不再是物理隔离了,它们都是一部分Region(不需要连续)的集合。整体上属于“标记-整理”算法,不会到处连续的空间碎片,可预测的停顿(比CMS更先进的地方在于能够让使用者明确指定一个长度为m毫秒的时间片段内,消耗在垃圾收集上的时间不得超过m毫秒)。整个过程分为4部分:

1、初始标记(Initial Marking),标记一下GC Roots能够关联的对象,并且修改TAMS(top at mark start)的值,需要暂停用户线程

2、并发标记(Concurrent Marking),从GC Roots进行可达性分析,找出存活的对象,与用户线程并发执行

3、最终标记(Final Marking),修正在并发标记阶段因为用户程序的并发执行导致变动的数据,需暂停用户线程

4、筛选回收(Live Data Counting And Evacuation),对各个Region的回收价值和成本进行排序,根据用户所期望的GC停顿时间制定回收计划。

4.8 垃圾收集器分类

  • 串行收集器

    Serial、Old Serial

    只有一个垃圾回收线程执行,用户线程暂停。

    适用于内存比较小的嵌入式设备。

  • 并行收集

    Parallel Scanvenge、Parallel Old

    多条垃圾收集器线程并行工作,但此时用户线程仍然处于等待状态。

    适用于科学计算、后台处理等若干交互场景。

    吞吐量优先

  • 并发收集器

    CMS、G1

    用户线程和垃圾收集器线程同时执行(但不一定是并行,可能是交替执行),垃圾收集线程在执行的时候不会停顿用户线程的运行。

    适用于相对时间有要求的场景,比如Web

    停顿时间优先

4.9 如何选择合适的垃圾收集器

  • 优先调整堆的大小让服务器自己来选择
  • 如果内存小于100M,使用串行收集器
  • 如果是CPU单核,并且没有停顿时间的要求,使用串行或者JVM自行选择
  • 如果允许停顿时间超过1S,选择并行或者JVM自行选择
  • 如果响应时间最重要,并且不能超过1S,使用并发收集器。
  • 来看看官网如何给出答案的: https://docs.oracle.com/javase/8/docs/technotes/guides/vm/gctuning/collectors.html#sthref28

4.10 如何开启垃圾收集器

1
2
3
4
5
6
7
8
9
(1)串行
-XX:+UseSerialGC
-XX:+UseSerialOldGC
(2)并行(吞吐量优先):
 -XX:+UseParallelGC
 -XX:+UseParallelOldGC
(3)并发收集器(响应时间优先)
-XX:+UseConcMarkSweepGC
-XX:+UseG1GC

4.11 如何理解吞吐量和停顿时间

  • 吞吐量

    运行用户代码时间/(运行用户代码时间+垃圾收集时间)

    高吞吐量则可以高效地利用CPU时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任
    务。

  • 停顿时间

    垃圾收集器进行垃圾回收终端应用执行响应的时间

    停顿时间越短就越适合需要和用户交互的程序,良好的响应速度能提升用户体验。

4.12 在什么情况下使用G1?

JDK 7开始使用,JDK 8非常成熟,JDK 9默认的垃圾收集器,适用于新老生代

判断是否需要使用G1收集器?

  • 50%以上的堆被存活对象占用
  • 对象分配和晋升的速度变化非常大
  • 垃圾回收时间比较长

未完待续……

如何优雅的学习JVM,进行篇(二)

一、理解Java虚拟机栈和栈帧

1.1 什么是栈帧呢?

每个栈帧被认为对应是一个被调用的方法,可以理解为一个方法的运行空间。

官方地址https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-2.html#jvms-2.6

栈帧的组成部分:

1、局部变量表(Local Variables):方法中的局部变量以及方法的参数存放在这张表中,局部变量中的变量不可以直接使用,如果需要使用的话,必须通过相关指令将其加载到操作数栈中作为操作数使用。

2、操作数栈(Operand Stack):以压栈出栈的方式存储操作数。

3、动态链接(Dynamic Linking):每个栈帧都包含指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态链接。

4、方法返回地址(Return Address):当一个方法执行时,只有两种方式可以退出,一种是遇到方法返回的字节码指令,一种是遇见异常,并且这个异常没有在方法体内得到处理。

5、附加信息

Person.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class Person {
private String name = "Kevin";
private int age;
private final double salary = 100;
private static String address;
private final static String hobby = "Programming";
public void say() {
System.out.println("person say...");
}
public static int calc(int op1,int op2) {
op1 = 3;
int result = op1 + op2;
return result;
}
public static void order() {

}
public static void main(String[] args) {
calc(1,2);
order();
}
}

反编译指令宝典,oracle官网:

https://docs.oracle.com/javase/specs/jvms/se8/html/index.html

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
Compiled from "Person.java"
public class Person {
...     
 public static int calc(int, int);
  Code:
   0: iconst_3   //将int类型常量3压入[操作数栈]
   1: istore_0   //将int类型值存入[局部变量0]
   2: iload_0    //从[局部变量0]中装载int类型值入栈
   3: iload_1    //从[局部变量1]中装载int类型值入栈
   4: iadd     //将栈顶元素弹出栈,执行int类型的加法,结果入栈
   【For example, the iadd instruction (§iadd) adds two int values together. It
requires that the int values to be added be the top two values of the operand stack, pushed
there by previous instructions. Both of the int values are popped from the operand stack.
They are added, and their sum is pushed back onto the operand stack. Subcomputations may be
nested on the operand stack, resulting in values that can be used by the encompassing
computation.】
   5: istore_2   //将栈顶int类型值保存到[局部变量2]中
   6: iload_2    //从[局部变量2]中装载int类型值入栈
   7: ireturn    //从方法中返回int类型的数据
...
}

例子中的Java虚拟机和栈帧,如图:

1.2 栈指向堆

如果在栈帧中有一个变量,类型为引用类型,比如Object obj = new Object(),这个时候就是典型的栈中的元素指向堆中的对象

1.3 方法区指向堆

方法区会存放静态变量,常量等数据。如果是下面这种情况,就是典型的方法区中的元素指向堆中的对象

1
private static Object obj = new Object();

1.4 堆指向方法区

方法区中会包含类的信息,堆中会有对象,那么一个对象是由哪个类创建出来的呢?它是如何记录信息的呢?我们需要了解一个Java对象的具体信息。

1.5 Java对象内存布局

一个Java对象在内存中的布局分为3个部分:

1、对象头:一系列的标记位(Mark Word)、指向对象对应的类元数据的内存地址(Class Pointer)、数组长度(Length)

2、实例数据:包含各对象所有的成员变量,其大小由变量类型决定

3、对齐填充:为了对象的大小为8字节的整数倍

二、JVM内存模型

2.1 内存模型结构

内存模型结构分为2个部分:

1、非堆区:

2、堆区:一个是Old区,另一个是Young区

另:Young区分为2个部分,一个是Survivor区(S0+S1),另一个是Eden区。

Eden : S0 : S1 = 8 : 1 : 1,S0和S1一样大,也可以叫做From和To。

2.2 对象创建所在区域

在一般情况下:

1、新创建的对象都会分配到Eden区

2、一些特殊的大对象都会分配到Old区

例如:有对象A、B、C等创建在Eden区,但是由于Eden区的内存空间有限,其大小只有100M,假如意见使用了100M或达到了一个设定的临界值,这个时候就需要对Eden区的内存空间进行清理,即垃圾回收(Garbage Collect),这样的GC也被称为Minor GC,
Minor GC指的是Young区的GC

2.3 Survivor区

Survivor分为两块,即S0和S1,也可以叫做From和To。

在同一个时间点上,S0和S1只能有一区有数据,另一区只能空着。

例如:接着上面示例的GC来说,一开始只有Eden区和S0中有对象,S1是空的。此时进行一次GC操作,S0区中的对象年龄就会+1,Eden区中的所有存活的对象会被复制到S1区,S0区中还能存活的对象会有两个去处。

若对象年龄达到之前设置好的年龄阈值,此时对象会被移动到Old区,没有达到阈值的对象会被复制到S1区。此时Eden区和S0区已经被清空(被GC的对象肯定是没有了,没有被GC的对象都有各自的去处了)。

这个时候S0和S1交换角色,之前的S0变成S1,之前的S1变成S0了。也就是说无论如何都要保证名为S1的Survivor区域都是空的

Minor GC会一直重复这样的过程,直到S1区被填满,然后会将所有的对象复制到Old区中。

2.4 Old区

一般Old区都是年龄较大的对象,或者相对超过了某个阈值的对象。在Old区也会有GC操作,Old区的GC操作我们被称为Major GC,每次GC之后还能存活的对象年龄也会+1,如果超过了某个阈值,也会被回收的。

2.5 如何理解对象的一辈子

我是一个普通的Java对象,我出生在Eden区,在Eden区我还看到了跟我长得很像的兄弟,我们在Eden区玩了挺长时间。有一天Eden区的人实在太多了,我就被迫去了Survivor区的S0区,自从去了Survivor区,我就开始漂了,有时候在Survivor区的S0区,有时候在Survivor区的S1区,居无定所。直到我18岁的时候,爸爸说我成人了,该去社会上闯荡了。

于是我就去Old区了,老年代里,人很多,并且年龄都挺大的,我在这里认识了很多人。在Old区里我生活了20年(每次GC加一岁),最后就被回收了。

2.6 常见的问题

1、如何理解Minor/Major/Full GC?

Minor GC:新生代

Major GC:老年代

Full GC:新生代+老年代

2、为什么需要Survivor区?只有Eden区不行吗?

如果没有Survivor,Eden区每次进行一次Minor GC,存活的对象就会被送到老年代。这样一来,老年代很快被填满,触发Major GC(因为Major GC一直伴随Minor GC,也可以看做触发了Full GC)。

老年代的空间远远大于新生代,进行一次Full GC消耗的时间比Minor GC长得多。执行时间长有什么坏处呢?频繁的Full GC消耗的时间很长,会影响程序的执行和响应速度

如果对老年代的空间进行增加或者减少呢,能够解决以上问题吗?

假如增加老年代的空间,更多的存活对象才能填满老年代。虽然降低了Full GC的频率,但是随着老年代的空间加大,一旦发生Full GC,执行所需要的时间更长。
假如减少老年代的空间,虽然Full GC的时间减少,但是老年代很快被存活的对象填满,Full GC的频率增加。

所以Survivor存在的意义,就是减少被送到老年代的对象,进而减少Full GC的发生,Survivor的预筛选保证,只有经历16次Minor GC还能在新生代中存活的对象,才会被送到老年代。

3、为什么需要两个Survivor区?

最大的好处就是解决了碎片化。如果只有一个Survivor区,在刚刚新建的对象在Eden中,一单Eden满了,触发一次Minor GC,Eden中的存活对象就会被移动到Survivor区中,这样循环下去,下一次Eden满了的时候,那么这样问题就来了,此时进行Minor GC,Eden和Survivor各有一些存活的对象,如果此时把Eden区中存活的对象硬是放到Survivor区,很明显这两部分对象所占的内存是不连续的,也就导致了内存碎片化。永远只有一个Survivor 空间是空的,而另一个Survivor 空间无碎片。

4、新生代中的Eden:S0:S1为什么是8:1:1?

新生代中的可用内存:复制算法用来担保的内存为9:1
可用内存中的Eden:S0为8:1
即新生代中的Eden:S0:S1位8:1:1

三、使用与验证

3.1 使用VisualVM

使用JDK自带的VisualVM工具进行查看:

3.2 堆内存溢出

示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14

import java.util.List;

public class HeapOut {

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

List<Person> personList = new ArrayList<>();
while (true) {
personList.add(new Person());
Thread.sleep(1);
}
}
}

设置启动参数:-Xmx20M -Xms20M,我们启动main方法后,让程序持续运行一段时间可以看到控制台出现如下信息:

1
2
3
4
5
6
7
8
 com.gooagoo.dop.trans.test.json.HeapOut
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
at java.util.Arrays.copyOf(Arrays.java:3181)
at java.util.ArrayList.grow(ArrayList.java:261)
at java.util.ArrayList.ensureExplicitCapacity(ArrayList.java:235)
at java.util.ArrayList.ensureCapacityInternal(ArrayList.java:227)
at java.util.ArrayList.add(ArrayList.java:458)
at com.gooagoo.dop.trans.test.json.HeapOut.main(HeapOut.java:12)

使用visualVM查看:

3.3 方法区内存溢出

示例:向方法区中添加class信息

asm依赖

1
2
3
4
5
<dependency>
  <groupId>asm</groupId>
  <artifactId>asm</artifactId>
  <version>3.3.1</version>
</dependency>

代码

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
package com.gooagoo.dop.trans.test.json;
import org.objectweb.asm.ClassWriter;
import org.objectweb.asm.MethodVisitor;
import org.objectweb.asm.Opcodes;
import java.util.ArrayList;
import java.util.List;
public class MyMetaspace extends ClassLoader {
public static List<Class<?>> createClasses() {
List<Class<?>> classes = new ArrayList<Class<?>>();
for (int i = 0; i < 10000000; ++i) {
ClassWriter cw = new ClassWriter(0);
cw.visit(Opcodes.V1_1, Opcodes.ACC_PUBLIC, "Class" + i, null, "java/lang/Object", null);
MethodVisitor mw = cw.visitMethod(Opcodes.ACC_PUBLIC, "<init>", "()V", null, null);
mw.visitVarInsn(Opcodes.ALOAD, 0);
mw.visitMethodInsn(Opcodes.INVOKESPECIAL, "java/lang/Object", "<init>", "()V");
mw.visitInsn(Opcodes.RETURN);
mw.visitMaxs(1, 1);
mw.visitEnd();
MyMetaspace test = new MyMetaspace();
byte[] code = cw.toByteArray();
Class<?> exampleClass = test.defineClass("Class" + i, code, 0, code.length);
classes.add(exampleClass);
}
return classes;
}


public static void main(String[] args) throws Exception {
List<Class<?>> list=new ArrayList<Class<?>>();
while(true){
list.addAll(MyMetaspace.createClasses());
Thread.sleep(5);
}
}
}

设置启动参数Metaspace的大小,比如-XX:MetaspaceSize=50M -XX:MaxMetaspaceSize=50M,我们启动main方法后,让程序持续运行一段时间可以看到控制台出现如下信息:

1
2
3
4
5
6
Exception in thread "main" java.lang.OutOfMemoryError: Metaspace
at java.lang.ClassLoader.defineClass1(Native Method)
at java.lang.ClassLoader.defineClass(ClassLoader.java:763)
at java.lang.ClassLoader.defineClass(ClassLoader.java:642)
at com.gooagoo.dop.trans.test.json.MyMetaspace.createClasses(MyMetaspace.java:21)
at com.gooagoo.dop.trans.test.json.MyMetaspace.main(MyMetaspace.java:30)

3.4 虚拟机栈StackOvewFlow

代码示例:

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

public static long count = 0;
public static void method(long i) {
System.out.println(count++);
method(i);
}

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

启动main方法后,让程序持续运行一段时间可以看到控制台出现如下信息:

说明:

Stack Space用来做方法的递归调用时压入Stack Frame(栈帧)。所以当递归调用太深的时候,就有可能耗尽Stack Space,所以出现StackOverFlow的错误。

线程栈的大小是个双刃剑,如果设置过小,可能会出现溢出,特别是在该线程递归、大的循环时出现溢出的可能性更大;如果设置过大,就有影响到创建栈的数量,如果是多线程应用,就会出现内存溢出的错误。

-Xss128k:设置每个线程的堆栈大小。JDK 5以后每个线程堆栈大小为1M,以前每个线程堆栈大小为256K。根据应用的线
程所需内存大小进行调整。在相同物理内存下,减小这个值能生成更多的线程。但是操作系统对一个进程内的线程数还是有
限制的,不能无限生成,经验值在3000~5000左右。

未完待续……

如何优雅的学习JVM,揭开篇(一)

尽管目前oracle官方给出JDK的最新版本已经到达V13了,经过调查得知还是有很多公司使用的是V8,本次学习JVM中所有的内容针对的V8版本。

一、官网(权威文档)

链接地址:https://docs.oracle.com/javase/8/

如何看待JDK/JRE/JVM之间的关系,官方给出下面的图应该很容易看明白了

Oracle有两个产品实现了Java平台标准版(Java SE)8:JavaSE开发工具包(JDK)8和JavaSE运行时环境(JRE)8。

JDK 8是jre8的超集,包含jre8中的所有内容,另外开发applet和应用。JRE8提供了库、Java虚拟机(JVM)和运行用Java编程编写的小程序和应用程序的其他组件语言。注意,JRE包含Java SE不需要的组件规范,包括标准和非标准Java组件。

二、源码到类文件

2.1 源代码

1
2
3
4
5
6
7
8
9
10
11
public class Student{
private String name;
private int age;
private static String sex;
public void say(){
System.out.println("student say...");
}
public int calcStudents(int op1,int op2){
return op1 + op2;
}
}

编译: javac Student.java —> Student.class

2.2 编译过程

Student.java -> 词法分析器 -> tokens流 -> 语法分析器 -> 语法树/抽象语法树 -> 语义分析器-> 注解抽象语法树 -> 字节码生成器 -> Student.class文件

2.3 类文件

The class File Format,官方地址:

https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-4.html

1
2
3
4
5
6
7
8
9
cafe babe 0000 0031 0028 0a00 0600 1a09
001b 001c 0800 1d0a 001e 001f 0700 2007
0021 0100 046e 616d 6501 0012 4c6a 6176
612f 6c61 6e67 2f53 7472 696e 673b 0100
0361 6765 0100 0149 0100 0373 6578 0100
063c 696e 6974 3e01 0003 2829 5601 0004
436f 6465 0100 0f4c 696e 654e 756d 6265
7254 6162 6c65 0100 124c 6f63 616c 5661
......

magic(魔数)

The magic item supplies the magic number identifying the class file format; it has the value 0xCAFEBABE.

1
cafe babe

minor_version, major_version

1
0000 0031

31对应10进制的49,代表JDK 8中的一个版本

constant_pool_count

1
0028

对应十进制28,代表常量池中28个常量

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
ClassFile {
u4 magic;
u2 minor_version;
u2 major_version;
u2 constant_pool_count;
cp_info constant_pool[constant_pool_count-1];
u2 access_flags;
u2 this_class;
u2 super_class;
u2 interfaces_count;
u2 interfaces[interfaces_count];
u2 fields_count;
field_info fields[fields_count];
u2 methods_count;
method_info methods[methods_count];
u2 attributes_count;
attribute_info attributes[attributes_count];
}

.class字节码文件

1
2
3
4
5
6
7
魔数与class文件版本
常量池
访问标志
类索引、父类索引、接口索引
字段表集合
方法表集合
属性表集合

三、类文件到虚拟机(类加载机制)

3.1 图解

上图中的使用和卸载不属于类加载的过程阶段,为了表现完整,所以全画上去了。

3.2 装载(Load)

查找和导入class文件

  • (1)通过一个类的全限定名获取定义此类的二进制字节流
  • (2)将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
  • (3)在Java堆中生成一个代表这个类的java.lang.Class对象,作为对方法区中这些数据的访问入口

在装载(Load)阶段,其中第(1)步:通过类的全限定名获取其定义的二进制字节流,需要借助类装载器完成,顾名思义,就是用来装载Class文件的。

(1)通过一个类的全限定名获取定义此类的二进制字节流

在装载阶段的第(2),(3)步可以发现有运行时数据,堆,方法区等名词

(2)将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构

(3)在Java堆中生成一个代表这个类的java.lang.Class对象,作为对方法区中这些数据的访问入口
说白了就是类文件被类装载器装载进来之后,类中的内容(比如变量,常量,方法,对象等这些数
据得要有个去处,也就是要存储起来,存储的位置肯定是在JVM中有对应的空间)

3.3 链接(Link)

3.3.1 验证(Verify)

  • 保证被加载类的正确性
  • 文件格式验证
  • 元数据验证
  • 字节码验证
  • 符号引用验证

3.3.2 准备(Prepare)

为类的静态变量分配内存,并将其初始化为默认值

3.3.3 解析(Resolve)

把类中的符号引用转换为直接引用

3.4 初始化(Initialize)

对类的静态变量,静态代码块执行初始化操作

四、类装载器(ClassLoader)

4.1 图解

4.2 分类解释

  • (1)Bootstrap ClassLoader 负责加载$JAVA_HOME中 jre/lib/rt.jar 里所有的class或Xbootclassoath选项指定的jar包。由C++实现,不是ClassLoader子类。
  • (2)Extension ClassLoader 负责加载java平台中扩展功能的一些jar包,包括$JAVA_HOME中jre/lib/*.jar 或 -Djava.ext.dirs指定目录下的jar包。
  • (3)App ClassLoader 负责加载classpath中指定的jar包及Djava.class.path 所指定目录下的类和jar包。
  • (4)Custom ClassLoader 通过java.lang.ClassLoader的子类自定义加载class,属于应用程序根据自身需要自定义的ClassLoader,如tomcat、jboss都会根据j2ee规范自行实现ClassLoader。

4.3 加载原则

检查某个类是否已经加载:顺序是自底向上,从Custom ClassLoader到BootStrap ClassLoader逐层检查,只要某个Classloader已加载,就视为已加载此类,保证此类只所有ClassLoader加载一次。
加载的顺序:加载的顺序是自顶向下,也就是由上层来逐层尝试加载此类。

双亲委派机制

定义:如果一个类加载器在接到加载类的请求时,它首先不会自己尝试去加载这个类,而是把这个请求任务委托给父类加载器去完成,依次递归,如果父类加载器可以完成类加载任务,就成功返回;只有父类加载器无法完成此加载任务时,才自己去加载。

优势:Java类随着加载它的类加载器一起具备了一种带有优先级的层次关系。比如,Java中的Object类,它存放在rt.jar之中,无论哪一个类加载器要加载这个类,最终都是委派给处于模型最顶端的启动类加载器进行加载,因此Object在各种类加载环境中都是同一个类。如果不采用双亲委派模型,那么由各个类加载器自己取加载的话,那么系统中会存在多种不同的Object类。

破坏:可以继承ClassLoader类,然后重写其中的loadClass方法,其他方式大家可以自己了解拓展一下。

五、运行时数据区(Run-Time Data Areas)

官方地址:

https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-2.html#jvms-2.5

Java虚拟机定义了在程序执行期间使用的各种运行时数据区域。这些数据区域中的一些是在Java虚拟机启动时创建的,只有在Java虚拟机退出时才被销毁。其他数据区域是每个线程。当线程在线程退出时创建和销毁时,每个线程数据区域都会创建。

5.1 图解

5.2 常规理解

5.2.1 Method Area(方法区)

方法区是各个线程共享的内存区域,在虚拟机启动时创建。
用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。

虽然Java虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却又一个别名叫做Non-Heap(非堆),目
的是与Java堆区分开来。
当方法区无法满足内存分配需求时,将抛出OutOfMemoryError异常。

1
2
3
4
5
The Java Virtual Machine defines various run-time data areas that are used
during execution of a program. Some of these data areas are created on Java
Virtual Machine start-up and are destroyed only when the Java Virtual Machine
exits. Other data areas are per thread. Per-thread data areas are created when a
thread is created and destroyed when the thread exits.

此时回看装载阶段的第2步:

(2)将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构

如果这时候把从Class文件到装载的第(1)和(2)步合并起来理解的话,可以画个图

  • (1)方法区在JDK 8中就是Metaspace,在JDK6或7中就是Perm Space
  • (2)Run-Time Constant Pool

Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息就是常量池,用于存放编译时期生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放。

5.2.2 Heap(堆)

Java堆是Java虚拟机所管理内存中最大的一块,在虚拟机启动时创建,被所有线程共享。

Java对象实例以及数组都在堆上分配。

1
2
3
The Java Virtual Machine has a heap that is shared among all Java Virtual Machine threads. The heap is the run-time data area from which memory for all class instances and arrays is allocated.

The heap is created on virtual machine start-up.

此时回看装载阶段的第3步:
(3)在Java堆中生成一个代表这个类的java.lang.Class对象,作为对方法区中这些数据的访问入口
此时装载(1)(2)(3)的图可以改动一下

5.2.3 Java Virtual Machine Stacks(虚拟机栈)

经过上面的分析,类加载机制的装载过程已经完成,后续的链接,初始化也会相应的生效。

假如目前的阶段是初始化完成了,后续做啥呢?肯定是Use使用咯,不用的话这样折腾来折腾去
有什么意义?那怎样才能被使用到?换句话说里面内容怎样才能被执行?比如通过主函数main调
用其他方法,这种方式实际上是main线程执行之后调用的方法,即要想使用里面的各种内容,得
要以线程为单位,执行相应的方法才行。

那一个线程执行的状态如何维护?一个线程可以执行多少个方法?这样的关系怎么维护呢?

虚拟机栈是一个线程执行的区域,保存着一个线程中方法的调用状态。换句话说,一个Java线程的运行
状态,由一个虚拟机栈来保存,所以虚拟机栈肯定是线程私有的,独有的,随着线程的创建而创建。

每一个被线程执行的方法,为该栈中的栈帧,即每个方法对应一个栈帧。

调用一个方法,就会向栈中压入一个栈帧;一个方法调用完成,就会把该栈帧从栈中弹出。

画图理解栈和栈帧

5.2.4 The pc Register(程序计数器)

我们都知道一个JVM进程中有多个线程在执行,而线程中的内容是否能够拥有执行权,是根据
CPU调度来的。

假如线程A正在执行到某个地方,突然失去了CPU的执行权,切换到线程B了,然后当线程A再获
得CPU执行权的时候,怎么能继续执行呢?这就是需要在线程中维护一个变量,记录线程执行到
的位置。

程序计数器占用的内存空间很小,由于Java虚拟机的多线程是通过线程轮流切换,并分配处理器执行时
间的方式来实现的,在任意时刻,一个处理器只会执行一条线程中的指令。因此,为了线程切换后能够
恢复到正确的执行位置,每条线程需要有一个独立的程序计数器(线程私有)。

如果线程正在执行Java方法,则计数器记录的是正在执行的虚拟机字节码指令的地址;
如果正在执行的是Native方法,则这个计数器为空。

1
2
3
4
5
6
7
8
9
The Java Virtual Machine can support many threads of execution at once (JLS
§17). Each Java Virtual Machine thread has its own pc (program counter)
register. At any point, each Java Virtual Machine thread is executing the code
of a single method, namely the current method (§2.6) for that thread. If that
method is not native, the pc register contains the address of the Java Virtual
Machine instruction currently being executed. If the method currently being
executed by the thread is native, the value of the Java Virtual Machine's pc
register is undefined. The Java Virtual Machine's pc register is wide enough to
hold a returnAddress or a native pointer on the specific platform.

5.2.5 Native Method Stacks(本地方法栈)

如果当前线程执行的方法是Native类型的,这些方法就会在本地方法栈中执行。

未完待续。。。。。。