0%

上一篇文章分析了HashMap的原理,有网友留言想看LinkedHashMap分析,今天它来了。

LinkedHashMap是HashMap的子类,在原有HashMap数据结构的基础上,它还维护着一个双向链表链接所有entry,这个链表定义了迭代顺序,通常是数据插入的顺序。

上图我只画了链表,其实红黑树节点也是一样的,只是节点类型不一样而已

也就是说我们遍历LinkedHashMap的时候,是从head指针指向的节点开始遍历,一直到tail指向的节点。

源码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class LinkedHashMap<K,V> extends HashMap<K,V> implements Map<K,V>
{
static class Entry<K,V> extends HashMap.Node<K,V> {
Entry<K,V> before, after;
Entry(int hash, K key, V value, Node<K,V> next) {
super(hash, key, value, next);
}

// 双向链表头节点
transient LinkedHashMap.Entry<K,V> head;

// 双向链表尾节点
transient LinkedHashMap.Entry<K,V> tail;

// 指定遍历LinkedHashMap的顺序,true表示按照访问顺序,false表示按照插入顺序,默认为false
final boolean accessOrder;
}

}

从LinkedHashMap的定义里面可以看到它单独维护了一个双向链表,用于记录元素插入的顺序。这也是为什么我们打印LinkedHashMap的时候可以按照插入顺序打印的支撑。而accessOrder属性则指明了进行遍历时是按照什么顺序进行访问,我们可以通过它的构造方法自己指定顺序。

1
2
3
4
5
public LinkedHashMap(int initialCapacity,float loadFactor,
boolean accessOrder) {
super(initialCapacity, loadFactor);
this.accessOrder = accessOrder;
}

当accessOrder=true,访问顺序的输出是什么意思呢?来看下下面的一段代码

1
2
3
4
5
6
7
8
9
LinkedHashMap<Integer,Integer> map = new LinkedHashMap<>(8, 0.75f, true);

map.put(1, 1);
map.put(2, 2);
map.put(3, 3);

map.get(2);

System.out.println(map);

输出结果是

1
{1=1, 3=3, 2=2}

可以看到get了的数据被放到了双向链表尾部,也就是按照了访问时间进行排序,这就是访问顺序的含义。

在插入的时候LinkedHashMap复写了HashMap的newNode以及newTreeNode方法,并且在方法内部更新了双向链表的指向关系。

同时插入的时候调用了afterNodeAccess()方法以及afterNodeInsertion()方法,在HashMap中这两个方法是空实现,而在LinkedHashMap中则有具体实现,这两个方法也是专门给LinkedHashMap进行回调处理的。

afterNodeAccess()方法中如果accessOrder=true时会移动节点到双向链表尾部。当我们在调用map.get()方法的时候如果accessOrder=true也会调用这个方法,这就是为什么访问顺序输出时访问到的元素移动到链表尾部的原因。

接下来来看看afterNodeInsertion()的实现

1
2
3
4
5
6
7
8
9
10
// evict如果为false,则表处于创建模式,当我们new HashMap(Map map)的时候就处于创建模式
void afterNodeInsertion(boolean evict) { // possibly remove eldest
LinkedHashMap.Entry<K,V> first;

// removeEldestEntry 总是返回false,所以下面的代码不会执行。
if (evict && (first = head) != null && removeEldestEntry(first)) {
K key = first.key;
removeNode(hash(key), key, null, false, true);
}
}

看到这里我有一个想法,可以通过LinkedHashMap来实现LRU(Least Recently Used,即近期最少使用),只要满足条件,就删除head节点。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class LRUCache<K,V> extends LinkedHashMap<K,V> {

private int cacheSize;

public LRUCache(int cacheSize) {
super(16,0.75f,true);
this.cacheSize = cacheSize;
}

/**
* 判断元素个数是否超过缓存容量
*/
@Override
protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
return size() > cacheSize;
}
}

就这样一个简单的LRU Cache就实现了,以后面试官如果喊你给它实现一个LRU,你就这样写给他,如果他让你换一种方式,你就用链表使用同样的思维给他实现一个,然后你就可以收割offer了。

对于删除,LinkedHashMap也同样是在HashMap的删除逻辑完成后,调用了afterNodeRemoval这个回调方法来更正链表指向关系。

其实你只要看了上一篇文章再也不怕面试官问我JDK8 HashMap,再记得LinkedHashMap只是多维护了一个双向链表之后,再看LinkedHashMap中关于链表操作的代码就非常简单了。


本文整理自

如何用LinkedHashMap实现LRU

仅做个人学习总结所用,遵循CC 4.0 BY-SA版权协议,如有侵权请联系删除!


函数式编程的由来

在很长的一段时间里,Java一直是面向对象的语言,一切皆对象,如果想要调用一个函数,函数必须属于一个类或对象,然后在使用类或对象进行调用。但是在其它的编程语言中,如js,c++,我们可以直接写一个函数,然后在需要的时候进行调用,即可以说是面向对象编程,也可以说是函数式编程。

从功能上来看,面向对象编程没什么不好的地方,但是从开发的角度来看,面向对象编程会多写很多可能是重复的代码行。比如创建一个Runnable的匿名类的时候:

1
2
3
4
5
6
Runnable runnable = new Runnable() {
@Override
public void run() {
System.out.println("do something...");
}
};

这一段代码中真正有用的只有run方法中的内容,剩余的部分都是属于Java编程语言的结构部分,没什么用,但是要写。

幸运的是Java8开始,引入了函数式编程接口与Lambda表达式,帮助我们写更少更优雅的代码

1
2
// 一行即可
Runnable runnable = () -> System.out.println("do something...");

函数式接口(Functional interfaces)

一个接口类只有一个抽象的方法叫做函数式接口(Functional Interface),在JDK中大部分函数式接口都会标记上@FunctionalInterface注解,并不是所有的函数式接口都要写@FunctionalInterface注解,只是用来方便我们区分哪些是函数式接口的,如果标记了这个注解,内部有多个抽象方法的时候,会报编译错误。

1
2
3
Error:(3, 1) java: 意外的 @FunctionalInterface 注释
xxx 不是函数接口
在 接口 xxx 中找到多个非覆盖抽象方法

当一个类是函数式接口的时候,我们可以直接使用Lambda表达式来实例化它,而不用写很多模板式代码。

写法:

1
2
3
// 类名称  变量 = (参数) -> (函数体)
// 如
FunctionTest test = () -> { };

内置函数式接口

JDK中已经内置了一些标准的函数式接口,位于java.util.function包下,满足我们大多数情况下的需求。包下的接口都比较通用,如果我们想要写新的函数式接口,可以首先看这个包下是不是已经提供了。

那么这个包提供了那些接口呢?

  • 最常见的四种,Function,Consumer,Supplier,Predicate。标准输入输出,优雅代码所推荐的写法

    • Function:即一个入参一个出参的场景。
    • Consumer:一个入参,但是没有出参
    • Supplier:无入参,一个出参
    • Predicate:可以看做是特殊的Function,一个入参,出参为bool类型。
  • 两个入参的函数式接口,即两个入参,一个出参。

    1
    BiFunction<T, U, R>

    , 最典型的使用案例即Java的Map方法。

    • BiFunction: 两个入参,一个泛型出参
    • ToDoubleBiFunction,ToIntBiFunction,ToLongBiFunction。两个入参,返回原始类型的出参
1
2
3
4
5
HashMap<String, Object> map = new HashMap<>();
map.put("a",1);
map.put("b",2);
map.replaceAll((o, o2) -> o + o2.toString());
System.out.println(map.values())
  • 一元函数式接口, 可以看做是特殊的Function接口,入参与出参有相同的类型。UnaryOperator,相同类型的转换。
  • 原始类型Function,因为Java的原始类型或者叫做主类型,int,short,double之类的,不能作为泛型参数。官方对这些原始类型提供了特殊的函数式接口
    • 以int类型:IntFunction, IntComsumer,IntSupplieer, IntPredicate, IntToDoubleFcuntion, IntToLongFunction, IntUnaryOperator, ToIntFunction

官方:

docs.oracle.com/javase/8/do…

Lambda表达式

如果理解了函数式接口,在一定程度上也是理解了Lambda表达式,Lambda表达式相当于对函数式接口的使用,我们不能只去写接口,但不去使用。

尽管Java支持了函数式接口,但是Java的函数仍然要寄托与类之中,不能单独存在,但是因为函数式接口只有一个抽象的函数,那么我们很明确的知道要使用拿一个方法。所以编译器可以自动帮我们推导要用哪个方法,不用在写多余的模板式代码

1
Runnable runnable = () -> System.out.println("do something...");
  • 首先Runnable是一个函数式的接口,所以我们可以使用Lambda表达式来实例化它
  • 因为run方法没有参数,所以lambda表达式:(argument) -> {body},参数为空()
  • 关于函数体部分,如果我们只有一行代码可以省略掉{}。

除了函数调用的Lambda表达式,还有一些方法的引用操作,使用::引用方法或者构造器而没有真正的实例化对象,可以探索下

1
2
3
4
5
String::toUpperCase
System.out::println
"abc"::length
ArrayList::new
int[]::new

官方:

www.oracle.com/webfolder/t…

docs.oracle.com/javase/tuto…

例子

我们可以通过以下实例(Java8Tester.java)来了解函数式接口 Predicate 的使用:

Java8Tester.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
import java.util.Arrays;
import java.util.List;
import java.util.function.Predicate;

public class Java8Tester {
public static void main(String args[]){
List<Integer> list = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9);

// Predicate<Integer> predicate = n -> true
// n 是一个参数传递到 Predicate 接口的 test 方法
// n 如果存在则 test 方法返回 true

System.out.println("输出所有数据:");

// 传递参数 n
eval(list, n->true);

// Predicate<Integer> predicate1 = n -> n%2 == 0
// n 是一个参数传递到 Predicate 接口的 test 方法
// 如果 n%2 为 0 test 方法返回 true

System.out.println("输出所有偶数:");
eval(list, n-> n%2 == 0 );

// Predicate<Integer> predicate2 = n -> n > 3
// n 是一个参数传递到 Predicate 接口的 test 方法
// 如果 n 大于 3 test 方法返回 true

System.out.println("输出大于 3 的所有数字:");
eval(list, n-> n > 3 );
}

public static void eval(List<Integer> list, Predicate<Integer> predicate) {
for(Integer n: list) {

if(predicate.test(n)) {
System.out.println(n + " ");
}
}
}
}

执行以上脚本,输出结果为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
$ javac Java8Tester.java 
$ java Java8Tester
输出所有数据:
1
2
3
4
5
6
7
8
9
输出所有偶数:
2
4
6
8
输出大于 3 的所有数字:
4
5
6
7
8
9

最后

Java的函数式编程远远超出这里所表达的内容,还需要更多的探索。当然这里主要介绍了Java的函数式接口,已经在java.util.function包下常见的函数式接口,最后对lambda表达式做了简单的说明。


本文整理自

Java8函数式编程

仅做个人学习总结所用,遵循CC 4.0 BY-SA版权协议,如有侵权请联系删除!


为什么要进行内存区域划分

JVM规范 规定,JVM 在执行 Java 程序的过程中会把它所管理的内存划分为若干个不同的数据区域。这些区域都有各自的用途。以及创建和销毁的时间,有的区域随着虚拟机进程的启动就存在了,而有些区域则依赖用户线程的启动和结束而建立和销毁。JVM 规范对 JVM 定义了运行时统一的内存划分规范,统一了标准,类似于 JDBC 规范一样。JVM 也有许多厂商的不同产品。比如下面的这些:

厂商 JVM
Oracle-SUN Hotspot
Oracle JRocket
IBM J9 JVM
阿里 Taobao JVM

其内存区域划分规范对于 JVM 的含义类似于我们 Java 中的接口,都是起到了规范的作用,JVM 是一台可以运行 Java 应用程序的抽象的计算机。在 JVM 中存在三个重要的概念:

  • JVM 规范:它定义了虚拟机运行的规范,但是由 Oracle(SUN)或者其它厂商实现
  • Java 运行时环境(JRE:Java Runtime Environment):它是 JVM 规范的具体实现
  • JVM 实例:编写好 Java 代码之后,运行 Java 程序,此时就会创建 JMV 实例

对于 Java 程序员来说,在虚拟机自动内存管理机制的帮助下,不再需要为每一个对象去编写内存释放的代码,不要像 C 或者 C++ 要时刻注意着内存泄漏和内存溢出的问题,这种由虚拟机去管理一切看起来都很美好。不过,也正是因为 Java 设计者把内存控制全部交给了 JVM,一旦出现了内存泄漏和溢出方面的问题,如果不了解虚拟机是怎么分配运行时内存的,那么排查错误将是一项非常艰难的工作。

运行时数据区域的组成

为什么我们经常把运行时数据区叫做 Java 内存模型(JMM:Java Memory Model),是因为运行时数据区太过于分散,没有联系,所以才会有 JVM 内存模型这个词,让我们把这些东西联系起来,方便记忆。JVM 运行时数据区中有些数据是一直存在的,被所有线程所共享。而有些区域则是线程私有的,伴随着线程的开始而创建,线程的结束而销毁。所以我们可以把JMM 分为两类:线程共享的线程私有的。根据 JVM 虚拟机规范的规定,JVM 虚拟机运行时数据区划分如下图所示:

运行时数据区主要分为以下五个部分:

  • 方法区
  • 虚拟机栈
  • 本地方法栈
  • 程序计数器

其中,按照线程在各个区域的数据是否共享划分为:

  • 线程共享部分:方法区、Java 堆以及运行时常量池(归属于方法区)
  • 线程私有部分:虚拟机栈、本地方法栈、程序计数器

接下来看看 Java 运行时数据区中各个部分的用途和特点:

方法区

什么是方法区

在 JVM 中,方法区是可供各个线程共享运行时的内存区域。方法区域传统语言中的编译代码存储区或者操作系统进程的正文段的作用非常类似,它存储了每一个类的结构信息,例如运行时常量池、字段和方法数据、类的构造函数和普通方法的字节码内容、还包括一些类、实例、接口初始化的时候用到的特殊方法。

在 Hotspot 虚拟机中,JDK 1.7 及以前版本用永久代(Permanent Generation)定义方法区,而在 JDK 1.8 以后则称为 元空间(Metapace)
方法区有个别名叫做非堆(Non-Heap),用于区别于 Java 堆区。默认最小值为 16 MB,最大值为 64 MB,可通过 -XX:PermSize-XX:MaxPermSize 参数设置方法的大小。
JDK 1.7 及之前的版本设置为:

1
2
-XX:PermSize=10m
-XX:MaxPermSize=55m

JDK 1.8 及之后的版本设置为:

1
2
-XX:MetaspaceSize=10m
-XX:MaxMetaspaceSize=55m
方法区的特点
  • 线程共享:方法区是堆的一个逻辑部分,因此和对一样是线程共享的。整个虚拟机中只有一个方法区。
  • 永久代:方法区中的信息一般要长期存在,而且它又是堆的逻辑部分,因此用堆的划分方法,我们把方法区称作永久代(方法区是规范,永久代是实现)。
  • 内存回收少:方法区中的信息一般需要长期存在,回收一遍内存之后可能之后少量信息无效。对方法区的内存回收主要是 对常量池的回收和对类型的卸载
  • JVM 规范对方法区的定义比较宽松:和堆一样,允许固定大小,也允许可扩展大小,还允许不实现垃圾回收。

方法区是所有都线程共享的,在一定的条件下它也会被 GC,当方法区域需要使用的内存超过其允许的大小时,会抛出 OOM(OutOfMemory)错误信息。

运行时常量池

类加载后,Class 文件结构中常量池中的数据将被存储在运行时常量池中。我们一般在一个类中通过 public static final 来声明一个常量或者声明一个字符串 String str = "abc"。这个类编译后产生的 Class 文件,这个类的所有信息都存储在这个 class 文件中,当这个类被 JVM 加载之后,class 文件中的常量就存放在方法区的运行时常量池中。而且在运行期间,可以向常量池添加新的常量。比如,String 类的 intern() 方法就能在运行期间向常量池中添加新的常量。

当运行时常量池中的某些常量没有被对象引用,同时也没有被变量引用时,那么就需要垃圾收集器回收。JVM 为每个已加载的类型维护一个常量池,常量池就是这个类型用到的常量的一个有序集合。其包括直接常量(基本类型,String)和对其他类型、方法、字段的符号引用。即字面量和符号引用,其中字面量指的是整个类中的字面量。包含成员变量、静态方法、非静态方法等中的字面量。池中的数据和数组一样通过索引访问。

虚拟机栈

什么是虚拟机栈

Java 虚拟机栈是描述 Java 方法运行过程的内存模型。Java 虚拟机栈会为每一个即将运行的方法创建一块叫做 栈帧 的区域,这块区域用于存储用于方法在运行时所需要的一些信息,这些信息具体包括:

  • 局部变量表
  • 操作数栈
  • 动态链接
  • 方法出口信息
  • 其它信息

当一个方法即将被运行时,Java 虚拟机栈首先会在 Java 虚拟机栈中为该方法创建一块”栈帧”,栈帧中包含局部变量表,操作数栈,动态链接,方法出口信息等。当方法在运行过程中需要创建局部变量时,就将局部变量的值存入栈帧的局部变量表中。当这个方法执行完毕后,这个方法所对应的栈帧将会出栈,并释放内存空间。Java 虚拟机栈上数据都是私有的,其他线程都不能访问该线程的栈数据。在函数中定义的一些基本类型的变量数据和对象的引用变量都在函数的栈内存中分配。当在一段代码块中定义一个变量时,Java 就会在栈中为这个变量分配内存空间,当该变量退出该作用域后,Java 会自动释放掉为该变量所分配的内存空间,该内存空间可以立即被另作他用。

虚拟机栈的特点
  • 局部变量表的创建是在方法被执行的时候,随着栈帧的创建而创建。局部变量表的大小在程序的编译期间就确定下来了,在创建的时候需要事先指定好大小,在方法运行的过程中局部变量表的大小是不会发生改变的。
  • Java虚拟机栈会出现两种错误(StackOverFlowError 和 OutOfMemoryError),StackOverFlowError:若 Java 虚拟机栈的内存大小不允许动态扩展,那么当线程请求栈的深度超过当前 Java 虚拟机栈的最大深度的时候就会抛出 StackOverFlowError。OutOfMemoryError:若 Java 虚拟机栈的内存大小不允许动态扩展,那么当线程请求栈时内存用完了,无法再动态扩展了,此时就会抛出 StackOverFlowError。
  • 虚拟机栈也是线程私有的,每个线程都有各自的 Java 虚拟机栈,而且随着线程的创建而创建,随着线程的死亡而死亡。
  • 栈中的数据在线程内部是共享的,要注意这种数据的共享与两个对象引用同 时指向一个对象的这种共享是不同的。它是由编译器完成的,它有利于节省空间。

本地方法栈

本地方法指的是使用 Java 以外的其他语言编写的代码,因为有些时候 Java 无法直接操作一些底层资源,只能通过 C 或汇编操作。因此需要通过本地方法来实现。而本地方法栈就是设计用来调用这些非 Java 语言方法的。会存放对应的局部变量信息、返回结果等。本地方法栈和 Java 虚拟机栈实现的功能类似,只不过本地方法栈是本地方法运行的内存模型。区别是虚拟机栈为虚拟机执行 Java 方法服务,而本地方法栈则是为虚拟机用到的 Native 方法服务,本地方法被执行的时候,在本地方法栈也会创建一个栈帧,用于存放该本地方法的局部变量表、操作数栈、动态链接以及出口信息等。方法执行完毕后相应的栈帧也会出栈并释放内存空间。

HotSpot虚拟机将虚拟机栈和本地方法栈合并实现了.

本地方法栈也会抛出两种错误,StackOverFlowError 和 OutOfMemoryError。

什么是堆

堆是用来存放对象(类、接口、数组)的内存空间,是JVM管理的最大的一块内存空间。几乎所有的对象都存储在堆中(实例创建后,成员变量也随对象存在堆中,随着垃圾回收进行释放)。堆是一个运行时数据区,在程序运行时动态分配内存。
在堆中产生了一个数组对对象后,还可以在栈中定义一个特殊的变量,让栈用这个变量的取值等于数组或对象在堆地址内存中的首地址,栈中的这个变量就成了数组或对象的引用变量。引用变量就相当于是为数组和对象起的一个名称,以后就可以在程序中使用栈中的引用变量来访问堆中数组或对象。
引用变量是普通的变量,定义时在栈中分配,引用变量在程序运行到其作用域外后释放。而数组和对象本身在堆中分配,即使程序运行到使用 new 产生数组或者对象的语句所在的代码之外,数组和对象本身占据的内存空间不会被释放,数组和对象在没有引用指向它的时候才会变为垃圾,不能再被使用。仍然占据内存空间不放,在随后的一个不确定的时期被 GC 垃圾回收收走。这也是 Java 比较占用内存的原因之一,实际上,栈中的变量指向堆内存的变量,这就是 Java 中的指针。

堆的特点
  • 线程共享:整个 JVM 只有一个堆,所有的线程都访问同一个堆。而程序计数器、Java 虚拟机栈、本地方法栈都是一个线程对应一个。
  • 在虚拟机启动的时候创建。
  • 垃圾回收的主要场所。
  • 堆的大小既可以固定也可以扩展,但主流的虚拟机堆的大小是可扩展的。
  • 堆可以分为:新生代和老年代
新生代

新生代程序新创建的对象都在新生代分配的,新生代由 Eden Space 和两块大小相同的 Survivor Space(通常又称 S0 和 S1或 FROM 和 To )构成,可通过 -Xmn 参数来指定新生代的大小,也可以通过

1
-XX:SurvivorRation

来调整 Eden Space 及 Survivor Space 的大小,因此新生代又可被分为:Eden,From Survivor,To Survivor。

老年代

老年代用户存放经过多次新生代垃圾回收仍然存活的对象,例如缓存对象,新建的对象也有可能直接进入老年代。主要有两种情况:一种是 大对象,可通过启动参数设置

1
-XX:PretenureSizeThreshold=1024

(单位为字节,默认为 0)来代表超过多大时就不再在新生代分配,而是直接在老年代分配。另一种是 大的数组对象,且数组中无引用外部对象。老年代所占的内存大小为 -Xmx 对应的值减去 -Xmn(新生代)对应的值。不同的区域存放具有不同生命周期的对象。这样可以根据不同的区域使用不同的垃圾回收算法,从而更具有针对性,从而更加高效。

  • JDK 1.8 及之后版本堆的内存空间分配(老年代:三分之二的堆空间,年轻代:三分之一的堆空间)
  • eden 区: 十分之八的年轻代空间
  • survivor 0:十分之一的年轻代空间
  • survivor 1:十分之一的年轻代空间

程序计数器

什么是程序计数器

程序计数器是一块比较小的内存空间,可以把它看作当前线程正在执行的字节码的行号指示器。程序计数器里面记录的是当前线程正在执行的那一条字节码指令的地址。当然,程序计数器是线程私有的。但是,如果当前线程执行的是一个线程本地的方法,那么此时这个线程的程序计数器为空。

本地方法为 Native Method,即由 native 修饰的方法。在定义一个 native 方法时,并不提供实现(类似 Java 中的接口或者抽象方法),因为其实现往往是由外面的 C 或者 C++ 等非 Java 语言实现的。

程序计数器的作用

程序计数器主要有两个作用:

  • 字节码解释器通过改变程序计数器来一次读取指令,从而实现代码的流程控制,如顺序执行、选择、循环、异常处理等。
  • 在多线程的条件下,程序计数器用来记录当前线程执行的位置,从而当线程被切换回来的时候能够知道这个线程上次运行到哪个地方了。
程序计数器的特点
  • 是一块比较小的存储空间
  • 是线程私有的,即每一个线程都有一个独立程序计数器
  • 是唯一一个不会出现 OOM(OutOfMemoryError)的内存区域
  • 声明周期随着线程的开始而创建,随着线程的终止而结束

方法区、永久代和元空间的关系

方法区和永久代的关系

涉及到内存模型,往往都会提到永久代,那么它和方法区又是什么关系呢?
JVM 虚拟机规范 只是规定了有方法区这个概念和它的作用,并没有规定如何实现它。那么,在不同 JVM 上方法区的实现肯定是不同的。同时大多数公司用的 JVM 都是 Oracle 公司的 HotSpot。在 HotSpot 上把 GC 分代收集扩展至方法区,或者说使用永久代(PermGen Space)来实现方法区。因此,我们可以得到结论,永久代是 HotSpot 的概念,方式区是 JVM 规范的定义,是一种规范,而永久代是一种实现,一个是标准一个是实现。其它的虚拟机实现并没有永久代这么一说。在 JDK 1.7 及之前的实现中,HotSpot 使用永久代实现方法区,HotSpot 使用 GC 分代来实现方法区内存回收,可以使用以下参数来调准方法区的大小:

1
2
-XX:PermSize     # 方法区初始大小
-XX:MaxPermSize # 方法区最大大小(超过这个值会抛出 OutOfMemoryError 异常:java.lang.OutOfMemoryError:PermGen)

永久代仍存在于JDK1.7中,并没完全移除,譬如

  • 符号引用(Symbols)转移到了native heap;

  • 字面量(interned strings)转移到了java heap;

  • String.intern()方法的实现也有变化;

  • 类的静态变量(class statics)转移到了java heap;

不断的使用String.intern()方法,在JDK1.6中会产生java.lang.OutOfMemoryError: PermGen space这个错误,在JDK1.7中就会报java.lang.OutOfMemoryError: Java Heap space,而在JDK1.8 中,也是java.lang.OutOfMemoryError: Java Heap space,但是还会多出warning;

在JDK1.8中,永久代被移除,取而代之的是元空间概念,也就是使用本地内存。对参数PermSize以及MaxPermSize的设置已经从1.8中移除。

元空间

对于 Java 8,HotSpot 取消了永久代,那么是不是也就没有方法区了吗?
当然不是,方法区是一个规范,规范没变,它就会一直在。那么取代永久代的就是元空间。它和永久代有什么不同呢?

  • 存储位置不同,永久代是堆的一部分,和新生代、老年代地址是连续的,而元空间属于本地内存(直接内存)
  • 存储内容不同,元空间存储类的元信息静态变量和常量池等并入堆中。相当于永久代的数据被分到了堆和元空间中。

直接内存

直接内存也称本地内存或堆外内存, 不属于虚拟机运行时数据区内存,该空间划分在虚拟机外,大小不受堆内存容量限制。

JDK1.4中加入新的NIO(New Input/OutPut)类,引入了一种基于通道(Channel)与缓冲区(Buffer)的I/O方式,可以通过Native函数库直接分配堆外内存,然后通过Java堆中的DirectByteBuffer对象来对这块内存的引用进行操作,避免数据在Java堆与Native堆中数据的来回复制。

直接内存受物理机剩余可用内存、处理器寻址空间的限制。如果虚拟机堆内存分配太大,会导致剩余直接内存空间不足而出现OutOfMemoryError异常。

总结

1 JVM 内存模型一共有两个“栈”,分别是 Java 虚拟机栈和本地方法栈

两个“栈”功能类似,都是方法运行过程的内存模型。并且两个“栈”内部构造相同,都是方法私有的。只不过 Java 虚拟机栈描述的是 Java 方法运行过程的内存模型,而本地方法栈是描述 Java 本地方法运行过程的内存模型。

2 JVM 内存模型中一共有两个“堆”,分别是原本的堆和方法区

方法区本质上还是属于堆的一个逻辑部分。堆中存放对象,方法区中存放类信息、常量、静态变量,即时编译器编译后的代码等。

3 堆是 JVM 中最大的一块内存区域,也是垃圾收集器主要工作的地方

在创建对象的时候,非静态成员会被加载到堆内存中,并完成成员变量的初始化。也就是说所有的非静态成员(成员变量、成员方法、构造方法、构造代码块和普通代码块)都是保存在堆内存中的。但是方法调用的时候,调用的方法会在栈内存中执行,构造代码块也会在栈内存中执行。

4 线程私有与共享

Java 虚拟机栈、程序计数器和本地方法栈都是线程私有的,也就是说每个线程都是各自的程序计数器、Java 虚拟机栈和本地方法栈。他们的生命周期和线程的生命周期一样。而堆、方法区则是线程共享的,在 JVM 中只有一个堆,一个方法区。并在 JVM 启动的时候就创建,直到 JVM 停止的时候才销毁。


参考文章:
Java Memory Management for Java Virtual Machine (JVM)
The Java® Virtual Machine Specification(Java SE 8 Edition)


本文整理自

Java 运行时数据区域

JVM内存管理—直接内存

仅做个人学习总结所用,遵循CC 4.0 BY-SA版权协议,如有侵权请联系删除!


概念介绍

异常是发生在程序执行过程中阻碍程序正常执行的错误事件,当一个程序出现错误时,可能的情况有如下3种:

  • 语法错误
    代码的格式错了,某个字母输错了
  • 运行时错误
    空指针异常,数组越界,除数为零等
  • 逻辑错误
    运行结果与预想的结果不一样,这是一种很难调试的错误

Java中的异常处理机制主要处理运行时错误。

异常分类

下图是一张经典的Java异常类层次结构图,对各种异常做出了较为清晰的分类

Java异常类层次结构图

从上图中可以看到,所有的异常都继承自一个共同的父类Throwable,而Throwable有两个重要的子类:Exception(异常)和Error(错误)
下面对这两个重要的子类进行介绍

  • Error(错误)
    是程序无法处理的错误,表示运行应用程序中较严重问题。大多数错误与代码编写者执行的操作无关,而表示代码运行时 JVM(Java 虚拟机)出现的问题。例如,Java虚拟机运行错误(Virtual MachineError),当 JVM 不再有继续执行操作所需的内存资源时,将出现 OutOfMemoryError。这些异常发生时,Java虚拟机(JVM)一般会选择线程终止。
    这些错误表示故障发生于虚拟机自身、或者发生在虚拟机试图执行应用时,如Java虚拟机运行错误(Virtual MachineError)、类定义错误(NoClassDefFoundError)等。这些错误是不可查的,因为它们在应用程序的控制和处理能力之 外,而且绝大多数是程序运行时不允许出现的状况。对于设计合理的应用程序来说,即使确实发生了错误,本质上也不应该试图去处理它所引起的异常状况。在 Java中,错误通过Error的子类描述。
  • Exception(异常)
    是程序本身可以处理的异常。主要包含RuntimeException等运行时异常和IOException,SQLException等非运行时异常。
    运行时异常包括:都是RuntimeException类及其子类异常,如NullPointerException(空指针异常)、IndexOutOfBoundsException(下标越界异常)等,这些异常是不检查异常,程序中可以选择捕获处理,也可以不处理。这些异常一般是由程序逻辑错误引起的,程序应该从逻辑角度尽可能避免这类异常的发生。
    运行时异常的特点是Java编译器不会检查它,也就是说,当程序中可能出现这类异常,即使没有用try-catch语句捕获它,也没有用throws子句声明抛出它,也会编译通过。
    非运行时异常(编译异常)包括:RuntimeException以外的异常,类型上都属于Exception类及其子类。从程序语法角度讲是必须进行处理的异常,如果不处理,程序就不能编译通过。如IOException、SQLException等以及用户自定义的Exception异常,一般情况下不自定义检查异常。

编译器是否要求强制处理的角度分类,异常类别又可分为:

  • 可查异常
    正确的程序在运行中,很容易出现的、情理可容的异常状况。可查异常虽然是异常状况,但在一定程度上它的发生是可以预计的,而且一旦发生这种异常状况,就必须采取某种方式进行处理。
    除了RuntimeException及其子类以外,其他的Exception类及其子类都属于可查异常。这种异常的特点是Java编译器会检查它,也就是说,当程序中可能出现这类异常,要么用try-catch语句捕获它,要么用throws子句声明抛出它,否则编译不会通过。
  • 不可查异常
    包括运行时异常(RuntimeException与其子类)和错误(Error)。

常见异常

ArithmeticException:数学运算异常,比如除0。
NullPointerException:空指针异常。
NegativeArraySizeException:数组大小为负值异常。
ArrayIndexOutOfBoundException:数组下标越界异常。
NumberFormatException:数字格式异常。
InputMismatchException:输入类型不匹配异常。
NoSuchMethodException:方法不存在异常。
DataFormatException:数据格式错误异常。
NoClassDefFoundError:未找到类定义错误。
OutOfMemoryError:内存不足错误。
StackOverflowError:堆栈溢出错误。
ThreadDeath:线程结束。
UnknownError:未知错误。

异常处理机制

在 Java 应用程序中,异常处理机制为:抛出异常,捕捉异常。

  • 抛出异常
    当一个方法出现错误引发异常时,方法创建异常对象并交付运行时系统,异常对象中包含了异常类型和异常出现时的程序状态等异常信息。运行时系统负责寻找处置异常的代码并执行。
    注意:对于运行时异常、错误或可查异常,Java技术所要求的异常处理方式有所不同。
    由于运行时异常的不可查性,为了更合理、更容易地实现应用程序,Java规定,运行时异常将由Java运行时系统自动抛出,允许应用程序忽略运行时异常。
    对于方法运行中可能出现的Error,当运行方法不欲捕捉时,Java允许该方法不做任何抛出声明。因为,大多数Error异常属于永远不能被允许发生的状况,也属于合理的应用程序不该捕捉的异常。
    对于所有的可查异常,Java规定:一个方法必须捕捉,或者声明抛出方法之外。也就是说,当一个方法选择不捕捉可查异常时,它必须声明将抛出异常
  • 捕获异常
    在方法抛出异常之后,运行时系统将转为寻找合适的异常处理器(exception handler)。潜在的异常处理器是异常发生时依次存留在调用栈中的方法的集合。当异常处理器所能处理的异常类型与方法抛出的异常类型相符时,即为合适 的异常处理器。运行时系统从发生异常的方法开始,依次回查调用栈中的方法,直至找到含有合适异常处理器的方法并执行。当运行时系统遍历调用栈而未找到合适 的异常处理器,则运行时系统终止。同时,意味着Java程序的终止。

通常使用关键字try、catch、finally来捕获异常
语法形式如下:

1
2
3
4
5
6
7
8
9
try {  
// 可能会发生异常的程序代码
} catch (Type1 id1) {
// 捕获并处理try抛出的异常类型Type1
} catch (Type2 id2) {
// 捕获并处理try抛出的异常类型Type2
} finally {
// 无论是否发生异常,都将执行的语句块
}

小结:
try 块:用于捕获异常。其后可接零个或多个catch块,如果没有catch块,则必须跟一个finally块。
catch 块:用于处理try捕获到的异常。
finally 块:无论是否捕获或处理异常,finally块里的语句都会被执行。当在try块或catch块中遇到return语句时,finally语句块将在方法返回之前被执行。在以下4种特殊情况下,finally块不会被执行:
1)在finally语句块中发生了异常。
2)在前面的代码中用了System.exit()退出程序。
3)程序所在的线程死亡。
4)关闭CPU。

try、catch、finally语句块的执行顺序:
1)当try没有捕获到异常时:try语句块中的语句逐一被执行,程序将跳过catch语句块,执行finally语句块和其后的语句;

2)当try捕获到异常,catch语句块里没有处理此异常的情况:当try语句块里的某条语句出现异常时,而没有处理此异常的catch语句块时,此异常将会抛给JVM处理,finally语句块里的语句还是会被执行,但finally语句块后的语句不会被执行;

3)当try捕获到异常,catch语句块里有处理此异常的情况:在try语句块中是按照顺序来执行的,当执行到某一条语句出现异常时,程序将跳到catch语句块,并与catch语句块逐一匹配,找到与之对应的处理程序,其他的catch语句块将不会被执行,而try语句块中,出现异常之后的语句也不会被执行,catch语句块执行完后,执行finally语句块里的语句,最后执行finally语句块后的语句;
流程如下图所示:

try、catch、finally语句块的执行顺序

面试常考问题总结

1.描述Java 7 ARM(Automatic Resource Management,自动资源管理)特征和多个catch块的使用
如果一个try块中有多个异常要被捕获,catch块中的代码会变丑陋的同时还要用多余的代码来记录异常。有鉴于此,Java 7的一个新特征是:一个catch子句中可以捕获多个异常。示例代码如下:

1
2
3
4
catch(IOException | SQLException | Exception ex){
logger.error(ex);
throw new MyException(ex.getMessage());
}

大多数情况下,当忘记关闭资源或因资源耗尽出现运行时异常时,我们只是用finally子句来关闭资源。这些异常很难调试,我们需要深入到资源使用的每一步来确定是否已关闭。因此,Java 7用try-with-resources进行了改进:在try子句中能创建一个资源对象,当程序的执行完try-catch之后,运行环境自动关闭资源。下面是这方面改进的示例代码:

1
2
3
4
5
try (MyResource mr = new MyResource()) {
System.out.println("MyResource created in try-with-resources");
} catch (Exception e) {
e.printStackTrace();
}

2.在Java中throw与throws关键字之间的区别?
throws用于在方法签名中声明此方法可能抛出的异常,而throw关键字则是中断程序的执行并移交异常对象到运行时进行处理。

3.被检查的异常和不受检查的异常有什么区别?

  • 被检查的异常应该用try-catch块代码处理,或者在main方法中用throws关键字让JRE了解程序可能抛出哪些异常。不受检查的异常在程序中不要求被处理或用throws语句告知。
  • Exception是所有被检查异常的基类,然而,RuntimeException是所有不受检查异常的基类
  • 被检查的异常适用于那些不是因程序引起的错误情况,比如:读取文件时文件不存在引发的FileNotFoundException。然而,不被检查的异常通常都是由于糟糕的编程引起的,比如:在对象引用时没有确保对象非空而引起的NullPointerException。

4.Java中final,finally,finalize的区别?
final和finally在Java中是关键字,而finalize则是一个方法。

  • final关键字使得类变量不可变,避免类被其它类继承或方法被重写。
  • finally跟try-catch块一起使用,即使是出现了异常,其子句总会被执行,通常,finally子句用来关闭相关资源。
  • finalize方法中的对象被销毁之前会被垃圾回收。

5.下面是一些代码相关的问题,需要回答该代码有没有问题?该怎么修改?
A.下面这段代码有什么问题呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import java.io.FileNotFoundException;
import java.io.IOException;

public class TestException {
public static void main(String[] args) {
try {
testExceptions();
} catch (FileNotFoundException | IOException e) {
e.printStackTrace();
}
}

public static void testExceptions() throws IOException,
FileNotFoundException {
}
}

上面代码的主要问题在于FileNotFoundException是IOException的子类,编译会报错:The exception FileNotFoundException is already caught by the alternative IOException.
有两种办法可以解决这个问题

  • 用两个catch子句来处理这两个异常
1
2
3
4
5
6
7
try {     
testExceptions();
}catch(FileNotFoundException e){
e.printStackTrace();
}catch (IOException e) {
e.printStackTrace();
}
  • 在catch子句中移除FileNotFoundException,只用IOException
1
2
3
4
5
try {
testExceptions();
}catch (IOException e) {
e.printStackTrace();
}

B.下面这段代码又有什么问题呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import java.io.FileNotFoundException; 
import java.io.IOException;
import javax.xml.bind.JAXBException;

public class TestException1 {
public static void main(String[] args) {
try {
go();
} catch (IOException e) {
e.printStackTrace();
} catch (FileNotFoundException e) {
e.printStackTrace();
} catch (JAXBException e) {
e.printStackTrace();
}
}

public static void go() throws IOException, JAXBException, FileNotFoundException{
}
}

上面代码的问题同样在于FileNotFoundException是IOException的子类,所以,FileNotFoundException的catch子句将被隐藏。编译时会报错:Unreachable catch block for FileNotFoundException.
解决方案:
改变catch子句的顺序来修复程序

1
2
3
4
5
6
7
8
9
try {
go();
} catch (FileNotFoundException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
} catch (JAXBException e) {
e.printStackTrace();
}

C.下面的代码同样存在问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import java.io.IOException;   
import javax.xml.bind.JAXBException;

public class TestException2 {
public static void main(String[] args) {
try {
foo();
} catch (IOException e) {
e.printStackTrace();
}catch(JAXBException e){
e.printStackTrace();
}catch(NullPointerException e){
e.printStackTrace();
}catch(Exception e){
e.printStackTrace();
}
}

public static void foo() throws IOException{

}
}

这段代码同样不能编译,因为JAXBException是个受检查的异常,而foo方法应该抛出此异常供调用方法捕获。你将会得到:Unreachable catch block for JAXBException这样的错误信息。这个异常不可能从try子句中抛出。为解决这个错误,只能将JAXBException从catch子句中移除。
也要注意到,NullPointerException的异常捕获是有效的,因为它是个不被检查的异常。

D.下面的代码存在什么问题呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class TestException3 {
public static void main(String[] args) {
try{
bar();
}catch(NullPointerException e){
e.printStackTrace();
}catch(Exception e){
e.printStackTrace();
}
foo();
}

public static void bar(){

}

public static void foo() throws NullPointerException{
}
}

这代码是个幌子,根本没问题,能被正确编译。我们能捕获到一般异常或者是不被检查的异常,即使在throws语句中没被提及。
同样,如果程序中的一个方法foo()在throws中声明了不被检查的异常,程序中也不一定要处理这个异常。

E.下面这段代码同样存在瑕疵。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import java.io.IOException;   

public class TestException4 {
public void start() throws IOException{

}

public void foo() throws NullPointerException{ }
}

class TestException5 extends TestException4{
public void start() throws Exception{ }
public void foo() throws RuntimeException{
}
}

这段代码不能被编译,因为父类中start的方法签名与子类中的start方法签名不相同。为纠正这错误,我们可以修改子类的方法签名使之与超类相同,我们也可以像下面代码那样移除子类中throws关键字。

1
2
3
4
@Override    
public void start(){

}

F.下面的代码存在什么问题呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import java.io.IOException;   
import javax.xml.bind.JAXBException;

public class TestException6 {
public static void main(String[] args) {
try {
foo();
} catch (IOException | JAXBException e) {
e = new Exception("");
e.printStackTrace();
}catch(Exception e){
e = new Exception("");
e.printStackTrace();
}
}

public static void foo() throws IOException, JAXBException{
}
}

这段代码同样不能编译,因为在多个catch子句中的异常对象是不可变的,我们不能改变其值。你会得到这样的:The parameter e of a multi-catch block cannot be assigned编译时错误信息。我们需要删掉将e赋值给新异常对象这句来修正错误。

参考资料

[1]详解Java中异常处理机制
[2]深入理解java异常处理机制
[3]JAVA异常处理相关面试题
[4]Java异常的面试问题及答案-Part 1
[5]Java异常的面试问题及答案-Part 2
[6]Java异常的面试问题及答案-Part 3


本文整理自

Java异常处理机制总结

仅做个人学习总结所用,遵循CC 4.0 BY-SA版权协议,如有侵权请联系删除!


前言

在之前的几篇博客中,我们大致介绍了,常见的 垃圾回收算法JVM 中常见的分类回收算法。这些都是从算法和规范上分析 Java 中的垃圾回收,属于方法论。在 JVM 中,垃圾回收的具体实现是由 垃圾回收器Garbage Collector)负责的。

正文

概述

在了解 垃圾回收器 之前,首先得了解一下垃圾回收器的几个名词。

1. 吞吐量

CPU 用于运行用户代码的时间与 CPU 总消耗时间的比值。比如说虚拟机总运行了 100 分钟,用户代码 时间 99 分钟,垃圾回收 时间 1 分钟,那么吞吐量就是 99%

吞吐量 = 运行用户代码时间/(运行用户代码时间 + 垃圾回收时间)

2. 停顿时间

停顿时间 指垃圾回收器正在运行时,应用程序暂停时间。对于 独占回收器 而言,停顿时间可能会比较长。使用 并发回收器 时,由于垃圾回收器和应用程序 交替运行,程序的 停顿时间 会变短,但是,由于其 效率 很可能不如独占垃圾回收器,故系统的 吞吐量 可能会较低。

3. GC的名词

3.1. 新生代GC(Minor GC)

指发生在 新生代 的垃圾回收动作,因为 Java 对象大多都具备 朝生夕死 的特性,所以 Minor GC 通常 非常频繁,一般回收速度也比较快。

3.2. 老年代GC(Major GC)

指发生在 老年代 的垃圾回收动作,出现了 Major GC,经常会伴随至少一次的 Minor GC(发生这种情况,那么 整个堆GC 一遍,通常称为 Full GC)。Major GC 的速度一般会比 Minor GC10 倍以上。

4. 并发与并行

4.1. 串行(Parallel)

单线程 进行垃圾回收工作,但此时 用户线程 仍然处于 等待状态

4.2. 并发(Concurrent)

这里的并发指 用户线程垃圾回收线程 交替执行。

4.3. 并行(Parallel)

这里的并行指 用户线程 和多条 垃圾回收线程 分别在不同 CPU 上同时工作。

垃圾回收算法

1. 根搜索算法

根搜索算法 是从 离散数学 中的图论引入的,程序把所有引用关系看作一张图,从一个节点 GC ROOT 开始,寻找对应的 引用节点,找到这个节点后,继续寻找 这个节点引用节点。当所有的引用节点寻找完毕后,剩余的节点 则被认为是 没有被引用到 的节点,即 无用 的节点。

img

上图 红色 为无用的节点,可以被 回收。目前 Java 中可以作为 GC ROOT 的对象有:

  1. 虚拟机栈 中引用的对象(本地变量表);
  2. 方法区静态变量 引用的对象;
  3. 方法区常量 引用的对象;
  4. 本地方法栈 中引用的对象(Native 对象)。

基本所有 GC 算法都引用 根搜索算法 这种概念。

2. 标记 - 清除算法

标记-清除算法根集合 进行扫描,对 存活的对象 进行 标记。标记完毕后,再扫描整个空间中 未被标记 的对象进行 直接回收,如下图所示:

img

标记-清除算法 不需要进行 对象的移动,并且仅对 不存活 的对象进行处理,在 存活 的对象 比较多 的情况下 极为高效。但由于 标记-清除算法 直接回收不存活的对象,并没有对还存活的对象进行 整理,因此会导致 内存碎片

3. 复制算法

复制算法 将内存划分为 两个区间,使用此算法时,所有 动态分配 的对象都只能分配在 其中一个 区间(活动区间),而 另外一个 区间(空间区间)则是 空闲 的。

复制算法 同样从 根集合 扫描,将 存活 的对象 复制空闲区间。当扫描完毕活动区间后,会的将 活动区间 一次性全部 回收。此时原本的 空闲区间 变成了 活动区间。下次 GC 时候又会重复刚才的操作,以此循环。

img

复制算法 在存活对象 比较少 的时候,极为高效,但是带来的成本是 牺牲一半的内存空间 用于进行 对象的移动。所以 复制算法 的使用场景,必须是对象的 存活率非常低 才行。最重要的是,我们需要克服 50%内存浪费

4. 标记 - 整理算法

标记-整理算法 采用 标记-清除算法 一样的方式进行对象的 标记,但在回收 不存活的对象 占用的空间后,会将所有 存活的对象 往 左端空闲空间 移动,并更新对应的指针。

img

标记-整理 是在 标记-清除 之上,又进行了 对象的移动排序整理,因此 成本更高,但却解决了 内存碎片 的问题。

JVM 为了 优化内存 的回收,使用了 分代回收 的方式。对于 新生代内存 的回收(Minor GC)主要采用 复制算法。而对于 老年代内存 的回收(Major GC),大多采用 标记-整理算法

垃圾回收器

1. 垃圾回收器分类标准

img

2. 七种垃圾回收器概述

JVM 中,具体实现有 SerialParNewParallel ScavengeCMSSerial Old(MSC)Parallel OldG1 等。在下图中,你可以看到 不同垃圾回收器 适合于 不同的内存区域,如果两个垃圾回收器之间 存在连线,那么表示两者可以 配合使用

如果当 垃圾回收器 进行垃圾清理时,必须 暂停 其他所有的 工作线程,直到它完全收集结束。我们称这种需要暂停工作线程才能进行清理的策略为 Stop-the-World。以上回收器中, SerialParNewParallel ScavengeSerial OldParallel Old 均采用的是 Stop-the-World 的策略。

img

图中有 7 种不同的 垃圾回收器,它们分别用于不同分代的垃圾回收。

  • 新生代回收器:Serial、ParNew、Parallel Scavenge
  • 老年代回收器:Serial Old、Parallel Old、CMS
  • 整堆回收器:G1

两个 垃圾回收器 之间有连线表示它们可以 搭配使用,可选的搭配方案如下:

新生代 老年代
Serial Serial Old
Serial CMS
ParNew Serial Old
ParNew CMS
Parallel Scavenge Serial Old
Parallel Scavenge Parallel Old
G1 G1

3. 单线程垃圾回收器

3.1. Serial(-XX:+UseSerialGC)

Serial 回收器是最基本的 新生代 垃圾回收器,是 单线程 的垃圾回收器。由于垃圾清理时,Serial 回收器 不存在 线程间的切换,因此,特别是在单 CPU 的环境下,它的 垃圾清除效率 比较高。对于 Client 运行模式的程序,选择 Serial 回收器是一个不错的选择。

Serial 新生代回收器 采用的是 复制算法

3.2. Serial Old(-XX:+UseSerialGC)

Serial Old 回收器是 Serial 回收器的 老生代版本,属于 单线程回收器,它使用 标记-整理 算法。对于 Server 模式下的虚拟机,在 JDK1.5 及其以前,它常与 Parallel Scavenge 回收器配合使用,达到较好的 吞吐量,另外它也是 CMS 回收器在 Concurrent Mode Failure 时的 后备方案

Serial 回收器和 Serial Old 回收器的执行效果如下:

Serial Old 老年代回收器 采用的是 标记 - 整理算法

4. 多线程垃圾回收器(吞吐量优先)

4.1. ParNew(-XX:+UseParNewGC)

ParNew 回收器是在 Serial 回收器的基础上演化而来的,属于 Serial 回收器的 多线程版本,同样运行在 新生代区域。在实现上,两者共用很多代码。在不同运行环境下,根据 CPU 核数,开启 不同的线程数,从而达到 最优 的垃圾回收效果。对于那些 Server 模式的应用程序,如果考虑采用 CMS 作为 老生代回收器 时,ParNew 回收器是一个不错的选择。

img

ParNew 新生代回收器 采用的是 复制算法

4.2. Parallel Scavenge(-XX:+UseParallelGC)

ParNew 回收一样,Parallel Scavenge 回收器也是运行在 新生代区域,属于 多线程 的回收器。但不同的是,ParNew 回收器是通过控制 垃圾回收线程数 来进行参数调整,而 Parallel Scavenge 回收器更关心的是 程序运行的吞吐量。即一段时间内,用户代码 运行时间占 总运行时间 的百分比。

Parallel Scavenge 新生代回收器 采用的是 复制算法

4.3. Parallel Old(-XX:+UseParallelOldGC)

Parallel Old 回收器是 Parallel Scavenge 回收器的 老生代版本,属于 多线程回收器,采用 标记-整理算法Parallel Old 回收器和 Parallel Scavenge 回收器同样考虑了 吞吐量优先 这一指标,非常适合那些 注重吞吐量CPU 资源敏感 的场合。

img

Parallel Old 老年代回收器 采用的是 标记 - 整理算法

5. 其他的回收器(停顿时间优先)

5.1. CMS(-XX:+UseConcMarkSweepGC)

CMS(Concurrent Mark Sweep) 回收器是在 最短回收停顿时间 为前提的回收器,属于 多线程回收器,采用 标记-清除算法

img

相比之前的回收器,CMS 回收器的运作过程比较复杂,分为四步:

  1. 初始标记(CMS initial mark)

初始标记 仅仅是标记 GC Roots直接关联 的对象。这个阶段 速度很快,需要 Stop the World

  1. 并发标记(CMS concurrent mark)

并发标记 进行的是 GC Tracing,从 GC Roots 开始对堆进行 可达性分析,找出 存活对象

  1. 重新标记(CMS remark)

重新标记 阶段为了 修正 并发期间由于 用户进行运作 导致的 标记变动 的那一部分对象的 标记记录。这个阶段的 停顿时间 一般会比 初始标记阶段 稍长一些,但远比 并发标记 的时间短,也需要 Stop The World

  1. 并发清除(CMS concurrent sweep)

并发清除 阶段会清除垃圾对象。

初始标记CMS initial mark)和 重新标记CMS remark)会导致 用户线程 卡顿,Stop the World 现象发生。

在整个过程中,CMS 回收器的 内存回收 基本上和 用户线程 并发执行,如下所示:

由于 CMS 回收器 并发收集停顿低,因此有些地方成为 并发低停顿回收器Concurrent Low Pause Sweep Collector)。

CMS 回收器的缺点:

  1. CMS回收器对CPU资源非常依赖

CMS 回收器过分依赖于 多线程环境,默认情况下,开启的 线程数(CPU 的数量 + 3)/ 4,当 CPU 数量少于 4 个时,CMS用户查询 的影响将会很大,因为他们要分出一半的运算能力去 执行回收器线程

  1. CMS回收器无法清除浮动垃圾

由于 CMS 回收器 清除已标记的垃圾 (处于最后一个阶段)时,用户线程 还在运行,因此会有新的垃圾产生。但是这部分垃圾 未被标记,在下一次 GC 才能清除,因此被成为 浮动垃圾

由于 内存回收用户线程 是同时进行的,内存在被 回收 的同时,也在被 分配。当 老生代 中的内存使用超过一定的比例时,系统将会进行 垃圾回收;当 剩余内存 不能满足程序运行要求时,系统将会出现 Concurrent Mode Failure,临时采用 Serial Old 算法进行 清除,此时的 性能 将会降低。

  1. 垃圾收集结束后残余大量空间碎片

CMS 回收器采用的 标记清除算法,本身存在垃圾收集结束后残余 大量空间碎片 的缺点。CMS 配合适当的 内存整理策略,在一定程度上可以解决这个问题。

5.2. G1回收器(垃圾区域Region优先)

G1JDK 1.7 中正式投入使用的用于取代 CMS压缩回收器。它虽然没有在物理上隔断 新生代老生代,但是仍然属于 分代垃圾回收器G1 仍然会区分 年轻代老年代,年轻代依然分有 Eden 区与 Survivor 区。

G1 首先将 分为 大小相等Region,避免 全区域 的垃圾回收。然后追踪每个 Region 垃圾 堆积的价值大小,在后台维护一个 优先列表,根据允许的回收时间优先回收价值最大的 Region。同时 G1采用 Remembered Set 来存放 Region 之间的 对象引用 ,其他回收器中的 新生代老年代 之间的对象引用,从而避免 全堆扫描G1 的分区示例如下图所示:

img

这种使用 Region 划分 内存空间 以及有 优先级 的区域回收方式,保证 G1 回收器在有限的时间内可以获得尽可能 高的回收效率

G1CMS 运作过程有很多相似之处,整个过程也分为 4 个步骤:

  1. 初始标记(CMS initial mark)

初始标记 仅仅是标记 GC Roots直接关联 的对象。这个阶段 速度很快,需要 Stop the World

  1. 并发标记(CMS concurrent mark)

并发标记 进行的是 GC Tracing,从 GC Roots 开始对堆进行 可达性分析,找出 存活对象

  1. 重新标记(CMS remark)

重新标记 阶段为了 修正 并发期间由于 用户进行运作 导致的 标记变动 的那一部分对象的 标记记录。这个阶段的 停顿时间 一般会比 初始标记阶段 稍长一些,但远比 并发标记 的时间短,也需要 Stop The World

  1. 筛选回收

首先对各个 Region回收价值成本 进行排序,根据用户所期望的 GC 停顿时间 来制定回收计划。这个阶段可以与用户程序一起 并发执行,但是因为只回收一部分 Region,时间是用户可控制的,而且停顿 用户线程 将大幅提高回收效率。

与其它 GC 回收相比,G1 具备如下 4 个特点:

  • 并行与并发

使用多个 CPU 来缩短 Stop-the-World停顿时间,部分其他回收器需要停顿 Java 线程执行的 GC 动作,G1 回收器仍然可以通过 并发的方式Java 程序继续执行。

  • 分代回收

与其他回收器一样,分代概念G1 中依然得以保留。虽然 G1 可以不需要 其他回收器配合 就能独立管理 整个GC堆,但它能够采用 不同的策略 去处理 新创建的对象已经存活 一段时间、熬过多次 GC 的旧对象,以获取更好的回收效果。新生代老年代 不再是 物理隔离,是多个 大小相等 的独立 Region

  • 空间整合

CMS标记—清理 算法不同,G1整体 来看是基于 标记—整理 算法实现的回收器。从 局部(两个 Region 之间)上来看是基于 复制算法 实现的。

但无论如何,这 两种算法 都意味着 G1 运作期间 不会产生内存空间碎片,回收后能提供规整的可用内存。这种特性有利于程序长时间运行,分配大对象 时不会因为无法找到 连续内存空间 而提前触发 下一次 GC

  • 可预测的停顿

这是 G1 相对于 CMS 的另一大优势,降低停顿时间G1CMS 共同的关注点。G1 除了追求 低停顿 外,还能建立 可预测停顿时间模型,能让使用者明确指定在一个 长度M 毫秒的 时间片段 内,消耗在 垃圾回收 上的时间不得超过 N 毫秒。(后台维护的 优先列表,优先回收 价值大Region)。

参考

周志明,深入理解Java虚拟机:JVM高级特性与最佳实践,机械工业出版社


本文整理自

JVM垃圾回收器

仅做个人学习总结所用,遵循CC 4.0 BY-SA版权协议,如有侵权请联系删除!


内部类就是定义在一个类中的另外一个类,是一种从属关系。在没有实际了解内部类之前,我始终困惑,为什么要在一个类中定义另外一个类,这不是增加代码结构复杂度么?现在才大致能知道这种设计的优势是大于其劣势的。比如,我们可以通过内部类解决类的单继承问题,外部类不能再继承的类可以交给内部类继承。我们可以通过定义内部类来实现一个类私属于一个类,实现更好的封装性。具体的我们接下来介绍,本文主要通过介绍内部类的四种不同类型的定义,实例的创建,内部实现原理以及使用场景几种不同角度来学习内部类。

  • 静态内部类
  • 成员内部类
  • 方法内部类
  • 匿名内部类

一、静态内部类

静态内部类的定义和普通的静态变量或者静态方法的定义方法是一样的,使用static关键字,只不过这次static是修饰在class上的,一般而言,只有静态内部类才允许使用static关键字修饰,普通类的定义是不能用static关键字修饰的,这一点需要注意一下。下面定义一个静态内部类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Out {
private static String name;
private int age;

public static class In{
private int age;
public void sayHello(){

System.out.println("my name is : "+name);
//--编译报错---
//System.out.println("my age is :"+ age);
}
}
}

在上述代码中,In这个类就是一个静态内部类。我们说内部类是可以访问外部类的私有字段和私有方法的,对于静态内部类,它遵循一致的原则,只能访问外部类的静态成员。上述代码中,外部类的非静态私有字段age在静态内部类中使不允许访问的,而静态字段name则是可访问的。下面我们看,如何创建一个静态内部类的实例对象。

1
2
3
4
public static void main(String [] args){
Out.In innerClass = new Out.In();
innerClass.sayHello();
}

静态内部类的实例对象创建还是比较简洁的,不同于成员内部类,它不需要关联外部类实例(具体的下文介绍),下面我们再看一段代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Out {
private static String name;

public static class In{
public void sayHello(){
System.out.println(name);
showName();
}
}

private static void showName(){
System.out.println(name);
}
}

上述代码在内部类中两次访问了外部类的静态成员,第一次访问了静态字段name,第二次访问的静态方法showName。在我们反编译这个类之前,首先需要知道的是,所谓的内部类的概念只是出现在编译阶段,对于jvm层是没有内部类这个概念的。也就是说,编译器会将一个类编译成一个源文件,对于内部类也是一样,它会从它的外部类中抽离出来,增加一些与外部类的联系,然后被编译成一个单独的源文件。下面我们先编译运行之后,利用Dj反编译class文件看看编译器都做了些什么事情。

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
//这是我们的Out外部类
public class Out
{
//省去了一些不重要的部分
private static void showName()
{
System.out.println(name);
}

private static String name;

static String access$000(){return name;}
static void access$100(){showName();}

}
//这是我们的内部类
public static class Out$In
{

public void sayHello()
{
System.out.println(Out.access$000());
Out.access$100();
}

public Out$In()
{
}
}

相信大家也已经看出来这两者之间的某种联系,编译器将Out这个类编译成两个独立的class源文件。对于Out中所有的私有成员(也就是内部类分离出去之后不能访问的成员),增设了可供调用的access$xxx方法,从而实现内部类与外部类之间的联系。这就是他们的本质。

至于使用场景,一般来说,对于和外部类联系紧密但是并不依赖于外部类实例的情况下,可以考虑定义成静态内部类。下面我们看稍显复杂的成员内部类。

二、成员内部类

我们说了,四种不同类型的内部类都各自有各自的使用场景,静态内部类适合于那种和外部类关系密切但是并不依赖外部类实例的情况。但是对于需要和外部类实例相关联的情况下,可以选择将内部类定义成成员内部类。以下代码定义了一个简单的成员内部类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Out {
private String name;

public void showName(){
System.out.println("my name is : "+name);
}

public class In{
public void sayHello(){
System.out.println(name);
Out.this.showName();
}
}
}

以上定义了一个简单的内部类In,我们的成员内部类可以直接访问外部类的成员字段和成员方法,因为它是关联着一个外部类实例的。下面我们看看在外部是如何创建该内部类实例的。

1
2
3
4
5
public static void main(String [] args){
Out out = new Out();
Out.In in = out.new In();
in.sayHello();
}

因为成员内部类是关联着一个具体的外部类实例的,所以它的实例创建必然是由外部类实例来创建的。对于实例的创建,我们只需要记住即可,成员内部类的实例创建需要关联外部类实例对象,静态内部类实例创建相对简单。下面我们主要看看在编译阶段编译器是如何保持内部类对外部类成员信息可访问的。

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
//反编译的Out外部类源码
public class Out
{
//省略部分非核心代码
public void showName()
{
System.out.println((new StringBuilder()).append("my name is : ").append(name).toString());
}

private String name;

static String access$000(Out o){return o.name;}
}
//反编译的内部类In源码
public class Out$In
{
public void sayHello()
{
System.out.println(Out.access$000(Out.this));
showName();
}

final Out this$0;

public Out$In()
{
this.this$0 = Out.this;
super();
}
}

由上述代码其实我们可以知道,当我们利用外部类实例创建内部类实例的时候,会将外部类实例作为初始资源传入内部类构造过程。这样我们就可以通过该实例访问外部类所有的成员信息,包括私有成员。(显式增加了暴露方法)

至于使用场景,对于那种要高度依赖外部类实例的情况下,定义一个成员内部类则会显的更加明智。

三、方法(局部)内部类

方法内部类,顾名思义,定义在一个方法内部的类。方法内部类相对而言要复杂一些,下面定义一个方法内部类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Out {
private String name;

public void sayHello(){
class In{
public void showName(){
System.out.println("my name is : "+name);
}
}

In in = new In();
in.showName();
}
}

我们定义了一个类,在该类中又定义了一个方法sayHello,然而在该方法中我们定义了一个内部类,类In就是一个方法内部类。我们的方法内部类的生命周期不超过包含它的方法的生命周期,也就是说,方法内部类只能在方法中使用。所以在声明的时候,任何的访问修饰符都是没有意义的,于是Java干脆不允许使用任何的访问修饰符修饰方法内部类。其中还需要注意一点的是,定义和使用时两回事,别看那一大串定义类的代码,你实际想要使用该类,就必须new对象,而对于方法内部类而言,只能在方法内部new对象。这就是方法内部类的简单介绍,下面我们看看其实现原理。

有关方法内部类的实现原理其实是和成员内部类差不太多的,也是在内部类初始化的时候为其传入一个外部类实例,区别在哪呢?就在于方法内部类是定义在具体方法的内部的,所以该类除了可以通过传入的外部实例访问外部类中的字段和方法,对于包含它的方法中被传入的参数也会随着外部类实例一起初始化给内部类。

毋庸置疑的是,方法内部类的封装性比之前介绍的两种都要完善。所以一般只有在需要高度封装的时候才会将类定义成方法内部类。

四、匿名内部类

可能内部类的所有分类中,匿名内部类的名号是最大的,也是我们最常用到的,多见于函数式编程,lambda表达式等。下面我们重点看看这个匿名内部类。

匿名内部类就是没有名字的内部类,在定义完成同时,实例也创建好了,常常和new关键字紧密结合。当然,它也不局限于类,也可以是接口 ,可以出现在任何位置。下面我们定义一个匿名内部类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
//首先定义一个普通类
public class Out {
private String name;

private void sayHello(){
System.out.println("my name is :" + name);
}
}
//定义和使用一个匿名内部类
public static void main(String [] args){
Out out = new Out(){
@Override
public void sayHello(){
System.out.println("my name is cyy");
}
public void showName(){
System.out.println("hello single");
}
};
out.sayHello();
}

从上述代码中可以很显然的让我们看出来,我们的匿名内部类必定是要依托一个父类的,因为它是没有名字的,无法用一个具体的类型来表示。所以匿名内部类往往都是通过继承一个父类,重写或者重新声明一些成员来实现一个匿名内部类的定义。实际上还是利用了里式转换原理。

从中我们也可以看到,一个匿名内部类定义的完成就意味着该内部类实例创建的完成。下面我们看看其实现原理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//反编译出来的匿名内部类
static class Test$1 extends Out
{
Out out;
public void sayHello()
{
System.out.println("my name is cyy");
}

Test$1(Out o)
{
this.out = o;
}
}

其实在看了上述三种内部类的原理之后,反而觉得匿名内部类的实现较为简单了。主要思路还是将内部类抽离出来,通过初始化传入外部类的实例以达到对外部类所有成员的访问。只是在匿名内部类中,被依托的父类不是他的外部类。匿名内部类的主要特点在于,没有名字,对象只能被使用一次,可以出现在任意位置。所以它的使用场景也是呼之欲出,对于一些对代码简洁度有所要求的情况下,可首选匿名内部类。

以上完成了对四种内部类的简单介绍,对于他们各自实现的原理也都已经介绍过了。其实大致相同,由于jvm对每个类都要求一个单独的源码文件,所以编译阶段就完成了分离的操作,但是在分离的过程中又要保持内部类和外部类之间的这种联系,于是编译器添加了一些接口保持这种信息共享的结构。使用内部类可以大大增加程序的封装性,使得代码整体简洁度较高。


本文整理自

深入理解Java内部类

仅做个人学习总结所用,遵循CC 4.0 BY-SA版权协议,如有侵权请联系删除!


栈(Stack)是一种后进先出(LIFO:Last In First Out)的数据结构。

什么是LIFO呢?我们先回顾一下Queue的特点FIFO:

1
2
3
4
5
          ────────────────────────
(\(\ (\(\ (\(\ (\(\ (\(\
(='.') ─> (='.') (='.') (='.') ─> (='.')
O(_")") O(_")") O(_")") O(_")") O(_")")
────────────────────────

所谓FIFO,是最先进队列的元素一定最早出队列,而LIFO是最后进Stack的元素一定最早出Stack。如何做到这一点呢?只需要把队列的一端封死:

1
2
3
4
5
           ───────────────────────────────┐
(\(\ (\(\ (\(\ (\(\ (\(\ │
(='.') <─> (='.') (='.') (='.') (='.')│
O(_")") O(_")") O(_")") O(_")") O(_")")│
───────────────────────────────┘

因此,Stack是这样一种数据结构:只能不断地往Stack中压入(push)元素,最后进去的必须最早弹出(pop)来:

donuts-stack

Stack只有入栈和出栈的操作:

  • 把元素压栈:push(E)
  • 把栈顶的元素“弹出”:pop(E)
  • 取栈顶元素但不弹出:peek(E)

在Java中,我们用Deque可以实现Stack的功能:

  • 把元素压栈:push(E)/addFirst(E)
  • 把栈顶的元素“弹出”:pop(E)/removeFirst()
  • 取栈顶元素但不弹出:peek(E)/peekFirst()

为什么Java的集合类没有单独的Stack接口呢?因为有个遗留名字就叫Stack, 继承自Vector,已不建议使用.

1
2
3
public class Stack<E> extends Vector<E> {

}

出于兼容性考虑,所以没办法创建Stack接口,只能用Deque接口来“模拟”一个Stack

当我们把Deque作为Stack使用时,注意只调用push()/pop()/peek()方法,不要调用addFirst()/removeFirst()/peekFirst()方法,这样代码更加清晰。

Stack的作用

Stack在计算机中使用非常广泛,JVM在处理Java方法调用的时候就会通过栈这种数据结构维护方法调用的层次。例如:

1
2
3
4
5
6
7
8
9
10
11
static void main(String[] args) {
foo(123);
}

static String foo(x) {
return "F-" + bar(x + 1);
}

static int bar(int x) {
return x << 2;
}

JVM会创建方法调用栈,每调用一个方法时,先将参数压栈,然后执行对应的方法;当方法返回时,返回值压栈,调用方法通过出栈操作获得方法返回值。

因为方法调用栈有容量限制,嵌套调用过多会造成栈溢出,即引发StackOverflowError.

我们再来看一个Stack的用途:对整数进行进制的转换就可以利用栈。

例如,我们要把一个int整数12500转换为十六进制表示的字符串,如何实现这个功能?

首先我们准备一个空栈:

1
2
3
4
5
6
7
8
9
│   │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
└───┘

然后计算12500÷16=781…4,余数是4,把余数4压栈:

1
2
3
4
5
6
7
8
9
│   │
│ │
│ │
│ │
│ │
│ │
│ │
│ 4 │
└───┘

然后计算781÷16=48…13,余数是1313的十六进制用字母D表示,把余数D压栈:

1
2
3
4
5
6
7
8
9
│   │
│ │
│ │
│ │
│ │
│ D │
│ │
│ 4 │
└───┘

然后计算48÷16=3…0,余数是0,把余数0压栈:

1
2
3
4
5
6
7
8
9
│   │
│ │
│ │
│ 0 │
│ │
│ D │
│ │
│ 4 │
└───┘

最后计算3÷16=0…3,余数是3,把余数3压栈:

1
2
3
4
5
6
7
8
9
│   │
│ 3 │
│ │
│ 0 │
│ │
│ D │
│ │
│ 4 │
└───┘

当商是0的时候,计算结束,我们把栈的所有元素依次弹出,组成字符串30D4,这就是十进制整数12500的十六进制表示的字符串。

计算中缀表达式

在编写程序的时候,我们使用的带括号的数学表达式实际上是中缀表达式,即运算符在中间,例如:1 + 2 * (9 - 5)

但是计算机执行表达式的时候,它并不能直接计算中缀表达式,而是通过编译器把中缀表达式转换为后缀表达式,例如:1 2 9 5 - * +

这个编译过程就会用到栈。我们先跳过编译这一步(涉及运算优先级,代码比较复杂),看看如何通过栈计算后缀表达式。

计算后缀表达式不考虑优先级,直接从左到右依次计算,因此计算起来简单。首先准备一个空的栈:

1
2
3
4
5
6
7
8
9
│   │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
└───┘

然后我们依次扫描后缀表达式1 2 9 5 - * +,遇到数字1,就直接扔到栈里:

1
2
3
4
5
6
7
8
9
│   │
│ │
│ │
│ │
│ │
│ │
│ │
│ 1 │
└───┘

紧接着,遇到数字295,也扔到栈里:

1
2
3
4
5
6
7
8
9
│   │
│ 5 │
│ │
│ 9 │
│ │
│ 2 │
│ │
│ 1 │
└───┘

接下来遇到减号时,弹出栈顶的两个元素,并计算9-5=4,把结果4压栈:

1
2
3
4
5
6
7
8
9
│   │
│ │
│ │
│ 4 │
│ │
│ 2 │
│ │
│ 1 │
└───┘

接下来遇到*号时,弹出栈顶的两个元素,并计算2*4=8,把结果8压栈:

1
2
3
4
5
6
7
8
9
│   │
│ │
│ │
│ │
│ │
│ 8 │
│ │
│ 1 │
└───┘

接下来遇到+号时,弹出栈顶的两个元素,并计算1+8=9,把结果9压栈:

1
2
3
4
5
6
7
8
9
│   │
│ │
│ │
│ │
│ │
│ │
│ │
│ 9 │
└───┘

扫描结束后,没有更多的计算了,弹出栈的唯一一个元素,得到计算结果9

小结

栈(Stack)是一种后进先出(LIFO)的数据结构,操作栈的元素的方法有:

  • 把元素压栈:push(E)
  • 把栈顶的元素“弹出”:pop(E)
  • 取栈顶元素但不弹出:peek(E)

在Java中,我们用Deque可以实现Stack的功能,注意只调用push()/pop()/peek()方法,避免调用Deque的其他方法。

最后,不要使用遗留类Stack


本文整理自

使用Stack

仅做个人学习总结所用,遵循CC 4.0 BY-SA版权协议,如有侵权请联系删除!


接口Queue

队列(Queue)是一种经常使用的集合。Queue实际上是实现了一个先进先出(FIFO:First In First Out)的有序表。它和List的区别在于,List可以在任意位置添加和删除元素,而Queue只有两个操作:

  • 把元素添加到队列末尾;
  • 从队列头部取出元素。

超市的收银台就是一个队列:

queue

在Java的标准库中,队列接口Queue定义了以下几个方法:

  • int size():获取队列长度;
  • boolean add(E)/boolean offer(E):添加元素到队尾;
  • E remove()/E poll():获取队首元素并从队列中删除;
  • E element()/E peek():获取队首元素但并不从队列中删除。

对于具体的实现类,有的Queue有最大队列长度限制,有的Queue没有。注意到添加、删除和获取队列元素总是有两个方法,这是因为在添加或获取元素失败时,这两个方法的行为是不同的。我们用一个表格总结如下:

throw Exception 返回false或null
添加元素到队尾 add(E e) boolean offer(E e)
取队首元素并删除 E remove() E poll()
取队首元素但不删除 E element() E peek()

举个栗子,假设我们有一个队列,对它做一个添加操作,如果调用add()方法,当添加失败时(可能超过了队列的容量),它会抛出异常:

1
2
3
4
5
6
7
Queue<String> q = ...
try {
q.add("Apple");
System.out.println("添加成功");
} catch(IllegalStateException e) {
System.out.println("添加失败");
}

如果我们调用offer()方法来添加元素,当添加失败时,它不会抛异常,而是返回false

1
2
3
4
5
6
Queue<String> q = ...
if (q.offer("Apple")) {
System.out.println("添加成功");
} else {
System.out.println("添加失败");
}

当我们需要从Queue中取出队首元素时,如果当前Queue是一个空队列,调用remove()方法,它会抛出异常:

1
2
3
4
5
6
7
Queue<String> q = ...
try {
String s = q.remove();
System.out.println("获取成功");
} catch(IllegalStateException e) {
System.out.println("获取失败");
}

如果我们调用poll()方法来取出队首元素,当获取失败时,它不会抛异常,而是返回null

1
2
3
4
5
6
7
Queue<String> q = ...
String s = q.poll();
if (s != null) {
System.out.println("获取成功");
} else {
System.out.println("获取失败");
}

因此,两套方法可以根据需要来选择使用。

注意:不要把null添加到队列中,否则poll()方法返回null时,很难确定是取到了null元素还是队列为空。

LinkedList既实现了List接口,又实现了Queue接口,但是,在使用的时候,如果我们把它当作List,就获取List的引用,如果我们把它当作Queue,就获取Queue的引用:

1
2
3
4
// 这是一个List:
List<String> list = new LinkedList<>();
// 这是一个Queue:
Queue<String> queue = new LinkedList<>();

始终按照面向抽象编程的原则编写代码,可以大大提高代码的质量。

小结

队列Queue实现了一个先进先出(FIFO)的数据结构:

  • 通过add()/offer()方法将元素添加到队尾;
  • 通过remove()/poll()从队首获取元素并删除;
  • 通过element()/peek()从队首获取元素但不删除。

要避免把null添加到队列。

使用PriorityQueue

我们知道,Queue是一个先进先出(FIFO)的队列。

在银行柜台办业务时,我们假设只有一个柜台在办理业务,但是办理业务的人很多,怎么办?

可以每个人先取一个号,例如:A1A2A3……然后,按照号码顺序依次办理,实际上这就是一个Queue

如果这时来了一个VIP客户,他的号码是V1,虽然当前排队的是A10A11A12……但是柜台下一个呼叫的客户号码却是V1

这个时候,我们发现,要实现“VIP插队”的业务,用Queue就不行了,因为Queue会严格按FIFO的原则取出队首元素。我们需要的是优先队列:PriorityQueue

PriorityQueueQueue的区别在于,它的出队顺序与元素的优先级有关,对PriorityQueue调用remove()poll()方法,返回的总是优先级最高的元素。

要使用PriorityQueue,我们就必须给每个元素定义“优先级”。我们以实际代码为例,先看看PriorityQueue的行为:

1
2
3
4
5
6
7
8
9
10
11
12
13
public class Main {
public static void main(String[] args) {
Queue<String> q = new PriorityQueue<>();
// 添加3个元素到队列:
q.offer("apple");
q.offer("pear");
q.offer("banana");
System.out.println(q.poll()); // apple
System.out.println(q.poll()); // banana
System.out.println(q.poll()); // pear
System.out.println(q.poll()); // null,因为队列为空
}
}

我们放入的顺序是"apple""pear""banana",但是取出的顺序却是"apple""banana""pear",这是因为从字符串的排序看,"apple"排在最前面,"pear"排在最后面。

因此,放入PriorityQueue的元素,必须实现Comparable接口PriorityQueue会根据元素的排序顺序决定出队的优先级。

如果我们要放入的元素并没有实现Comparable接口怎么办?PriorityQueue允许我们提供一个Comparator对象来判断两个元素的顺序。我们以银行排队业务为例,实现一个PriorityQueue

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 class Main {
public static void main(String[] args) {
Queue<User> q = new PriorityQueue<>(new UserComparator());
// 添加3个元素到队列:
q.offer(new User("Bob", "A1"));
q.offer(new User("Alice", "A2"));
q.offer(new User("Boss", "V1"));
System.out.println(q.poll()); // Boss/V1
System.out.println(q.poll()); // Bob/A1
System.out.println(q.poll()); // Alice/A2
System.out.println(q.poll()); // null,因为队列为空
}
}

class UserComparator implements Comparator<User> {
public int compare(User u1, User u2) {
if (u1.number.charAt(0) == u2.number.charAt(0)) {
// 如果两人的号都是A开头或者都是V开头,比较号的大小:
return u1.number.compareTo(u2.number);
}
if (u1.number.charAt(0) == 'V') {
// u1的号码是V开头,优先级高:
return -1;
} else {
return 1;
}
}
}

class User {
public final String name;
public final String number;

public User(String name, String number) {
this.name = name;
this.number = number;
}

public String toString() {
return name + "/" + number;
}
}

实现PriorityQueue的关键在于提供的UserComparator对象,它负责比较两个元素的大小(较小的在前)。UserComparator总是把V开头的号码优先返回,只有在开头相同的时候,才比较号码大小。

上面的UserComparator的比较逻辑其实还是有问题的,它会把A10排在A2的前面,请尝试修复该错误。

小结

PriorityQueue实现了一个优先队列:从队首获取元素时,总是获取优先级最高的元素。

PriorityQueue默认按元素比较的顺序排序(必须实现Comparable接口),也可以通过Comparator自定义排序算法(元素就不必实现Comparable接口)。

使用Deque

我们知道,Queue是队列,只能一头进,另一头出。

如果把条件放松一下,允许两头都进,两头都出,这种队列叫双端队列(Double Ended Queue),学名Deque

Java集合提供了接口Deque来实现一个双端队列,它的功能是:

  • 既可以添加到队尾,也可以添加到队首;
  • 既可以从队首获取,又可以从队尾获取。

我们来比较一下QueueDeque出队和入队的方法:

Queue Deque
添加元素到队尾 add(E e) / offer(E e) addLast(E e) / offerLast(E e)
取队首元素并删除 E remove() / E poll() E removeFirst() / E pollFirst()
取队首元素但不删除 E element() / E peek() E getFirst() / E peekFirst()
添加元素到队首 addFirst(E e) / offerFirst(E e)
取队尾元素并删除 E removeLast() / E pollLast()
取队尾元素但不删除 E getLast() / E peekLast()

对于添加元素到队尾的操作,Queue提供了add()/offer()方法,而Deque提供了addLast()/offerLast()方法。添加元素到对首、取队尾元素的操作在Queue中不存在,在Deque中由addFirst()/removeLast()等方法提供。

注意到Deque接口实际上扩展自Queue

1
2
3
public interface Deque<E> extends Queue<E> {
...
}

因此,Queue提供的add()/offer()方法在Deque中也可以使用,但是,使用Deque,最好不要调用offer(),而是调用offerLast()

1
2
3
4
5
6
7
8
9
10
11
12
public class Main {
public static void main(String[] args) {
Deque<String> deque = new LinkedList<>();
deque.offerLast("A"); // A
deque.offerLast("B"); // B -> A
deque.offerFirst("C"); // B -> A -> C
System.out.println(deque.pollFirst()); // C, 剩下B -> A
System.out.println(deque.pollLast()); // B
System.out.println(deque.pollFirst()); // A
System.out.println(deque.pollFirst()); // null
}
}

如果直接写deque.offer(),我们就需要思考,offer()实际上是offerLast(),我们明确地写上offerLast(),不需要思考就能一眼看出这是添加到队尾。

因此,使用Deque,推荐总是明确调用offerLast()/offerFirst()或者pollFirst()/pollLast()方法。

Deque是一个接口,它的实现类有ArrayDequeLinkedList

我们发现LinkedList真是一个全能选手,它即是List,又是Queue,还是Deque。但是我们在使用的时候,总是用特定的接口来引用它,这是因为持有接口说明代码的抽象层次更高,而且接口本身定义的方法代表了特定的用途。

1
2
3
4
5
6
// 不推荐的写法:
LinkedList<String> d1 = new LinkedList<>();
d1.offerLast("z");
// 推荐的写法:
Deque<String> d2 = new LinkedList<>();
d2.offerLast("z");

可见面向抽象编程的一个原则就是:尽量持有接口,而不是具体的实现类。参考依赖倒转原则.

小结

Deque实现了一个双端队列(Double Ended Queue),它可以:

  • 将元素添加到队尾或队首:addLast()/offerLast()/addFirst()/offerFirst()
  • 从队首/队尾获取元素并删除:removeFirst()/pollFirst()/removeLast()/pollLast()
  • 从队首/队尾获取元素但不删除:getFirst()/peekFirst()/getLast()/peekLast()
  • 总是调用xxxFirst()/xxxLast()以便与Queue的方法区分开;
  • 避免把null添加到队列。

本文整理自

使用Queue

使用PriorityQueue

使用Deque

仅做个人学习总结所用,遵循CC 4.0 BY-SA版权协议,如有侵权请联系删除!


我们知道,Map用于存储key-value的映射,对于充当key的对象,是不能重复的,并且,不但需要正确覆写equals()方法,还要正确覆写hashCode()方法。

如果我们只需要存储不重复的key,并不需要存储映射的value,那么就可以使用Set

Set用于存储不重复的元素集合,它主要提供以下几个方法:

  • 将元素添加进Setboolean add(E e)
  • 将元素从Set删除:boolean remove(Object e)
  • 判断是否包含元素:boolean contains(Object e)

Set实际上相当于只存储key、不存储value的Map。我们经常用Set用于去除重复元素。

因为放入Set的元素和Map的key类似,都要正确实现equals()hashCode()方法,否则该元素无法正确地放入Set

最常用的Set实现类是HashSet,实际上,HashSet仅仅是对HashMap的一个简单封装,它的核心代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class HashSet<E> implements Set<E> {
// 持有一个HashMap:
private HashMap<E, Object> map = new HashMap<>();

// 放入HashMap的value:
private static final Object PRESENT = new Object();

public boolean add(E e) {
return map.put(e, PRESENT) == null;
}

public boolean contains(Object o) {
return map.containsKey(o);
}

public boolean remove(Object o) {
return map.remove(o) == PRESENT;
}
}

Set接口并不保证有序,而SortedSet接口则保证元素是有序的:

  • HashSet是无序的,因为它实现了Set接口,并没有实现SortedSet接口;
  • TreeSet是有序的,因为它实现了SortedSet接口。

用一张图表示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
       ┌───┐
│Set│
└───┘

┌────┴─────┐
│ │
┌───────┐ ┌─────────┐
│HashSet│ │SortedSet│
└───────┘ └─────────┘


┌─────────┐
│ TreeSet │
└─────────┘

HashSet遍历的输出顺序既不是添加的顺序,也不是String排序的顺序,在不同版本的JDK中,这个顺序也可能是不同的。

HashSet换成TreeSet,在遍历TreeSet时,输出就是有序的,这个顺序是元素的排序顺序.

使用TreeSet和使用TreeMap的要求一样,添加的元素必须正确实现Comparable接口,如果没有实现Comparable接口,那么创建TreeSet时必须传入一个Comparator对象。

小结

Set用于存储不重复的元素集合:

  • 放入HashSet的元素与作为HashMap的key要求相同;
  • 放入TreeSet的元素与作为TreeMap的Key要求相同;

利用Set可以去除重复元素;

遍历SortedSet按照元素的排序顺序遍历,也可以自定义排序算法。


本文整理自

使用Set

仅做个人学习总结所用,遵循CC 4.0 BY-SA版权协议,如有侵权请联系删除!


在编写应用程序的时候,经常需要读写配置文件。例如,用户的设置:

1
2
3
4
# 上次最后打开的文件:
last_open_file=/data/hello.txt
# 自动保存文件的时间间隔:
auto_save_interval=60

配置文件的特点是,它的Key-Value一般都是String-String类型的,因此我们完全可以用Map来表示它。

因为配置文件非常常用,所以Java集合库提供了一个Properties来表示一组“配置”。由于历史遗留原因,Properties内部本质上是一个Hashtable,但我们只需要用到Properties自身关于读写配置的接口。

读取配置文件

Properties读取配置文件非常简单。Java默认配置文件以.properties为扩展名,每行以key=value表示,以#课开头的是注释。以下是一个典型的配置文件:

1
2
3
4
# setting.properties

last_open_file=/data/hello.txt
auto_save_interval=60

可以从文件系统读取这个.properties文件:

1
2
3
4
5
6
String f = "setting.properties";
Properties props = new Properties();
props.load(new java.io.FileInputStream(f));

String filepath = props.getProperty("last_open_file");
String interval = props.getProperty("auto_save_interval", "120");

可见,用Properties读取配置文件,一共有三步:

  1. 创建Properties实例;
  2. 调用load()读取文件;
  3. 调用getProperty()获取配置。

调用getProperty()获取配置时,如果key不存在,将返回null。我们还可以提供一个默认值,这样,当key不存在的时候,就返回默认值。

也可以从classpath读取.properties文件,因为load(InputStream)方法接收一个InputStream实例,表示一个字节流,它不一定是文件流,也可以是从jar包中读取的资源流:

1
2
Properties props = new Properties();
props.load(getClass().getResourceAsStream("/common/setting.properties"));

试试从内存读取一个字节流:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import java.io.*;
import java.util.Properties;

public class Main {
public static void main(String[] args) throws IOException {
String settings = "# test" + "\n" + "course=Java" + "\n" + "last_open_date=2019-08-07T12:35:01";
ByteArrayInputStream input = new ByteArrayInputStream(settings.getBytes("UTF-8"));
Properties props = new Properties();
props.load(input);

System.out.println("course: " + props.getProperty("course"));
System.out.println("last_open_date: " + props.getProperty("last_open_date"));
System.out.println("last_open_file: " + props.getProperty("last_open_file"));
System.out.println("auto_save: " + props.getProperty("auto_save", "60"));
}
}

如果有多个.properties文件,可以反复调用load()读取,后读取的key-value会覆盖已读取的key-value:

1
2
3
Properties props = new Properties();
props.load(getClass().getResourceAsStream("/common/setting.properties"));
props.load(new FileInputStream("C:\\conf\\setting.properties"));

上面的代码演示了Properties的一个常用用法:可以把默认配置文件放到classpath中,然后,根据机器的环境编写另一个配置文件,覆盖某些默认的配置。

Properties设计的目的是存储String类型的key-value,Properties实际上是从Hashtable派生的,它的设计实际上是有问题的,但是为了保持兼容性,现在已经没法修改了。除了getProperty()setProperty()方法外,还有从Hashtable继承下来的get()put()方法,这些方法的参数签名是Object,我们在使用Properties的时候,不要去调用这些从Hashtable继承下来的方法。

写入配置文件

如果通过setProperty()修改了Properties实例,可以把配置写入文件,以便下次启动时获得最新配置。写入配置文件使用store()方法:

1
2
3
4
Properties props = new Properties();
props.setProperty("url", "http://www.liaoxuefeng.com");
props.setProperty("language", "Java");
props.store(new FileOutputStream("C:\\conf\\setting.properties"), "这是写入的properties注释");

编码

早期版本的Java规定.properties文件编码是ASCII编码(ISO8859-1),如果涉及到中文就必须用name=\u4e2d\u6587来表示,非常别扭。从JDK9开始,Java的.properties文件可以使用UTF-8编码了。

不过,需要注意的是,由于load(InputStream)默认总是以ASCII编码读取字节流,所以会导致读到乱码。我们需要用另一个重载方法load(Reader)读取:

1
2
Properties props = new Properties();
props.load(new FileReader("settings.properties", StandardCharsets.UTF_8));

就可以正常读取中文。InputStreamReader的区别是一个是字节流,一个是字符流。字符流在内存中已经以char类型表示了,不涉及编码问题。

小结

Java集合库提供的Properties用于读写配置文件.properties.properties文件可以使用UTF-8编码。

可以从文件系统、classpath或其他任何地方读取.properties文件。

读写Properties时,注意仅使用getProperty()setProperty()方法,不要调用继承而来的get()put()等方法。


本文整理自

使用Properties

仅做个人学习总结所用,遵循CC 4.0 BY-SA版权协议,如有侵权请联系删除!