is
zhou

Java的io流总结

zhouchong阅读(16)评论(0)

1、流的概念和作用

  1. 流的概念是1984年由C语言第一次引入。“流”可以看作是一个流动的数据缓冲区。数据从数据源流向数据目的地。流在互联网上是串行传送。最常见的数据源就是键盘。最常见的数据目的地就是屏幕。
  2. 流是一组有顺序的,有起点和终点的字节集合,是对数据传输的总称或抽象。即数据在两设备间的传输称为流,流的本质是数据传输,根据数据传输特性将流抽象为各种类,方便更直观的进行数据操作。
    • 字节流,就是可以一个一个字节进行读取,写,可以处理所有文件数据。
    • 字符流,就是可以一个一个字符进行读取,写,基本上是对文本文件内容的。
  3. 《O’Reilly-Java Io》中是这么解释的:
    A stream is an ordered sequence of bytes of undetermined length. Input streams move bytes
    of data into a Java program from some generally external source. Output streams move bytes
    of data from Java to some generally external target. (In special cases streams can also move
    bytes from one part of a Java program to another.)
    流是一个不确定长度的有序字节序列。输入流从外部资源将数据字节移动到Java程序中。输出流从Java程序中将数据字节移动到外部目标。(特殊的情况也可以将字节从java程序中一部分移动到另一部分)
  4. 详解:
    参见:https://www.zhihu.com/question/28457447

2、流从哪里来?

  1. 网络
  2. 文件
  3. java内部程序

3、IO流的分类

  1. 根据处理数据类型的不同分为:字符流和字节流
  2. 根据数据流向不同分为:输入流和输出流

4、字符流和字节流

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

5、输入流和输出流

  1. 对输入流只能进行读操作,对输出流只能进行写操作,程序中需要根据待传输数据的不同特性而使用不同的流。

6、总览

1. Java.io包中最重要的就是5个类和一个接口。
  1. 5个类指的是File、OutputStream、InputStream、Writer、Reader;一个接口指的是Serializable。掌握了这些就掌握了Java I/O的精髓了。
2. Java I/O主要包括如下3层次
  1. 流式部分——最主要的部分。如:OutputStream、InputStream、Writer、Reader等
  2. 非流式部分——如:File类、RandomAccessFile类和FileDescriptor等类
  3. 其他——文件读取部分的与安全相关的类,如:SerializablePermission类,以及与本地操作系统相关的文件系统的类,如:FileSystem类和Win32FileSystem类和WinNTFileSystem类。
3. 主要类如下:
  1. File(文件特征与管理):用于文件或者目录的描述信息,例如生成新目录,修改文件名,删除文件,判断文件所在路径等。
  2. InputStream(字节流,二进制格式操作):抽象类,基于字节的输入操作,是所有输入流的父类。定义了所有输入流都具有的共同特征。
  3. OutputStream(字节流,二进制格式操作):抽象类。基于字节的输出操作。是所有输出流的父类。定义了所有输出流都具有的共同特征。
  4. Reader(字符流,文本格式操作):抽象类,基于字符的输入操作。
  5. Writer(字符流,文本格式操作):抽象类,基于字符的输出操作。
  6. RandomAccessFile(随机文件操作):它的功能丰富,可以从文件的任意位置进行存取(输入输出)操作。
4. I/O流
  1. java.io包里有4个基本类:InputStream、OutputStream及Reader、Writer类,它们分别处理字节流和字符流。
  2. 其他各种各样的流都是由这4个派生出来的。

7、如何选择I/O流

  1. 确定是输入还是输出
    输入:输入流 InputStream Reader
    输出:输出流 OutputStream Writer
  2. 明确操作的数据对象是否是纯文本
    是:字符流 Reader,Writer
    否:字节流 InputStream,OutputStream
  3. 明确具体的设备。
    • 文件:
      读:FileInputStream,, FileReader,
      写:FileOutputStream,FileWriter
    • 数组:
      byte[ ]:ByteArrayInputStream, ByteArrayOutputStream
      char[ ]:CharArrayReader, CharArrayWriter
    • String:
      StringBufferInputStream(已过时,因为其只能用于String的每个字符都是8位的字符串), StringReader, StringWriter
    • Socket流
      键盘:用System.in(是一个InputStream对象)读取,用System.out(是一个OutoutStream对象)打印
  4. 是否需要转换流
    是,就使用转换流,从Stream转化为Reader、Writer:InputStreamReader,OutputStreamWriter
  5. 是否需要缓冲提高效率
    是就加上Buffered:BufferedInputStream, BufferedOuputStream, BufferedReader, BufferedWriter
  6. 是否需要格式化输出

8、示例代码

  • 将标准输入(键盘输入)显示到标准输出(显示器),支持字符。
char ch;
BufferedReader in = new BufferedReader(new InputStreamReader(System.in));  //将字节流转为字符流,带缓冲
try {
    while ((ch = (char) in.read()) != -1){
        System.out.print(ch);
    }
} catch (IOException e) {
    e.printStackTrace();
}
  • 将AtomicityTest.java的内容打印到显示器

方法一:

BufferedReader in = new BufferedReader(new FileReader("AtomicityTest.java"));
String s;
try {
    while ((s = in.readLine()) != null){
        System.out.println(s);
    }
    in.close();
} catch (IOException e) {
    e.printStackTrace();
}

方法二:

FileReader in = new FileReader("AtomicityTest.java");
int b;
try {
    while ((b = in.read()) != -1){
        System.out.print((char)b);
    }
    in.close();
} catch (IOException e) {
    e.printStackTrace();
}

方法三:(有可能出现乱码)

FileInputStream in = new FileInputStream("AtomicityTest.java");
int n = 50;
byte[] buffer = new byte[n];
try {
    while ((in.read(buffer,0,n) != -1 && n > 0)){
        System.out.print(new String(buffer));
    }
    in.close();
} catch (IOException e) {
    e.printStackTrace();
}
  • 将文件A的内容拷贝到文件B
FileInputStream in = new FileInputStream("AtomicityTest.java");
FileOutputStream out = new FileOutputStream("copy.txt");
int b;
while ((b = in.read()) != -1){
    out.write(b);
}
out.flush();
in.close();
out.close();
  • 将标准输入的内容写入文件
Scanner in = new Scanner(System.in);
FileWriter out = new FileWriter("systemIn.log");
String s;
while (!(s = in.nextLine()).equals("Q")){
    out.write(s + "\n");
}
out.flush();
out.close();
in.close();

 

 

 

面向GC的JAVA编程

zhouchong阅读(54)评论(0)

Java程序员在编码过程中通常不需要考虑内存问题,JVM经过高度优化的GC机制大部分情况下都能够很好地处理堆(Heap)的清理问题。以至于许多Java程序员认为,我只需要关心何时创建对象,而回收对象,就交给GC来做吧!甚至有人说,如果在编程过程中频繁考虑内存问题,是一种退化,这些事情应该交给编译器,交给虚拟机来解决。

这话其实也没有太大问题,的确,大部分场景下关心内存、GC的问题,显得有点“杞人忧天”了,高老爷说过:

过早优化是万恶之源。

但另一方面,什么才是“过早优化”?

If we could do things right for the first time, why not?

事实上JVM的内存模型( JMM )理应是Java程序员的基础知识,处理过几次JVM线上内存问题之后就会很明显感受到,很多系统问题,都是内存问题。

对JVM内存结构感兴趣的同学可以看下 浅析Java虚拟机结构与机制 这篇文章,本文就不再赘述了,本文也并不关注具体的GC算法,相关的文章汗牛充栋,随时可查。

另外,不要指望GC优化的这些技巧,可以对应用性能有成倍的提高,特别是对I/O密集型的应用,或是实际落在YoungGC上的优化,可能效果只是帮你减少那么一点YoungGC的频率。

但我认为,优秀程序员的价值,不在于其所掌握的几招屠龙之术,而是在细节中见真著,就像前面说的,如果我们可以一次把事情做对,并且做好,在允许的范围内尽可能追求卓越,为什么不去做呢?

一、GC分代的基本假设

大部分GC算法,都将堆内存做分代(Generation)处理,但是为什么要分代呢,又为什么不叫内存分区、分段,而要用面向时间、年龄的“代”来表示不同的内存区域?

GC分代的基本假设是:

绝大部分对象的生命周期都非常短暂,存活时间短。

而这些短命的对象,恰恰是GC算法需要首先关注的。所以在大部分的GC中,YoungGC(也称作MinorGC)占了绝大部分,对于负载不高的应用,可能跑了数个月都不会发生FullGC。

基于这个前提,在编码过程中,我们应该尽可能地缩短对象的生命周期。在过去,分配对象是一个比较重的操作,所以有些程序员会尽可能地减少new对象的次数,尝试减小堆的分配开销,减少内存碎片。

但是,短命对象的创建在JVM中比我们想象的性能更好,所以,不要吝啬new关键字,大胆地去new吧。

当然前提是不做无谓的创建,对象创建的速率越高,那么GC也会越快被触发。

结论:

  • 分配小对象的开销分享小,不要吝啬去创建。
  • GC最喜欢这种小而短命的对象。
  • 让对象的生命周期尽可能短,例如在方法体内创建,使其能尽快地在YoungGC中被回收,不会晋升(romote)到年老代(Old Generation)。

二、对象分配的优化

基于大部分对象都是小而短命,并且不存在多线程的数据竞争。这些小对象的分配,会优先在线程私有的 TLAB 中分配,TLAB中创建的对象,不存在锁甚至是CAS的开销。

TLAB占用的空间在Eden Generation。

当对象比较大,TLAB的空间不足以放下,而JVM又认为当前线程占用的TLAB剩余空间还足够时,就会直接在Eden Generation上分配,此时是存在并发竞争的,所以会有CAS的开销,但也还好。

当对象大到Eden Generation放不下时,JVM只能尝试去Old Generation分配,这种情况需要尽可能避免,因为一旦在Old Generation分配,这个对象就只能被Old Generation的GC或是FullGC回收了。

三、不可变对象的好处

GC算法在扫描存活对象时通常需要从ROOT节点开始,扫描所有存活对象的引用,构建出对象图。

不可变对象对GC的优化,主要体现在Old Generation中。

可以想象一下,如果存在Old Generation的对象引用了Young Generation的对象,那么在每次YoungGC的过程中,就必须考虑到这种情况。

Hotspot JVM为了提高YoungGC的性能,避免每次YoungGC都扫描Old Generation中的对象引用,采用了 卡表(Card Table) 的方式。

简单来说,当Old Generation中的对象发生对Young Generation中的对象产生新的引用关系或释放引用时,都会在卡表中响应的标记上标记为脏(dirty),而YoungGC时,只需要扫描这些dirty的项就可以了。

可变对象对其它对象的引用关系可能会频繁变化,并且有可能在运行过程中持有越来越多的引用,特别是容器。这些都会导致对应的卡表项被频繁标记为dirty。

而不可变对象的引用关系非常稳定,在扫描卡表时就不会扫到它们对应的项了。

注意,这里的不可变对象,不是指仅仅自身引用不可变的final对象,而是真正的Immutable Objects

四、引用置为null的传说

早期的很多Java资料中都会提到在方法体中将一个变量置为null能够优化GC的性能,类似下面的代码:

1
2
3
List<String> list = new ArrayList<String>();
// some code
list = null; // help GC

事实上这种做法对GC的帮助微乎其微,有时候反而会导致代码混乱。

我记得几年前 @rednaxelafx 在HLL VM小组中详细论述过这个问题,原帖我没找到,结论基本就是:

  • 在一个非常大的方法体内,对一个较大的对象,将其引用置为null,某种程度上可以帮助GC。
  • 大部分情况下,这种行为都没有任何好处。

所以,还是早点放弃这种“优化”方式吧。

GC比我们想象的更聪明。

五、手动档的GC

在很多Java资料上都有下面两个奇技淫巧:

  • 通过Thread.yield()让出CPU资源给其它线程。
  • 通过System.gc()触发GC。

事实上JVM从不保证这两件事,而System.gc()在JVM启动参数中如果允许显式GC,则会触发FullGC,对于响应敏感的应用来说,几乎等同于自杀。

So,让我们牢记两点:

  • Never use Thread.yield()。
  • Never use System.gc()。除非你真的需要回收Native Memory。

第二点有个Native Memory的例外,如果你在以下场景:

  • 使用了NIO或者NIO框架(Mina/Netty)
  • 使用了DirectByteBuffer分配字节缓冲区
  • 使用了MappedByteBuffer做内存映射

由于Native Memory只能通过FullGC(或是CMS GC)回收,所以除非你非常清楚这时真的有必要,否则不要轻易调用System.gc(),且行且珍惜。

另外为了防止某些框架中的System.gc调用(例如NIO框架、Java RMI),建议在启动参数中加上-XX:+DisableExplicitGC来禁用显式GC。

这个参数有个巨大的坑,如果你禁用了System.gc(),那么上面的3种场景下的内存就无法回收,可能造成OOM,如果你使用了CMS GC,那么可以用这个参数替代:-XX:+ExplicitGCInvokesConcurrent。

关于System.gc(),可以参考 @bluedavy 的几篇文章:

 

六、指定容器初始化大小

Java容器的一个特点就是可以动态扩展,所以通常我们都不会去考虑初始大小的设置,不够了反正会自动扩容呗。

但是扩容不意味着没有代价,甚至是很高的代价。

例如一些基于数组的数据结构,例如StringBuilder、StringBuffer、ArrayList、HashMap等等,在扩容的时候都需要做ArrayCopy,对于不断增长的结构来说,经过若干次扩容,会存在大量无用的老数组,而回收这些数组的压力,全都会加在GC身上。

这些容器的构造函数中通常都有一个可以指定大小的参数,如果对于某些大小可以预估的容器,建议加上这个参数。

可是因为容器的扩容并不是等到容器满了才扩容,而是有一定的比例,例如HashMap的扩容阈值和负载因子(loadFactor)相关。

Google Guava框架对于容器的初始容量提供了非常便捷的工具方法,例如:

1
2
3
4
5
6
7
Lists.newArrayListWithCapacity(initialArraySize);
Lists.newArrayListWithExpectedSize(estimatedSize);
Sets.newHashSetWithExpectedSize(expectedSize);
Maps.newHashMapWithExpectedSize(expectedSize);

这样我们只要传入预估的大小即可,容量的计算就交给Guava来做吧。

反例:如果采用默认无参构造函数,创建一个ArrayList,不断增加元素直到OOM,那么在此过程中会导致:

  • 多次数组扩容,重新分配更大空间的数组
  • 多次数组拷贝
  • 内存碎片

七、对象池

为了减少对象分配开销,提高性能,可能有人会采取对象池的方式来缓存对象集合,作为复用的手段。

但是对象池中的对象由于在运行期长期存活,大部分会晋升到Old Generation,因此无法通过YoungGC回收。

并且通常……没有什么效果。

对于对象本身:

  • 如果对象很小,那么分配的开销本来就小,对象池只会增加代码复杂度。
  • 如果对象比较大,那么晋升到Old Generation后,对GC的压力就更大了。

从线程安全的角度考虑,通常池都是会被并发访问的,那么你就需要处理好同步的问题,这又是一个大坑,并且同步带来的开销,未必比你重新创建一个对象小

对于对象池,唯一合适的场景就是当池中的每个对象的创建开销很大时,缓存复用才有意义,例如每次new都会创建一个连接,或是依赖一次RPC。

比如说:

  • 线程池
  • 数据库连接池
  • TCP连接池

即使你真的需要实现一个对象池,也请使用成熟的开源框架,例如Apache Commons Pool。

另外,使用JDK的ThreadPoolExecutor作为线程池,不要重复造轮子,除非当你看过AQS的源码后认为你可以写得比Doug Lea更好。

八、对象作用域

尽可能缩小对象的作用域,即生命周期。

  • 如果可以在方法内声明的局部变量,就不要声明为实例变量。
  • 除非你的对象是单例的或不变的,否则尽可能少地声明static变量。

九、各类引用

java.lang.ref.Reference有几个子类,用于处理和GC相关的引用。JVM的引用类型简单来说有几种:

  • Strong Reference,最常见的引用
  • Weak Reference,当没有指向它的强引用时会被GC回收
  • Soft Reference,只当临近OOM时才会被GC回收
  • Phantom Reference,主要用于识别对象被GC的时机,通常用于做一些清理工作

当你需要实现一个缓存时,可以考虑优先使用WeakHashMap,而不是HashMap,当然,更好的选择是使用框架,例如Guava Cache。

最后,再次提醒,以上的这些未必可以对代码有多少性能上的提升,但是熟悉这些方法,是为了帮助我们写出更卓越的代码,和GC更好地合作。

深入理解Java 8 Lambda(类库篇——Streams API,Collectors和并行)

zhouchong阅读(19)评论(0)

关于

  1. 深入理解 Java 8 Lambda(语言篇——lambda,方法引用,目标类型和默认方法)
  2. 深入理解 Java 8 Lambda(类库篇——Streams API,Collector 和并行)
  3. 深入理解 Java 8 Lambda(原理篇——Java 编译器如何处理 lambda)

本文是深入理解 Java 8 Lambda 系列的第二篇,主要介绍 Java 8 针对新增语言特性而新增的类库(例如 Streams API、Collectors 和并行)。

本文是对 Brian GoetzState of the Lambda: Libraries Edition 一文的翻译。

Java SE 8 增加了新的语言特性(例如 lambda 表达式和默认方法),为此 Java SE 8 的类库也进行了很多改进,本文简要介绍了这些改进。在阅读本文前,你应该先阅读 深入浅出Java 8 Lambda(语言篇),以便对 Java SE 8 的新增特性有一个全面了解。

背景(Background)

自从lambda表达式成为Java语言的一部分之后,Java集合(Collections)API就面临着大幅变化。而 JSR 355(规定了 Java lambda 表达式的标准)的正式启用更是使得 Java 集合 API 变的过时不堪。尽管我们可以从头实现一个新的集合框架(比如“Collection II”),但取代现有的集合框架是一项非常艰难的工作,因为集合接口渗透了 Java 生态系统的每个角落,将它们一一换成新类库需要相当长的时间。因此,我们决定采取演化的策略(而非推倒重来)以改进集合 API:

  • 为现有的接口(例如 CollectionListStream)增加扩展方法;
  • 在类库中增加新的 (stream,即 java.util.stream.Stream)抽象以便进行聚集(aggregation)操作;
  • 改造现有的类型使之可以提供流视图(stream view);
  • 改造现有的类型使之可以容易的使用新的编程模式,这样用户就不必抛弃使用以久的类库,例如 ArrayListHashMap(当然这并不是说集合 API 会常驻永存,毕竟集合 API 在设计之初并没有考虑到 lambda 表达式。我们可能会在未来的 JDK 中添加一个更现代的集合类库)。

除了上面的改进,还有一项重要工作就是提供更加易用的并行(Parallelism)库。尽管 Java 平台已经对并行和并发提供了强有力的支持,然而开发者在实际工作(将串行代码并行化)中仍然会碰到很多问题。因此,我们希望 Java 类库能够既便于编写串行代码也便于编写并行代码,因此我们把编程的重点从具体执行细节(how computation should be formed)转移到抽象执行步骤(what computation should be perfomed)。除此之外,我们还需要在将并行变的 容易(easier)和将并行变的 不可见(invisible)之间做出抉择,我们选择了一个折中的路线:提供 显式(explicit)但 非侵入(unobstrusive)的并行。(如果把并行变的透明,那么很可能会引入不确定性(nondeterminism)以及各种数据竞争(data race)问题)

内部迭代和外部迭代(Internal vs external iteration)

集合类库主要依赖于 外部迭代(external iteration)。Collection 实现 Iterable 接口,从而使得用户可以依次遍历集合的元素。比如我们需要把一个集合中的形状都设置成红色,那么可以这么写:

1
2
3
for (Shape shape : shapes) {
shape.setColor(RED);
}

这个例子演示了外部迭代:for-each 循环调用 shapesiterator() 方法进行依次遍历。外部循环的代码非常直接,但它有如下问题:

  • Java 的 for 循环是串行的,而且必须按照集合中元素的顺序进行依次处理;
  • 集合框架无法对控制流进行优化,例如通过排序、并行、短路(short-circuiting)求值以及惰性求值改善性能。

尽管有时 for-each 循环的这些特性(串行,依次)是我们所期待的,但它对改善性能造成了阻碍。

我们可以使用 内部迭代(internal iteration)替代外部迭代,用户把对迭代的控制权交给类库,并向类库传递迭代时所需执行的代码。

下面是前例的内部迭代代码:

1
shapes.forEach(s -> s.setColor(RED));

尽管看起来只是一个小小的语法改动,但是它们的实际差别非常巨大。用户把对操作的控制权交还给类库,从而允许类库进行各种各样的优化(例如乱序执行、惰性求值和并行等等)。总的来说,内部迭代使得外部迭代中不可能实现的优化成为可能。

外部迭代同时承担了 做什么(把形状设为红色)和 怎么做(得到 Iterator 实例然后依次遍历)两项职责,而内部迭代只负责 做什么,而把 怎么做 留给类库。通过这样的职责转变:用户的代码会变得更加清晰,而类库则可以进行各种优化,从而使所有用户都从中受益。

流(Stream)

是 Java SE 8 类库中新增的关键抽象,它被定义于 java.util.stream(这个包里有若干流类型:Stream<T> 代表对象引用流,此外还有一系列特化(specialization)流,比如 IntStream 代表整形数字流)。每个流代表一个值序列,流提供一系列常用的聚集操作,使得我们可以便捷的在它上面进行各种运算。集合类库也提供了便捷的方式使我们可以以操作流的方式使用集合、数组以及其它数据结构。

流的操作可以被组合成 流水线(Pipeline)。以前面的例子为例,如果我们只想把蓝色改成红色:

1
2
3
shapes.stream()
.filter(s -> s.getColor() == BLUE)
.forEach(s -> s.setColor(RED));

Collection 上调用 stream() 会生成该集合元素的流视图(stream view),接下来 filter() 操作会产生只包含蓝色形状的流,最后,这些蓝色形状会被 forEach 操作设为红色。

如果我们想把蓝色的形状提取到新的 List 里,则可以:

1
2
3
4
List<Shape> blue =
shapes.stream()
.filter(s -> s.getColor() == BLUE)
.collect(Collectors.toList());

collect() 操作会把其接收的元素聚集(aggregate)到一起(这里是 List),collect() 方法的参数则被用来指定如何进行聚集操作。在这里我们使用 toList() 以把元素输出到 List 中。(如需更多 collect() 方法的细节,请阅读 Collectors 一节)

如果每个形状都被保存在 Box 里,然后我们想知道哪个盒子至少包含一个蓝色形状,我们可以这么写:

1
2
3
4
5
Set<Box> hasBlueShape =
shapes.stream()
.filter(s -> s.getColor() == BLUE)
.map(s -> s.getContainingBox())
.collect(Collectors.toSet());

map() 操作通过映射函数(这里的映射函数接收一个形状,然后返回包含它的盒子)对输入流里面的元素进行依次转换,然后产生新流。

如果我们需要得到蓝色物体的总重量,我们可以这样表达:

1
2
3
4
5
int sum =
shapes.stream()
.filter(s -> s.getColor() == BLUE)
.mapToInt(s -> s.getWeight())
.sum();

这些例子演示了流框架的设计,以及如何使用流框架解决实际问题。

流和集合(Streams vs Collections)

集合和流尽管在表面上看起来很相似,但它们的设计目标是不同的:集合主要用来对其元素进行有效(effective)的管理和访问(access),而流并不支持对其元素进行直接操作或直接访问,而只支持通过声明式操作在其上进行运算然后得到结果。除此之外,流和集合还有一些其它不同:

  • 无存储:流并不存储值;流的元素源自数据源(可能是某个数据结构、生成函数或 I/O 通道等等),通过一系列计算步骤得到;
  • 天然的函数式风格(Functional in nature):对流的操作会产生一个结果,但流的数据源不会被修改;
  • 惰性求值:多数流操作(包括过滤、映射、排序以及去重)都可以以惰性方式实现。这使得我们可以用一遍遍历完成整个流水线操作,并可以用短路操作提供更高效的实现;
  • 无需上界(Bounds optional):不少问题都可以被表达为无限流(infinite stream):用户不停地读取流直到满意的结果出现为止(比如说,枚举 完美数 这个操作可以被表达为在所有整数上进行过滤)。集合是有限的,但流不是(操作无限流时我们必需使用短路操作,以确保操作可以在有限时间内完成);

从API的角度来看,流和集合完全互相独立,不过我们可以既把集合作为流的数据源(Collection 拥有 stream()parallelStream() 方法),也可以通过流产生一个集合(使用前例的 collect() 方法)。Collection 以外的类型也可以作为 stream 的数据源,比如JDK中的 BufferedReaderRandomBitSet 已经被改造可以用做流的数据源,Arrays.stream() 则产生给定数组的流视图。事实上,任何可以用 Iterator 描述的对象都可以成为流的数据源,如果有额外的信息(比如大小、是否有序等特性),库还可以进行进一步的优化。

惰性(Laziness)

过滤和映射这样的操作既可以被 急性求值(以 filter 为例,急性求值需要在方法返回前完成对所有元素的过滤),也可以被 惰性求值(用 Stream 代表过滤结果,当且仅当需要时才进行过滤操作)在实际中进行惰性运算可以带来很多好处。比如说,如果我们进行惰性过滤,我们就可以把过滤和流水线里的其它操作混合在一起,从而不需要对数据进行多遍遍历。相类似的,如果我们在一个大型集合里搜索第一个满足某个条件的元素,我们可以在找到后直接停止,而不是继续处理整个集合。(这一点对无限数据源是很重要,惰性求值对于有限数据源起到的是优化作用,但对无限数据源起到的是决定作用,没有惰性求值,对无限数据源的操作将无法终止)

对于过滤和映射这样的操作,我们很自然的会把它当成是惰性求值操作,不过它们是否真的是惰性取决于它们的具体实现。另外,像 sum() 这样生成值的操作和 forEach() 这样产生副作用的操作都是“天然急性求值”,因为它们必须要产生具体的结果。

以下面的流水线为例:

1
2
3
4
5
int sum =
shapes.stream()
.filter(s -> s.getColor() == BLUE)
.mapToInt(s -> s.getWeight())
.sum();

这里的过滤操作和映射操作是惰性的,这意味着在调用 sum() 之前,我们不会从数据源提取任何元素。在 sum 操作开始之后,我们把过滤、映射以及求和混合在对数据源的一遍遍历之中。这样可以大大减少维持中间结果所带来的开销。

大多数循环都可以用数据源(数组、集合、生成函数以及I/O管道)上的聚合操作来表示:进行一系列惰性操作(过滤和映射等操作),然后用一个急性求值操作(forEachtoArraycollect 等操作)得到最终结果——例如过滤—映射—累积,过滤—映射—排序—遍历等组合操作。惰性操作一般被用来计算中间结果,这在Streams API设计中得到了很好的体现——与其让 filtermap 返回一个集合,我们选择让它们返回一个新的流。在 Streams API 中,返回流对象的操作都是惰性操作,而返回非流对象的操作(或者无返回值的操作,例如 forEach())都是急性操作。绝大多数情况下,潜在的惰性操作会被用于聚合,这正是我们想要的——流水线中的每一轮操作都会接收输入流中的元素,进行转换,然后把转换结果传给下一轮操作。

在使用这种 数据源—惰性操作—惰性操作—急性操作 流水线时,流水线中的惰性几乎是不可见的,因为计算过程被夹在数据源和最终结果(或副作用操作)之间。这使得API的可用性和性能得到了改善。

对于 anyMatch(Predicate)findFirst() 这些急性求值操作,我们可以使用短路(short-circuiting)来终止不必要的运算。以下面的流水线为例:

1
2
3
4
Optional<Shape> firstBlue =
shapes.stream()
.filter(s -> s.getColor() == BLUE)
.findFirst();

由于过滤这一步是惰性的,findFirst 在从其上游得到一个元素之后就会终止,这意味着我们只会处理这个元素及其之前的元素,而不是所有元素。findFirst() 方法返回 Optional 对象,因为集合中有可能不存在满足条件的元素。Optional 是一种用于描述可缺失值的类型。

在这种设计下,用户并不需要显式进行惰性求值,甚至他们都不需要了解惰性求值。类库自己会选择最优化的计算方式。

并行(Parallelism)

流水线既可以串行执行也可以并行执行,并行或串行是流的属性。除非你显式要求使用并行流,否则JDK总会返回串行流。(串行流可以通过 parallel() 方法被转化为并行流)

尽管并行是显式的,但它并不需要成为侵入式的。利用 parallelStream(),我们可以轻松的把之前重量求和的代码并行化:

1
2
3
4
5
int sum =
shapes.parallelStream()
.filter(s -> s.getColor = BLUE)
.mapToInt(s -> s.getWeight())
.sum();

并行化之后和之前的代码区别并不大,然而我们可以很容易看出它是并行的(此外我们并不需要自己去实现并行代码)。

因为流的数据源可能是一个可变集合,如果在遍历流时数据源被修改,就会产生干扰(interference)。所以在进行流操作时,流的数据源应保持不变(held constant)。这个条件并不难维持,如果集合只属于当前线程,只要 lambda 表达式不修改流的数据源就可以。(这个条件和遍历集合时所需的条件相似,如果集合在遍历时被修改,绝大多数的集合实现都会抛出ConcurrentModificationException)我们把这个条件称为无干扰性(non-interference)。

我们应避免在传递给流方法的 lambda 产生副作用。一般来说,打印调试语句这种输出变量的操作是安全的,然而在 lambda 表达式里访问可变变量就有可能造成数据竞争或是其它意想不到的问题,因为 lambda 在执行时可能会同时运行在多个线程上,因而它们所看到的元素有可能和正常的顺序不一致。无干扰性有两层含义:

  1. 不要干扰数据源;
  2. 不要干扰其它 lambda 表达式,当一个 lambda 在修改某个可变状态而另一个 lambda 在读取该状态时就会产生这种干扰。

只要满足无干扰性,我们就可以安全的进行并行操作并得到可预测的结果,即便对线程不安全的集合(例如 ArrayList)也是一样。

实例(Examples)

下面的代码源自 JDK 中的 Class 类型(getEnclosingMethod 方法),这段代码会遍历所有声明的方法,然后根据方法名称、返回类型以及参数的数量和类型进行匹配:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
for (Method method : enclosingInfo.getEnclosingClass().getDeclaredMethods()) {
if (method.getName().equals(enclosingInfo.getName())) {
Class<?>[] candidateParamClasses = method.getParameterTypes();
if (candidateParamClasses.length == parameterClasses.length) {
boolean matches = true;
for (int i = 0; i < candidateParamClasses.length; i += 1) {
if (!candidateParamClasses[i].equals(parameterClasses[i])) {
matches = false;
break;
}
}
if (matches) { // finally, check return type
if (method.getReturnType().equals(returnType)) {
return method;
}
}
}
}
}
throw new InternalError(“Enclosing method not found”);

通过使用流,我们不但可以消除上面代码里面所有的临时变量,还可以把控制逻辑交给类库处理。通过反射得到方法列表之后,我们利用 Arrays.stream 将它转化为 Stream,然后利用一系列过滤器去除类型不符、参数不符以及返回值不符的方法,然后通过调用 findFirst 得到 Optional<Method>,最后利用 orElseThrow 返回目标值或者抛出异常。

1
2
3
4
5
6
return Arrays.stream(enclosingInfo.getEnclosingClass().getDeclaredMethods())
.filter(m -> Objects.equals(m.getName(), enclosingInfo.getName()))
.filter(m -> Arrays.equals(m.getParameterTypes(), parameterClasses))
.filter(m -> Objects.equals(m.getReturnType(), returnType))
.findFirst()
.orElseThrow(() -> new InternalError(“Enclosing method not found”));

相对于未使用流的代码,这段代码更加紧凑,可读性更好,也不容易出错。

流操作特别适合对集合进行查询操作。假设有一个“音乐库”应用,这个应用里每个库都有一个专辑列表,每张专辑都有其名称和音轨列表,每首音轨表都有名称、艺术家和评分。

假设我们需要得到一个按名字排序的专辑列表,专辑列表里面的每张专辑都至少包含一首四星及四星以上的音轨,为了构建这个专辑列表,我们可以这么写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
List<Album> favs = new ArrayList<>();
for (Album album : albums) {
boolean hasFavorite = false;
for (Track track : album.tracks) {
if (track.rating >= 4) {
hasFavorite = true;
break;
}
}
if (hasFavorite)
favs.add(album);
}
Collections.sort(favs, new Comparator<Album>() {
public int compare(Album a1, Album a2) {
return a1.name.compareTo(a2.name);
}
});

我们可以用流操作来完成上面代码中的三个主要步骤——识别一张专辑是否包含一首评分大于等于四星的音轨(使用 anyMatch);按名字排序;以及把满足条件的专辑放在一个 List 中:

1
2
3
4
5
List<Album> sortedFavs =
albums.stream()
.filter(a -> a.tracks.anyMatch(t -> (t.rating >= 4)))
.sorted(Comparator.comparing(a -> a.name))
.collect(Collectors.toList());

Compartor.comparing 方法接收一个函数(该函数返回一个实现了 Comparable 接口的排序键值),然后返回一个利用该键值进行排序的 Comparator(请参考下面的 比较器工厂 一节)。

收集器(Collectors)

在之前的例子中,我们利用 collect() 方法把流中的元素聚合到 ListSet 中。collect() 接收一个类型为 Collector 的参数,这个参数决定了如何把流中的元素聚合到其它数据结构中。Collectors 类包含了大量常用收集器的工厂方法,toList()toSet() 就是其中最常见的两个,除了它们还有很多收集器,用来对数据进行对复杂的转换。

Collector 的类型由其输入类型和输出类型决定。以 toList() 收集器为例,它的输入类型为 T,输出类型为 List<T>toMap是另外一个较为复杂的 Collector,它有若干个版本。最简单的版本接收一对函数作为输入,其中一个函数用来生成键(key),另一个函数用来生成值(value)。toMap 的输入类型是 T,输出类型是 Map<K, V>,其中 KV 分别是前面两个函数所生成的键类型和值类型。(复杂版本的 toMap 收集器则允许你指定目标 Map 的类型或解决键冲突)。举例来说,下面的代码以目录数字为键值创建一个倒排索引:

1
2
3
Map<Integer, Album> albumsByCatalogNumber =
albums.stream()
.collect(Collectors.toMap(a -> a.getCatalogNumber(), a -> a));

groupingBy 是一个与 toMap 相类似的收集器,比如说我们想要把我们最喜欢的音乐按歌手列出来,这时我们就需要这样的 Collector:它以 Track 作为输入,以 Map<Artist, List<Track>> 作为输出。groupingBy 收集器就可以胜任这个工作,它接收分类函数(classification function),然后根据这个函数生成 Map,该 Map 的键是分类函数的返回结果,值是该分类下的元素列表。

1
2
3
4
Map<Artist, List<Track>> favsByArtist =
tracks.stream()
.filter(t -> t.rating >= 4)
.collect(Collectors.groupingBy(t -> t.artist));

收集器可以通过组合和复用来生成更加复杂的收集器,简单版本的 groupingBy 收集器把元素按照分类函数为每个元素计算出分类键值,然后把输入元素输出到对应的分类列表中。除了这个版本,还有一个更加通用(general)的版本允许你使用 其它 收集器来整理输入元素:它接收一个分类函数以及一个下流(downstream)收集器(单参数版本的 groupingBy 使用 toList() 作为其默认下流收集器)。举例来说,如果我们想把每首歌曲的演唱者收集到 Set 而非 List 中,我们可以使用 toSet 收集器:

1
2
3
4
5
Map<Artist, Set<Track>> favsByArtist =
tracks.stream()
.filter(t -> t.rating >= 4)
.collect(Collectors.groupingBy(t -> t.artist,
Collectors.toSet()));

如果我们需要按照歌手和评分来管理歌曲,我们可以生成多级 Map

1
2
3
4
Map<Artist, Map<Integer, List<Track>>> byArtistAndRating =
tracks.stream()
.collect(groupingBy(t -> t.artist,
groupingBy(t -> t.rating)));

在最后的例子里,我们创建了一个歌曲标题里面的词频分布。我们首先使用 Stream.flatMap() 得到一个歌曲流,然后用 Pattern.splitAsStream 把每首歌曲的标题打散成词流;接下来我们用 groupingByString.toUpperCase 对这些词进行不区分大小写的分组,最后使用 counting() 收集器计算每个词出现的次数(从而无需创建中间集合)。

1
2
3
4
5
Pattern pattern = Pattern.compile(“\\s+”);
Map<String, Integer> wordFreq =
tracks.stream()
.flatMap(t -> pattern.splitAsStream(t.name)) // Stream<String>
.collect(groupingBy(s -> s.toUpperCase(), counting()));

flatMap 接收一个返回流(这里是歌曲标题里的词)的函数。它利用这个函数将输入流中的每个元素转换为对应的流,然后把这些流拼接到一个流中。所以上面代码中的 flatMap 会返回所有歌曲标题里面的词,接下来我们不区分大小写的把这些词分组,并把词频作为值(value)储存。

Collectors 类包含大量的方法,这些方法被用来创造各式各样的收集器,以便进行查询、列表(tabulation)和分组等工作,当然你也可以实现一个自定义 Collector

并行的实质(Parallelism under the hood)

Java SE 7 引入了 Fork/Join 模型,以便高效实现并行计算。不过,通过 Fork/Join 编写的并行代码和同功能的串行代码的差别非常巨大,这使改写串行代码变的非常困难。通过提供串行流和并行流,用户可以在串行操作和并行操作之间进行便捷的切换(无需重写代码),从而使得编写正确的并行代码变的更加容易。

为了实现并行计算,我们一般要把计算过程递归分解(recursive decompose)为若干步:

  • 把问题分解为子问题;
  • 串行解决子问题从而得到部分结果(partial result);
  • 合并部分结果合为最终结果。

这也是 Fork/Join 的实现原理。

为了能够并行化任意流上的所有操作,我们把流抽象为 SpliteratorSpliterator 是对传统迭代器概念的一个泛化。分割迭代器(spliterator)既支持顺序依次访问数据,也支持分解数据:就像 Iterator 允许你跳过一个元素然后保留剩下的元素,Spliterator 允许你把输入元素的一部分(一般来说是一半)转移(carve off)到另一个新的 Spliterator 中,而剩下的数据则会被保存在原来的 Spliterator 里。(这两个分割迭代器还可以被进一步分解)除此之外,分割迭代器还可以提供源的元数据(比如元素的数量,如果已知的话)和其它一系列布尔值特征(比如说“元素是否被排序”这样的特征),Streams 框架可以利用这些数据来进行优化。

上面的分解方法也同样适用于其它数据结构,数据结构的作者只需要提供分解逻辑,然后就可以直接享用并行流操作带来的遍历。

大多数用户无需去实现 Spliterator 接口,因为集合上的 stream() 方法往往就足够了。但如果你需要实现一个集合或一个流,那么你可能需要手动实现 Spliterator 接口。Spliterator 接口的API如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
public interface Spliterator<T> {
// Element access
boolean tryAdvance(Consumer< ? super T> action);
void forEachRemaining(Consumer< ? super T> action);
// Decomposition
Spliterator<T> trySplit();
//Optional metadata
long estimateSize();
int characteristics();
Comparator< ? super T> getComparator();
}

集合库中的基础接口 CollectionIterable 都实现了正确但相对低效的 spliterator() 实现,但派生接口(例如 Set)和具体实现类(例如 ArrayList)均提供了高效的分割迭代器实现。分割迭代器的实现质量会影响到流操作的执行效率;如果在 split() 方法中进行良好(平衡)的划分,CPU 的利用率会得到改善;此外,提供正确的特性(characteristics)和大小(size)这些元数据有利于进一步优化。

出现顺序(Encounter order)

多数数据结构(例如列表,数组和I/O通道)都拥有 自然出现顺序(natural encounter order),这意味着它们的元素出现顺序是可预测的。其它的数据结构(例如 HashSet)则没有一个明确定义的出现顺序(这也是 HashSetIterator 实现中不保证元素出现顺序的原因)。

是否具有明确定义的出现顺序是 Spliterator 检查的特性之一(这个特性也被流使用)。除了少数例外(比如 Stream.forEach()Stream.findAny()),并行操作一般都会受到出现顺序的限制。这意味着下面的流水线:

1
2
3
4
List<String> names =
people.parallelStream()
.map(Person::getName)
.collect(toList());

代码中名字出现的顺序必须要和流中的 Person 出现的顺序一致。一般来说,这是我们所期待的结果,而且它对多大多数的流实现都不会造成明显的性能损耗。从另外的角度来说,如果源数据是 HashSet,那么上面代码中名字就可以以任意顺序出现。

JDK 中的流和 lambda(Streams and lambdas in JDK)

Stream 在 Java SE 8 中非常重要,我们希望可以在 JDK 中尽可能广的使用 Stream。我们为 Collection 提供了 stream()parallelStream(),以便把集合转化为流;此外数组可以通过 Arrays.stream() 被转化为流。

除此之外,Stream 中还有一些静态工厂方法(以及相关的原始类型流实现),这些方法被用来创建流,例如 Stream.of()Stream.generate 以及 IntStream.range。其它的常用类型也提供了流相关的方法,例如 String.charsBufferedReader.linesPattern.splitAsStreamRandom.intsBitSet.stream

最后,我们提供了一系列API用于构建流,类库的编写者可以利用这些API来在流上实现其它聚集操作。实现 Stream 至少需要一个 Iterator,不过如果编写者还拥有其它元数据(例如数据大小),类库就可以通过 Spliterator 提供一个更加高效的实现(就像 JDK 中所有的集合一样)。

比较器工厂(Comparator factories)

我们在 Comparator 接口中新增了若干用于生成比较器的实用方法:

静态方法 Comparator.comparing() 接收一个函数(该函数返回一个实现 Comparable 接口的比较键值),返回一个 Comparator,它的实现十分简洁:

1
2
3
4
public static <T, U extends Comparable< ? super U>> Compartor<T> comparing(
Function< ? super T, ? extends U> keyExtractor) {
return (c1, c2) -> keyExtractor.apply(c1).compareTo(keyExtractor.apply(c2));
}

我们把这种方法称为 高阶函数 ——以函数作为参数或是返回值的函数。我们可以使用高阶函数简化代码:

1
2
List<Person> people = …
people.sort(comparing(p -> p.getLastName()));

这段代码比“过去的代码”(一般要定义一个实现 Comparator 接口的匿名类)要简洁很多。但是它真正的威力在于它大大改进了可组合性(composability)。举例来说,Comparator 拥有一个用于逆序的默认方法。于是,如果想把列表按照姓进行反序排序,我们只需要创建一个和之前一样的比较器,然后调用反序方法即可:

1
people.sort(comparing(p -> p.getLastName()).reversed());

与之类似,默认方法 thenComparing 允许你去改进一个已有的 Comparator:在原比较器返回相等的结果时进行进一步比较。下面的代码演示了如何按照姓和名进行排序:

1
2
3
4
Comparator<Person> c =
Comparator.comparing(p -> p.getLastName())
.thenComparing(p -> p.getFirstName());
people.sort(c);

可变的集合操作(Mutative collection operation)

集合上的流操作一般会生成一个新的值或集合。不过有时我们希望就地修改集合,所以我们为集合(例如 CollectionListMap)提供了一些新的方法,比如 Iterable.forEach(Consumer)Collection.removeAll(Predicate)List.replaceAll(UnaryOperator)List.sort(Comparator)Map.computeIfAbsent()。除此之外,ConcurrentMap 中的一些非原子方法(例如 replaceputIfAbsent)被提升到 Map 之中。

小结(Summary)

引入 lambda 表达式是 Java 语言的巨大进步,但这还不够——开发者每天都要使用核心类库,为了开发者能够尽可能方便的使用语言的新特性,语言的演化和类库的演化是不可分割的。Stream 抽象作为新增类库特性的核心,提供了强大的数据集合操作功能,并被深入整合到现有的集合类和其它的 JDK 类型中。

深入理解Java 8 Lambda(语言篇——lambda,方法引用,目标类型和默认方法)

zhouchong阅读(17)评论(0)

关于

  1. 深入理解 Java 8 Lambda(语言篇——lambda,方法引用,目标类型和默认方法)
  2. 深入理解 Java 8 Lambda(类库篇——Streams API,Collector 和并行)
  3. 深入理解 Java 8 Lambda(原理篇——Java 编译器如何处理 lambda)

本文是深入理解 Java 8 Lambda 系列的第一篇,主要介绍 Java 8 新增的语言特性(比如 lambda 和方法引用),语言概念(比如目标类型和变量捕获)以及设计思路。

本文是对 Brian GoetzState of Lambda 一文的翻译,那么问题来了:

为什么要翻译这个系列?

  1. 工作之后,我开始大量使用 Java
  2. 公司将会在不久的未来使用 Java 8
  3. 作为资质平庸的开发者,我需要打一点提前量,以免到时拙计
  4. 为了学习Java 8(主要是其中的 lambda 及相关库),我先后阅读了Oracle的 官方文档Cay HorstmannCore Java的作者)的 Java 8 for the Really Impatient 和Richard Warburton的 Java 8 Lambdas
  5. 但我感到并没有多大收获,Oracle的官方文档涉及了 lambda 表达式的每一个概念,但都是点到辄止;后两本书(尤其是Java 8 Lambdas)花了大量篇幅介绍 Java lambda 及其类库,但实质内容不多,读完了还是没有对Java lambda产生一个清晰的认识
  6. 关键在于这些文章和书都没有解决我对Java lambda的困惑,比如:
    • Java 8 中的 lambda 为什么要设计成这样?(为什么要一个 lambda 对应一个接口?而不是 Structural Typing?)
    • lambda 和匿名类型的关系是什么?lambda 是匿名对象的语法糖吗?
    • Java 8 是如何对 lambda 进行类型推导的?它的类型推导做到了什么程度?
    • Java 8 为什么要引入默认方法?
    • Java 编译器如何处理 lambda?
    • 等等……
  7. 之后我在 Google 搜索这些问题,然后就找到 Brian Goetz 的三篇关于Java lambda的文章(State of LambdaState of Lambda libraries versionTranslation of lambda),读完之后上面的问题都得到了解决
  8. 为了加深理解,我决定翻译这一系列文章

警告(Caveats)

如果你不知道什么是函数式编程,或者不了解 mapfilterreduce 这些常用的高阶函数,那么你不适合阅读本文,请先学习函数式编程基础(比如 这本书)。


State of Lambda by Brian Goetz

The high-level goal of Project Lambda is to enable programming patterns that require modeling code as data to be convenient and idiomatic in Java.

关于

本文介绍了 Java SE 8 中新引入的 lambda 语言特性以及这些特性背后的设计思想。这些特性包括:

  • lambda 表达式(又被成为“闭包”或“匿名方法”)
  • 方法引用和构造方法引用
  • 扩展的目标类型和类型推导
  • 接口中的默认方法和静态方法

1. 背景

Java 是一门面向对象编程语言。面向对象编程语言和函数式编程语言中的基本元素(Basic Values)都可以动态封装程序行为:面向对象编程语言使用带有方法的对象封装行为,函数式编程语言使用函数封装行为。但这个相同点并不明显,因为Java 对象往往比较“重量级”:实例化一个类型往往会涉及不同的类,并需要初始化类里的字段和方法。

不过有些 Java 对象只是对单个函数的封装。例如下面这个典型用例:Java API 中定义了一个接口(一般被称为回调接口),用户通过提供这个接口的实例来传入指定行为,例如:

1
2
3
public interface ActionListener {
void actionPerformed(ActionEvent e);
}

这里并不需要专门定义一个类来实现 ActionListener,因为它只会在调用处被使用一次。用户一般会使用匿名类型把行为内联(inline):

1
2
3
4
5
button.addActionListener(new ActionListener() {
public void actionPerformed(ActionEvent e) {
ui.dazzle(e.getModifiers());
}
});

很多库都依赖于上面的模式。对于并行 API 更是如此,因为我们需要把待执行的代码提供给并行 API,并行编程是一个非常值得研究的领域,因为在这里摩尔定律得到了重生:尽管我们没有更快的 CPU 核心(core),但是我们有更多的 CPU 核心。而串行 API 就只能使用有限的计算能力。

随着回调模式和函数式编程风格的日益流行,我们需要在Java中提供一种尽可能轻量级的将代码封装为数据(Model code as data)的方法。匿名内部类并不是一个好的 选择,因为:

  1. 语法过于冗余
  2. 匿名类中的 this 和变量名容易使人产生误解
  3. 类型载入和实例创建语义不够灵活
  4. 无法捕获非 final 的局部变量
  5. 无法对控制流进行抽象

上面的多数问题均在Java SE 8中得以解决:

  • 通过提供更简洁的语法和局部作用域规则,Java SE 8 彻底解决了问题 1 和问题 2
  • 通过提供更加灵活而且便于优化的表达式语义,Java SE 8 绕开了问题 3
  • 通过允许编译器推断变量的“常量性”(finality),Java SE 8 减轻了问题 4 带来的困扰

不过,Java SE 8 的目标并非解决所有上述问题。因此捕获可变变量(问题 4)和非局部控制流(问题 5)并不在 Java SE 8的范畴之内。(尽管我们可能会在未来提供对这些特性的支持)

2. 函数式接口(Functional interfaces)

尽管匿名内部类有着种种限制和问题,但是它有一个良好的特性,它和Java类型系统结合的十分紧密:每一个函数对象都对应一个接口类型。之所以说这个特性是良好的,是因为:

  • 接口是 Java 类型系统的一部分
  • 接口天然就拥有其运行时表示(Runtime representation)
  • 接口可以通过 Javadoc 注释来表达一些非正式的协定(contract),例如,通过注释说明该操作应可交换(commutative)

上面提到的 ActionListener 接口只有一个方法,大多数回调接口都拥有这个特征:比如 Runnable 接口和 Comparator 接口。我们把这些只拥有一个方法的接口称为 函数式接口。(之前它们被称为 SAM类型,即 单抽象方法类型(Single Abstract Method))

我们并不需要额外的工作来声明一个接口是函数式接口:编译器会根据接口的结构自行判断(判断过程并非简单的对接口方法计数:一个接口可能冗余的定义了一个 Object 已经提供的方法,比如 toString(),或者定义了静态方法或默认方法,这些都不属于函数式接口方法的范畴)。不过API作者们可以通过 @FunctionalInterface 注解来显式指定一个接口是函数式接口(以避免无意声明了一个符合函数式标准的接口),加上这个注解之后,编译器就会验证该接口是否满足函数式接口的要求。

实现函数式类型的另一种方式是引入一个全新的 结构化 函数类型,我们也称其为“箭头”类型。例如,一个接收 StringObject 并返回 int 的函数类型可以被表示为 (String, Object) -> int。我们仔细考虑了这个方式,但出于下面的原因,最终将其否定:

  • 它会为Java类型系统引入额外的复杂度,并带来 结构类型(Structural Type)指名类型(Nominal Type) 的混用。(Java 几乎全部使用指名类型)
  • 它会导致类库风格的分歧——一些类库会继续使用回调接口,而另一些类库会使用结构化函数类型
  • 它的语法会变得十分笨拙,尤其在包含受检异常(checked exception)之后
  • 每个函数类型很难拥有其运行时表示,这意味着开发者会受到 类型擦除(erasure) 的困扰和局限。比如说,我们无法对方法 m(T->U)m(X->Y) 进行重载(Overload)

所以我们选择了“使用已知类型”这条路——因为现有的类库大量使用了函数式接口,通过沿用这种模式,我们使得现有类库能够直接使用 lambda 表达式。例如下面是 Java SE 7 中已经存在的函数式接口:

除此之外,Java SE 8中增加了一个新的包:java.util.function,它里面包含了常用的函数式接口,例如:

  • Predicate<T>——接收 T 并返回 boolean
  • Consumer<T>——接收 T,不返回值
  • Function<T, R>——接收 T,返回 R
  • Supplier<T>——提供 T 对象(例如工厂),不接收值
  • UnaryOperator<T>——接收 T 对象,返回 T
  • BinaryOperator<T>——接收两个 T,返回 T

除了上面的这些基本的函数式接口,我们还提供了一些针对原始类型(Primitive type)的特化(Specialization)函数式接口,例如 IntSupplierLongBinaryOperator。(我们只为 intlongdouble 提供了特化函数式接口,如果需要使用其它原始类型则需要进行类型转换)同样的我们也提供了一些针对多个参数的函数式接口,例如 BiFunction<T, U, R>,它接收 T 对象和 U 对象,返回 R 对象。

3. lambda表达式(lambda expressions)

匿名类型最大的问题就在于其冗余的语法。有人戏称匿名类型导致了“高度问题”(height problem):比如前面 ActionListener的例子里的五行代码中仅有一行在做实际工作。

lambda表达式是匿名方法,它提供了轻量级的语法,从而解决了匿名内部类带来的“高度问题”。

下面是一些lambda表达式:

1
2
3
(int x, int y) -> x + y
() -> 42
(String s) -> { System.out.println(s); }

第一个 lambda 表达式接收 xy 这两个整形参数并返回它们的和;第二个 lambda 表达式不接收参数,返回整数 ‘42’;第三个 lambda 表达式接收一个字符串并把它打印到控制台,不返回值。

lambda 表达式的语法由参数列表、箭头符号 -> 和函数体组成。函数体既可以是一个表达式,也可以是一个语句块:

  • 表达式:表达式会被执行然后返回执行结果。
  • 语句块:语句块中的语句会被依次执行,就像方法中的语句一样——
    • return 语句会把控制权交给匿名方法的调用者
    • breakcontinue 只能在循环中使用
    • 如果函数体有返回值,那么函数体内部的每一条路径都必须返回值

表达式函数体适合小型 lambda 表达式,它消除了 return 关键字,使得语法更加简洁。

lambda 表达式也会经常出现在嵌套环境中,比如说作为方法的参数。为了使 lambda 表达式在这些场景下尽可能简洁,我们去除了不必要的分隔符。不过在某些情况下我们也可以把它分为多行,然后用括号包起来,就像其它普通表达式一样。

下面是一些出现在语句中的 lambda 表达式:

1
2
3
4
5
6
7
8
FileFilter java = (File f) -> f.getName().endsWith(“*.java”);
String user = doPrivileged(() -> System.getProperty(“user.name”));
new Thread(() -> {
connectToService();
sendNotification();
}).start();

4. 目标类型(Target typing)

需要注意的是,函数式接口的名称并不是 lambda 表达式的一部分。那么问题来了,对于给定的 lambda 表达式,它的类型是什么?答案是:它的类型是由其上下文推导而来。例如,下面代码中的 lambda 表达式类型是 ActionListener

1
ActionListener l = (ActionEvent e) -> ui.dazzle(e.getModifiers());

这就意味着同样的 lambda 表达式在不同上下文里可以拥有不同的类型:

1
2
3
Callable<String> c = () -> “done”;
PrivilegedAction<String> a = () -> “done”;

第一个 lambda 表达式 () -> "done"Callable 的实例,而第二个 lambda 表达式则是 PrivilegedAction 的实例。

编译器负责推导 lambda 表达式类型。它利用 lambda 表达式所在上下文 所期待的类型 进行推导,这个 被期待的类型 被称为 目标类型。lambda 表达式只能出现在目标类型为函数式接口的上下文中。

当然,lambda 表达式对目标类型也是有要求的。编译器会检查 lambda 表达式的类型和目标类型的方法签名(method signature)是否一致。当且仅当下面所有条件均满足时,lambda 表达式才可以被赋给目标类型 T

  • T 是一个函数式接口
  • lambda 表达式的参数和 T 的方法参数在数量和类型上一一对应
  • lambda 表达式的返回值和 T 的方法返回值相兼容(Compatible)
  • lambda 表达式内所抛出的异常和 T 的方法 throws 类型相兼容

由于目标类型(函数式接口)已经“知道” lambda 表达式的形式参数(Formal parameter)类型,所以我们没有必要把已知类型再重复一遍。也就是说,lambda 表达式的参数类型可以从目标类型中得出:

1
Comparator<String> c = (s1, s2) -> s1.compareToIgnoreCase(s2);

在上面的例子里,编译器可以推导出 s1s2 的类型是 String。此外,当 lambda 的参数只有一个而且它的类型可以被推导得知时,该参数列表外面的括号可以被省略:

1
2
3
FileFilter java = f -> f.getName().endsWith(“.java”);
button.addActionListener(e -> ui.dazzle(e.getModifiers()));

这些改进进一步展示了我们的设计目标:“不要把高度问题转化成宽度问题。”我们希望语法元素能够尽可能的少,以便代码的读者能够直达 lambda 表达式的核心部分。

lambda 表达式并不是第一个拥有上下文相关类型的 Java 表达式:泛型方法调用和“菱形”构造器调用也通过目标类型来进行类型推导:

1
2
3
4
5
List<String> ls = Collections.emptyList();
List<Integer> li = Collections.emptyList();
Map<String, Integer> m1 = new HashMap<>();
Map<Integer, String> m2 = new HashMap<>();

5. 目标类型的上下文(Contexts for target typing)

之前我们提到 lambda 表达式智能出现在拥有目标类型的上下文中。下面给出了这些带有目标类型的上下文:

  • 变量声明
  • 赋值
  • 返回语句
  • 数组初始化器
  • 方法和构造方法的参数
  • lambda 表达式函数体
  • 条件表达式(? :
  • 转型(Cast)表达式

在前三个上下文(变量声明、赋值和返回语句)里,目标类型即是被赋值或被返回的类型:

1
2
3
4
5
6
7
8
Comparator<String> c;
c = (String s1, String s2) -> s1.compareToIgnoreCase(s2);
public Runnable toDoLater() {
return () -> {
System.out.println(“later”);
}
}

数组初始化器和赋值类似,只是这里的“变量”变成了数组元素,而类型是从数组类型中推导得知:

1
2
3
4
filterFiles(
new FileFilter[] {
f -> f.exists(), f -> f.canRead(), f -> f.getName().startsWith(“q”)
});

方法参数的类型推导要相对复杂些:目标类型的确认会涉及到其它两个语言特性:重载解析(Overload resolution)和参数类型推导(Type argument inference)。

重载解析会为一个给定的方法调用(method invocation)寻找最合适的方法声明(method declaration)。由于不同的声明具有不同的签名,当 lambda 表达式作为方法参数时,重载解析就会影响到 lambda 表达式的目标类型。编译器会通过它所得之的信息来做出决定。如果 lambda 表达式具有 显式类型(参数类型被显式指定),编译器就可以直接 使用lambda 表达式的返回类型;如果lambda表达式具有 隐式类型(参数类型被推导而知),重载解析则会忽略 lambda 表达式函数体而只依赖 lambda 表达式参数的数量。

如果在解析方法声明时存在二义性(ambiguous),我们就需要利用转型(cast)或显式 lambda 表达式来提供更多的类型信息。如果 lambda 表达式的返回类型依赖于其参数的类型,那么 lambda 表达式函数体有可能可以给编译器提供额外的信息,以便其推导参数类型。

1
2
List<Person> ps = …
Stream<String> names = ps.stream().map(p -> p.getName());

在上面的代码中,ps 的类型是 List<Person>,所以 ps.stream() 的返回类型是 Stream<Person>map() 方法接收一个类型为 Function<T, R> 的函数式接口,这里 T 的类型即是 Stream 元素的类型,也就是 Person,而 R 的类型未知。由于在重载解析之后 lambda 表达式的目标类型仍然未知,我们就需要推导 R 的类型:通过对 lambda 表达式函数体进行类型检查,我们发现函数体返回 String,因此 R 的类型是 String,因而 map() 返回 Stream<String>。绝大多数情况下编译器都能解析出正确的类型,但如果碰到无法解析的情况,我们则需要:

  • 使用显式 lambda 表达式(为参数 p 提供显式类型)以提供额外的类型信息
  • 把 lambda 表达式转型为 Function<Person, String>
  • 为泛型参数 R 提供一个实际类型。(.<String>map(p -> p.getName())

lambda 表达式本身也可以为它自己的函数体提供目标类型,也就是说 lambda 表达式可以通过外部目标类型推导出其内部的返回类型,这意味着我们可以方便的编写一个返回函数的函数:

1
Supplier<Runnable> c = () -> () -> { System.out.println(“hi”); };

类似的,条件表达式可以把目标类型“分发”给其子表达式:

1
Callable<Integer> c = flag ? (() -> 23) : (() -> 42);

最后,转型表达式(Cast expression)可以显式提供 lambda 表达式的类型,这个特性在无法确认目标类型时非常有用:

1
2
// Object o = () -> { System.out.println(“hi”); }; 这段代码是非法的
Object o = (Runnable) () -> { System.out.println(“hi”); };

除此之外,当重载的方法都拥有函数式接口时,转型可以帮助解决重载解析时出现的二义性。

目标类型这个概念不仅仅适用于 lambda 表达式,泛型方法调用和“菱形”构造方法调用也可以从目标类型中受益,下面的代码在 Java SE 7 是非法的,但在 Java SE 8 中是合法的:

1
2
3
List<String> ls = Collections.checkedList(new ArrayList<>(), String.class);
Set<Integer> si = flag ? Collections.singleton(23) : Collections.emptySet();

6. 词法作用域(Lexical scoping)

在内部类中使用变量名(以及 this)非常容易出错。内部类中通过继承得到的成员(包括来自 Object 的方法)可能会把外部类的成员掩盖(shadow),此外未限定(unqualified)的 this 引用会指向内部类自己而非外部类。

相对于内部类,lambda 表达式的语义就十分简单:它不会从超类(supertype)中继承任何变量名,也不会引入一个新的作用域。lambda 表达式基于词法作用域,也就是说 lambda 表达式函数体里面的变量和它外部环境的变量具有相同的语义(也包括 lambda 表达式的形式参数)。此外,’this’ 关键字及其引用在 lambda 表达式内部和外部也拥有相同的语义。

为了进一步说明词法作用域的优点,请参考下面的代码,它会把 "Hello, world!" 打印两遍:

1
2
3
4
5
6
7
8
9
10
11
public class Hello {
Runnable r1 = () -> { System.out.println(this); }
Runnable r2 = () -> { System.out.println(toString()); }
public String toString() { return “Hello, world”; }
public static void main(String… args) {
new Hello().r1.run();
new Hello().r2.run();
}
}

与之相类似的内部类实现则会打印出类似 Hello$1@5b89a773Hello$2@537a7706 之类的字符串,这往往会使开发者大吃一惊。

基于词法作用域的理念,lambda 表达式不可以掩盖任何其所在上下文中的局部变量,它的行为和那些拥有参数的控制流结构(例如 for 循环和 catch 从句)一致。

个人补充:这个说法很拗口,所以我在这里加一个例子以演示词法作用域:

1
2
3
4
5
int i = 0;
int sum = 0;
for (int i = 1; i < 10; i += 1) { //这里会出现编译错误,因为i已经在for循环外部声明过了
sum += i;
}

7. 变量捕获(Variable capture)

在 Java SE 7 中,编译器对内部类中引用的外部变量(即捕获的变量)要求非常严格:如果捕获的变量没有被声明为 final 就会产生一个编译错误。我们现在放宽了这个限制——对于 lambda 表达式和内部类,我们允许在其中捕获那些符合 有效只读(Effectively final)的局部变量。

简单的说,如果一个局部变量在初始化后从未被修改过,那么它就符合有效只读的要求,换句话说,加上 final 后也不会导致编译错误的局部变量就是有效只读变量。

1
2
3
4
Callable<String> helloCallable(String name) {
String hello = “Hello”;
return () -> (hello + “, “ + name);
}

this 的引用,以及通过 this 对未限定字段的引用和未限定方法的调用在本质上都属于使用 final 局部变量。包含此类引用的 lambda 表达式相当于捕获了 this 实例。在其它情况下,lambda 对象不会保留任何对 this 的引用。

这个特性对内存管理是一件好事:内部类实例会一直保留一个对其外部类实例的强引用,而那些没有捕获外部类成员的 lambda 表达式则不会保留对外部类实例的引用。要知道内部类的这个特性往往会造成内存泄露。

尽管我们放宽了对捕获变量的语法限制,但试图修改捕获变量的行为仍然会被禁止,比如下面这个例子就是非法的:

1
2
int sum = 0;
list.forEach(e -> { sum += e.size(); });

为什么要禁止这种行为呢?因为这样的 lambda 表达式很容易引起 race condition。除非我们能够强制(最好是在编译时)这样的函数不能离开其当前线程,但如果这么做了可能会导致更多的问题。简而言之,lambda 表达式对 封闭,对 变量 开放。

个人补充:lambda 表达式对 封闭,对 变量 开放的原文是:lambda expressions close over values, not variables,我在这里增加一个例子以说明这个特性:

1
2
3
4
5
int sum = 0;
list.forEach(e -> { sum += e.size(); }); // Illegal, close over values
List<Integer> aList = new List<>();
list.forEach(e -> { aList.add(e); }); // Legal, open over variables

lambda 表达式不支持修改捕获变量的另一个原因是我们可以使用更好的方式来实现同样的效果:使用规约(reduction)。java.util.stream 包提供了各种通用的和专用的规约操作(例如 summinmax),就上面的例子而言,我们可以使用规约操作(在串行和并行下都是安全的)来代替 forEach

1
2
3
4
int sum =
list.stream()
.mapToInt(e -> e.size())
.sum();

sum() 等价于下面的规约操作:

1
2
3
4
int sum =
list.stream()
.mapToInt(e -> e.size())
.reduce(0 , (x, y) -> x + y);

规约需要一个初始值(以防输入为空)和一个操作符(在这里是加号),然后用下面的表达式计算结果:

1
0 + list[0] + list[1] + list[2] + …

规约也可以完成其它操作,比如求最小值、最大值和乘积等等。如果操作符具有可结合性(associative),那么规约操作就可以容易的被并行化。所以,与其支持一个本质上是并行而且容易导致 race condition 的操作,我们选择在库中提供一个更加并行友好且不容易出错的方式来进行累积(accumulation)。

8. 方法引用(Method references)

lambda 表达式允许我们定义一个匿名方法,并允许我们以函数式接口的方式使用它。我们也希望能够在 已有的 方法上实现同样的特性。

方法引用和 lambda 表达式拥有相同的特性(例如,它们都需要一个目标类型,并需要被转化为函数式接口的实例),不过我们并不需要为方法引用提供方法体,我们可以直接通过方法名称引用已有方法。

以下面的代码为例,假设我们要按照 nameagePerson 数组进行排序:

1
2
3
4
5
6
7
8
9
10
11
12
class Person {
private final String name;
private final int age;
public int getAge() { return age; }
public String getName() {return name; }
}
Person[] people = …
Comparator<Person> byName = Comparator.comparing(p -> p.getName());
Arrays.sort(people, byName);

在这里我们可以用方法引用代替lambda表达式:

1
Comparator<Person> byName = Comparator.comparing(Person::getName);

这里的 Person::getName 可以被看作为 lambda 表达式的简写形式。尽管方法引用不一定(比如在这个例子里)会把语法变的更紧凑,但它拥有更明确的语义——如果我们想要调用的方法拥有一个名字,我们就可以通过它的名字直接调用它。

因为函数式接口的方法参数对应于隐式方法调用时的参数,所以被引用方法签名可以通过放宽类型,装箱以及组织到参数数组中的方式对其参数进行操作,就像在调用实际方法一样:

1
2
3
4
Consumer<Integer> b1 = System::exit; // void exit(int status)
Consumer<String[]> b2 = Arrays:sort; // void sort(Object[] a)
Consumer<String> b3 = MyProgram::main; // void main(String… args)
Runnable r = Myprogram::mapToInt // void main(String… args)

9. 方法引用的种类(Kinds of method references)

方法引用有很多种,它们的语法如下:

  • 静态方法引用:ClassName::methodName
  • 实例上的实例方法引用:instanceReference::methodName
  • 超类上的实例方法引用:super::methodName
  • 类型上的实例方法引用:ClassName::methodName
  • 构造方法引用:Class::new
  • 数组构造方法引用:TypeName[]::new

对于静态方法引用,我们需要在类名和方法名之间加入 :: 分隔符,例如 Integer::sum

对于具体对象上的实例方法引用,我们则需要在对象名和方法名之间加入分隔符:

1
2
Set<String> knownNames = …
Predicate<String> isKnown = knownNames::contains;

这里的隐式 lambda 表达式(也就是实例方法引用)会从 knownNames 中捕获 String 对象,而它的方法体则会通过Set.contains使用该 String 对象。

有了实例方法引用,在不同函数式接口之间进行类型转换就变的很方便:

1
2
Callable<Path> c = …
Privileged<Path> a = c::call;

引用任意对象的实例方法则需要在实例方法名称和其所属类型名称间加上分隔符:

1
Function<String, String> upperfier = String::toUpperCase;

这里的隐式 lambda 表达式(即 String::toUpperCase 实例方法引用)有一个 String 参数,这个参数会被 toUpperCase 方法使用。

如果类型的实例方法是泛型的,那么我们就需要在 :: 分隔符前提供类型参数,或者(多数情况下)利用目标类型推导出其类型。

需要注意的是,静态方法引用和类型上的实例方法引用拥有一样的语法。编译器会根据实际情况做出决定。

一般我们不需要指定方法引用中的参数类型,因为编译器往往可以推导出结果,但如果需要我们也可以显式在 :: 分隔符之前提供参数类型信息。

和静态方法引用类似,构造方法也可以通过 new 关键字被直接引用:

1
SocketImplFactory factory = MySocketImpl::new;

如果类型拥有多个构造方法,那么我们就会通过目标类型的方法参数来选择最佳匹配,这里的选择过程和调用构造方法时的选择过程是一样的。

如果待实例化的类型是泛型的,那么我们可以在类型名称之后提供类型参数,否则编译器则会依照”菱形”构造方法调用时的方式进行推导。

数组的构造方法引用的语法则比较特殊,为了便于理解,你可以假想存在一个接收 int 参数的数组构造方法。参考下面的代码:

1
2
IntFunction<int[]> arrayMaker = int[]::new;
int[] array = arrayMaker.apply(10) // 创建数组 int[10]

10. 默认方法和静态接口方法(Default and static interface methods)

lambda 表达式和方法引用大大提升了 Java 的表达能力(expressiveness),不过为了使把 代码即数据 (code-as-data)变的更加容易,我们需要把这些特性融入到已有的库之中,以便开发者使用。

Java SE 7 时代为一个已有的类库增加功能是非常困难的。具体的说,接口在发布之后就已经被定型,除非我们能够一次性更新所有该接口的实现,否则向接口添加方法就会破坏现有的接口实现。默认方法(之前被称为 虚拟扩展方法守护方法)的目标即是解决这个问题,使得接口在发布之后仍能被逐步演化。

这里给出一个例子,我们需要在标准集合 API 中增加针对 lambda 的方法。例如 removeAll 方法应该被泛化为接收一个函数式接口 Predicate,但这个新的方法应该被放在哪里呢?我们无法直接在 Collection 接口上新增方法——不然就会破坏现有的 Collection 实现。我们倒是可以在 Collections 工具类中增加对应的静态方法,但这样就会把这个方法置于“二等公民”的境地。

默认方法 利用面向对象的方式向接口增加新的行为。它是一种新的方法:接口方法可以是 抽象的 或是 默认的。默认方法拥有其默认实现,实现接口的类型通过继承得到该默认实现(如果类型没有覆盖该默认实现)。此外,默认方法不是抽象方法,所以我们可以放心的向函数式接口里增加默认方法,而不用担心函数式接口的单抽象方法限制。

下面的例子展示了如何向 Iterator 接口增加默认方法 skip

1
2
3
4
5
6
7
8
9
interface Iterator<E> {
boolean hasNext();
E next();
void remove();
default void skip(int i) {
for ( ; i > 0 && hasNext(); i -= 1) next();
}
}

根据上面的 Iterator 定义,所有实现 Iterator 的类型都会自动继承 skip 方法。在使用者的眼里,skip 不过是接口新增的一个虚拟方法。在没有覆盖 skip 方法的 Iterator 子类实例上调用 skip 会执行 skip 的默认实现:调用 hasNextnext 若干次。子类可以通过覆盖 skip 来提供更好的实现——比如直接移动游标(cursor),或是提供为操作提供原子性(Atomicity)等。

当接口继承其它接口时,我们既可以为它所继承而来的抽象方法提供一个默认实现,也可以为它继承而来的默认方法提供一个新的实现,还可以把它继承而来的默认方法重新抽象化。

除了默认方法,Java SE 8 还在允许在接口中定义 静态 方法。这使得我们可以从接口直接调用和它相关的辅助方法(Helper method),而不是从其它的类中调用(之前这样的类往往以对应接口的复数命名,例如 Collections)。比如,我们一般需要使用静态辅助方法生成实现 Comparator 的比较器,在Java SE 8中我们可以直接把该静态方法定义在 Comparator 接口中:

1
2
3
4
public static <T, U extends Comparable<? super U>>
Comparator<T> comparing(Function<T, U> keyExtractor) {
return (c1, c2) -> keyExtractor.apply(c1).compareTo(keyExtractor.apply(c2));
}

11. 继承默认方法(Inheritance of default methods)

和其它方法一样,默认方法也可以被继承,大多数情况下这种继承行为和我们所期待的一致。不过,当类型或者接口的超类拥有多个具有相同签名的方法时,我们就需要一套规则来解决这个冲突:

  • 类的方法(class method)声明优先于接口默认方法。无论该方法是具体的还是抽象的。
  • 被其它类型所覆盖的方法会被忽略。这条规则适用于超类型共享一个公共祖先的情况。

为了演示第二条规则,我们假设 CollectionList 接口均提供了 removeAll 的默认实现,然后 Queue 继承并覆盖了 Collection 中的默认方法。在下面的 implement 从句中,List 中的方法声明会优先于 Queue 中的方法声明:

1
class LinkedList<E> implements List<E>, Queue<E> { … }

当两个独立的默认方法相冲突或是默认方法和抽象方法相冲突时会产生编译错误。这时程序员需要显式覆盖超类方法。一般来说我们会定义一个默认方法,然后在其中显式选择超类方法:

1
2
3
interface Robot implements Artist, Gun {
default void draw() { Artist.super.draw(); }
}

super 前面的类型必须是有定义或继承默认方法的类型。这种方法调用并不只限于消除命名冲突——我们也可以在其它场景中使用它。

最后,接口在 inheritsextends 从句中的声明顺序和它们被实现的顺序无关。

12. 融会贯通(Putting it together)

我们在设计lambda时的一个重要目标就是新增的语言特性和库特性能够无缝结合(designed to work together)。接下来,我们通过一个实际例子(按照姓对名字列表进行排序)来演示这一点:

比如说下面的代码:

1
2
3
4
5
6
List<Person> people = …
Collections.sort(people, new Comparator<Person>() {
public int compare(Person x, Person y) {
return x.getLastName().compareTo(y.getLastName());
}
})

冗余代码实在太多了!

有了lambda表达式,我们可以去掉冗余的匿名类:

1
2
Collections.sort(
people, (Person x, Person y) -> x.getLastName().compareTo(y.getLastName()));

尽管代码简洁了很多,但它的抽象程度依然很差:开发者仍然需要进行实际的比较操作(而且如果比较的值是原始类型那么情况会更糟),所以我们要借助 Comparator 里的 comparing 方法实现比较操作:

1
Collections.sort(people, Comparator.comparing((Person p) -> p.getLastName()));

在类型推导和静态导入的帮助下,我们可以进一步简化上面的代码:

1
Collections.sort(people, comparing(p -> p.getLastName()));

我们注意到这里的 lambda 表达式实际上是 getLastName 的代理(forwarder),于是我们可以用方法引用代替它:

1
Collections.sort(people, comparing(Person::getLastName));

最后,使用 Collections.sort 这样的辅助方法并不是一个好主意:它不但使代码变的冗余,也无法为实现 List 接口的数据结构提供特定(specialized)的高效实现,而且由于 Collections.sort 方法不属于 List 接口,用户在阅读 List 接口的文档时不会察觉在另外的 Collections 类中还有一个针对 List 接口的排序(sort())方法。

默认方法可以有效的解决这个问题,我们为 List 增加默认方法 sort(),然后就可以这样调用:

1
people.sort(comparing(Person::getLastName));;

此外,如果我们为 Comparator 接口增加一个默认方法 reversed()(产生一个逆序比较器),我们就可以非常容易的在前面代码的基础上实现降序排序。

1
people.sort(comparing(Person::getLastName).reversed());;

13. 小结(Summary)

Java SE 8 提供的新语言特性并不算多——lambda 表达式,方法引用,默认方法和静态接口方法,以及范围更广的类型推导。但是把它们结合在一起之后,开发者可以编写出更加清晰简洁的代码,类库编写者可以编写更加强大易用的并行类库。

如何理解Java中的多态

zhouchong阅读(34)评论(0)

作者:Life Hackathons
链接:https://www.zhihu.com/question/20268454/answer/18201559
来源:知乎
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

在Stackoverflow上见过的解释多态最好的答案: java – Polymorphism vs Overriding vs Overloading

编译如下:

解释多态最清晰的方法是通过一个抽象的基类(或者接口).看下面的一个抽象基类定义:

public abstract class Human
{
    ...
    public abstract void goPee();
}

我们定义了一个被称为”人类”的抽象基类.”去撒尿”这个方法是抽象的,因为对于整个人类来说没有一个统一的撒尿方法.只有当你具体地讨论这个人是男人还是女人的时候,”撒尿”这个方法才有具体的含义.与此同时,”人类”也是一个抽象的概念-不可能有一个既不是男人也不是女人的”人类”存在.当我们讨论一个人的时候,TA要么是个男人,要么是个女人.

public class Male extends Human
{
    ...
    @Override
    public void goPee()
    {
        System.out.println("Stand Up");
    }
}
public class Female extends Human
{
    ...
    @Override
    public void goPee()
    {
        System.out.println("Sit Down");
    }
}

现在我们有了更具体的关于男人和女人的类定义:男人站着撒尿,.他们都是人类的继承类,但是他们有不同的撒尿方法.

多态最完美的展现在于当我们试图让一屋子的人都去撒尿的时候:

public static void main(String args)
{
    ArrayList<Human> group = new ArrayList<Human>();
    group.add(new Male());
    group.add(new Female());
    // ... add more...

    // tell the class to take a pee break
    for (Human person : group)
    {
        person.goPee();
    }
}

得到的结果是:

Stand Up
Sit Down
Sit Down
Stand Up
Stand Up
...

Java继承的缺点

zhouchong阅读(38)评论(0)

java 中的继承的 优点和缺点如下:

  1. 优点:
    1.可以使用父类的所有非私有方法;而且单继承可由接口来弥补。
    2.可以继承父类中定义的成员方法以及成员变量,使得子类可以减少代码的书写。还可以重写父类的方法以增加子类的功能。
  2. 缺点:
    1.耦合性太大
    2.就是破坏了类的封装性,其实继承一般多用于抽象方法的继承和接口的实现

继承(inheritance)是实现代码重用的有力手段,但并非总是最好的选择。继承打破了封装性,因为子类依赖于超类中特定功能的实现细节。超类的实现有可能随着发行版本的不同而有所变化,导致子类遭到破坏。

  1. 子类遭到破坏的案例
  2. 使用复合和转发

子类遭到破坏的案例

假设有一个程序使用HashSet,为了查看它自创建以来曾经添加过多少个元素,我们可以通过继承扩展HashSet,重写add和addAll方法。

public class InstrumentedHashSet<E> extends HashSet<E> {
  private int addCount = 0;

  public InstrumentedHashSet() {}

  public InstrumentedHashSet(int initCap, float loadFactor) {
    super(initCap, loadFactor);
  }

  @Override
  public boolean add(E e) {
    addCount ++;
    return super.add(e);
  }

  @Override
  public boolean addAll(Collection<? extends E> c) {
    addCount += c.size();
    return super.addAll(c);
  }

  public int getAddCount() {
    return addCount;
  }
}

这段代码看上去没什么问题,假如执行下面的程序,我们期望getAddCount返回3,但它实际上返回的是6。

InstrumentedHashSet<String> s = new InstrumentedHashSet<String>();
s.addAll(Arrays.asList("Snap", "Crackle", "Pop"));
System.out.println(s.getAddCount());

哪里出错了?

在HashSet内部,addAll方法是基于add方法来实现的,即使HashSet的文档中并没有说明这一细节,这也是合理的。因此InstrumentedHashSet中的addAll方法首先把addCount增加了3,然后利用super.addAll()调用HashSet的addAll实现,在该实现中又调用了被InstrumentedHashSet覆盖了的add方法,每个元素调用一次,这三次又分别给addCount增加了1,所以总共增加了6。

因此,使用继承扩展一个类很危险,父类的具体实现很容易影响子类的正确性。而复合优先于继承告诉我们,不用扩展现有的类,而是在新类中增加一个私有域,让它引用现有类的一个实例。这种设计称为复合(Composition)。

使用复合和转发

使用复合来扩展一个类需要实现两部分:新的类和可重用的转发类。转发类用于将所有方法调用转发给私有域。这样得到的类非常稳固,不依赖于现有类的实现细节。请看下面的例子。

//Wrapper class - use composition in place of inheritance
public class InstrumentedSet<E> extends ForwardingSet<E>{

    private int addCount = 0;

    public InstrumentedSet(Set<E> s) {
        super(s);
    }

    @Override
    public boolean add(E e) {
        addCount ++;
        return super.add(e);
    }

    @Override
    public boolean addAll(Collection<? extends E> c) {
        addCount += c.size();
        return super.addAll(c);
    }

    public int getAddCount() {
        return addCount;
    }
}

//Reusable forwarding class
class ForwardingSet<E> implements Set<E> {

    private final Set<E> s;

    public ForwardingSet(Set<E> s) {this.s = s;}

    @Override
    public int size() {return s.size();}

    @Override
    public boolean isEmpty() {return s.isEmpty();}

    @Override
    public boolean contains(Object o) {return s.contains(o);}

    @Override
    public Iterator<E> iterator() {return s.iterator();}

    @Override
    public Object[] toArray() {return s.toArray();}

    @Override
    public <T> T[] toArray(T[] a) {return s.toArray(a);}

    @Override
    public boolean add(E e) {return s.add(e);}

    @Override
    public boolean remove(Object o) {return s.remove(o);}

    @Override
    public boolean containsAll(Collection<?> c) {return s.containsAll(c);}

    @Override
    public boolean addAll(Collection<? extends E> c) {return s.addAll(c);}

    @Override
    public boolean retainAll(Collection<?> c) {return s.retainAll(c);}

    @Override
    public boolean removeAll(Collection<?> c) {return s.retainAll(c);}

    @Override
    public void clear() {s.clear();}

}

现在,使用InstrumentedSet不会再出上面的问题了,因为无论是add方法还是addAll方法都转发给了私有域s来处理,这些方法对于s来说总是一致的,不会受InstrumentedSet的影响。另一个好处是此时的包装类InstrumentedSet可以用来包装任何Set实现,有了更广泛的适用性。例如

Set<Date> s = new InstrumentedSet<Date>(new TreeSet<Date>(cmp));
Set<E> s2 = new InstrumentedSet<E>(new HashSet<E>(capacity));

只有当子类和超类之间确实存在父子关系时,才可以考虑使用继承。否则都应该用复合,包装类不仅比子类更加健壮,而且功能也更加强大。

JVM运行时数据区

zhouchong阅读(32)评论(0)

JVM运行时数据区(JVM Runtime Area)其实就是指JVM在运行期间,其对计算机内存空间的划分和分配。本文将通过以下几个话题来讨论JVM运行时数据区。

    • Topic 1. JVM运行时数据区里有什么?
    • Topic 2. 虚拟机栈 是什么?虚拟机栈里有什么?
    • Topic 3.栈帧是什么?栈帧里有什么?
    • Topic 4. 方法区是什么?方法区里有什么?

Topic 1.JVM运行时数据区里有什么?

Topic 2. 虚拟机栈是什么?虚拟机栈里有什么?

 

Topic 3. 栈帧是什么?栈帧里有什么?

 

Topic 4. 方法区是什么?方法区里有什么?

JVM机器指令集

zhouchong阅读(32)评论(0)

0. 前言

Java虚拟机和真实的计算机一样,运行的都是二进制的机器码;而我们将.java 源代码编译成.class 文件,class文件便是Java虚拟机能够认识的二进制机器码,Java能够识别class文件中的信息和机器指令,进而执行这些机器指令。那么,Java虚拟机是如何运行这些二进制的机器码的呢? 本文将通过一个非常简单的例子,带你感受一下Java虚拟机运行机器码的过程和其工作的基本原理。

读完本文,你将会了解到:

1、Java虚拟机对运行时虚拟机栈(JVM Stack) 的组织

2、方法调用过程是怎样在JVM中表示的

3、JVM对一个方法执行的基本策略

4. JVM机器指令的格式

5. 机器指令的执行模式—基于操作数栈的模式

1. Java虚拟机对运行时虚拟机栈(JVM Stack)的组织

Java虚拟机在运行时会为每一个线程在内存中分配了一个虚拟机栈,来表示线程的运行状态和信息,虚拟机栈中的元素称之为栈帧(JVM stack frame),每一个栈帧表示这对一个方法的调用信息。如下所示:

上述的描述可能会有点抽象,为了给读者一个直观的感受,我们定义一个简单的Java类,然后执行这个运行这个类,逐步分析整个Java虚拟机的运行时信息的组织的。

2.  方法调用过程在JVM中是如何表示的

 

我们将定义如下带有main方法的简单类org.louis.jvm.codeset.Bootstrap.java ,逐步分析该类在JVM中是如何表示的,方法是如何一步步运行的:

  1. package org.louis.jvm.codeset;
  2. /**
  3.  * JVM 原理简单用例
  4.  * @author louis
  5.  *
  6.  */
  7. public class Bootstrap {
  8.     public static void main(String[] args) {
  9.         String name = “Louis”;
  10.         greeting(name);
  11.     }
  12.     public static void greeting(String name)
  13.     {
  14.         System.out.println(“Hello,”+name);
  15.     }
  16. }

当我们将Bootstrap.java 编译成Bootstrap.class 并运行这段程序的时候,在JVM复杂的运行逻辑中,会有以下几步:

1. 首先JVM会先将这个Bootstrap.class 信息加载到 内存中的方法区(Method Area)中。

Bootstrap.class 中包含了常量池信息,方法的定义 以及编译后的方法实现的二进制形式的机器指令,所有的线程共享一个方法区,从中读取方法定义和方法的指令集。

2. 接着,JVM会在Heap堆上为Bootstrap.class 创建一个Class<Bootstrap>实例用来表示Bootstrap.class 的 类实例。

3. JVM开始执行main方法,这时会为main方法创建一个栈帧,以表示main方法的整个执行过程(我会在后面章节中详细展开这个过程);

4. main方法在执行的过程之中,调用了greeting静态方法,则JVM会为greeting方法创建一个栈帧,推到虚拟机栈顶(我会在后面章节中详细展开这个过程)。

5.当greeting方法运行完成后,则greeting方法出栈,main方法继续运行;

JVM方法调用的过程是通过栈帧来实现的,那么,方法的指令是如何运行的呢?弄清楚这个之前,我们要先了解对于JVM而言,方法的结构是什么样的。

我们知道,class 文件时 JVM能够识别的二进制文件,其中通过特定的结构描述了每个方法的定义。

JVM在编译Bootstrap.java 的过程中,在将源代码编译成二进制机器码的同时,会判断其中的每一个方法的三个信息:

1 ).  在运行时会使用到的局部变量的数量(作用是:当JVM为方法创建栈帧的时候,在栈帧中为该方法创建一个局部变量表,来存储方法指令在运算时的局部变量值)

2 ).  其机器指令执行时所需要的最大的操作数栈的大小(当JVM为方法创建栈帧的时候,在栈帧中为方法创建一个操作数栈,保证方法内指令可以完成工作)

3 ).  方法的参数的数量

经过编译之后,我们可以得到main方法和greeting方法的信息如下:

注: 上述编译后的信息全部都存储在Bootstrap.class 文件中,并按照这Class文件格式的形式存储,关于Class文件格式的定义,我在前几篇文章中已经做了非常详尽的介绍,如果您全部阅读了,那么相信您已经可以“读懂” class 文件了。如何读懂class二进制文件中关于method及其相应机器码的组织,请阅读《Java虚拟机原理图解》1.5、 class文件中的方法表集合–method方法在class文件中是怎样组织的

JVM运行main方法的过程:

1.为main方法创建栈帧: 

JVM解析main方法,发现其 局部变量的数量为 2,操作数栈的数量为1, 则会为main方法创建一个栈帧(VM Stack),并将其加入虚拟机栈中:

2. 完成栈帧初始化:

main栈帧创建完成后,会将栈帧push 到虚拟机栈中,现在有两步重要的事情要做:

a). 计算PC值。PC 是指令计数器,其内部的值决定了JVM虚拟机下一步应该执行哪一个机器指令,而机器指令存放在方法区,我们需要让PC的值指向方法区的main方法上;

初始化 PC = main方法在方法区指令的地址+0;

b). 局部变量的初始化。main方法有个入参(String[] args) ,JVM已经在main所在的栈帧的局部变量表中为其空出来了一个slot ,我们需要将 args 的引用值初始化到局部点亮表中;

    1. 接着JVM开始读取PC指向的机器指令。如上图所示,main方法的指令序列:12 10 4c 2b b8 20 12 b1 ,通过JVM虚拟机指令集规范,可以将这个指令序列解析成以下Java汇编语言:
机器指令 汇编语言 解释 对栈帧的影响
0x12 0x10 ldc #16 将常量池中第16个常量池项引用推到操作数栈栈顶。
常量池第16项是CONSTANT_UTF-8_INFO项,表示”Louis”字符串
这里写图片描述
0x4c astore_1 操作数栈的栈顶元素出栈,将栈顶元素的值赋给index=1 的局部变量表元素上。

这里等价于:name = “Louis”.

这里写图片描述
0x2b aload_1 将局部变量表中index=1的元素的值推到操作数栈栈顶 这里写图片描述
0xb8 0x20 0x12 invokestatic #18 0xb8表示机器指令invokestatic,操作数是0x20 << 8| 0x12 = 18,操作数18表示指向常量池第18项,该项是main方法的符号引用:
org/louis/jvm/codeset/Bootstrap.greeting:(Ljava/lang/String;)V
当JVM执行这条语句的时候,会做以下几件事:
a).方法符号引用校验。会校验这个方法的符号引用,按照这个符号规则 在常量池中查找是否有这个方法的定义,如果找到了此方法的定义,则表示解析成功。如果是方法greeting:(Ljava/lang/String;)V没有找到,JVM会抛出错误NoSuchMethodError
b).为新的方法调用创建新的栈帧。然后JVM会为此方法greeting创建一个新的栈帧(VM stack),并根据greeting中操作数栈的大小和局部变量的数量分别创建相应大小的操作数栈;然后将此栈帧推到虚拟机栈的栈顶。
c).更新PC指令计数器的值。将当前PC程序计数器的值记录到greeting栈帧中,当greeting执行完成后,以便恢复PC值。更新PC的值,使下一条执行的指令地址指向greeting方法的指令开始部分。
这条语句会使当前的main方法执行暂停,使JVM进入对greeting方法的执行当中当greeting方法执行完成后,才会恢复PC程序计数器的值指向当前下一条指令。
0xb1 return 返回

当main方法调用greeting()时, JVM会为greeting方法创建一个栈帧,用以表示对greeting方法的调用,具体栈帧信息如下:

具体的greeting方法的机器码表示的含义如下图所示:

机器指令 汇编语言 解释 常量池引用
b2 20 1a getstatic     #26 获取指定类的静态域,并将其值压入栈顶.
将常量池中的第26个符号引用推到操作数栈中:
#26:
// Field java/lang/System.out:Ljava/io/PrintStream;
bb 20 20 new           #32 创建一个对象,并将其引用值压入栈顶。
创建一个java/lang/StringBuider实例,将其压入栈顶。
#32:
// class java/lang/StringBuilder
59 dup 复制操作数栈栈顶的值,并插入到栈顶
12 22 ldc           #34 从运行时常量池中提取数据推入操作数栈
将“Hello” String引用复制到 操作数栈中
#34:
// String Hello,
b7 20 24  invokespecial #36 调用超类构造方法,实例初始化方法,私有方法。
此处调用StringBuilder(String)构造方法,并将结果推到栈顶
#36:
// Method java/lang/StringBuilder.”<init>”:(Ljava/lang/String;)V
2a  aload_0 将第一个局部变量的引用推到栈顶。
当前局部变量表的第一个局部变量引用是 :“Louis”,即将Louis推到栈顶
b6 20 26 invokevirtual #38 调用超类构造方法,实例初始化方法,私有方法。
StringBuilder实例的 append(String ) 方法,表示:
“Hello,”+”Louis”.
// Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
b6 20 2a  invokevirtual #42 调用超类构造方法,实例初始化方法,私有方法。
调用StringBuilder实例的toString()方法,结果保留在栈顶。
 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
b6 20 2e invokevirtual #46 调用超类构造方法,实例初始化方法,私有方法。
调用System.out.println(String)方法
 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
b1 return 结束返回

3.  JVM对一个方法执行的基本策略

 

一般地,对于java方法的执行,在JVM在其某一特定线程的虚拟机栈(JVM Stack) 中会为方法分配一个 局部变量表,一个操作数栈,用以存储方法的运行过程中的中间值存储。

由于JVM的指令是基于栈的,即大部分的指令的执行,都伴随着操作数的出栈和入栈。所以在学习JVM的机器指令的时候,一定要铭记一点:

每个机器指令的执行,对操作数栈和局部变量的影响,充分地了解了这个机制,你就可以非常顺畅地读懂class文件中的二进制机器指令了。

如下是栈帧信息的简化图,在分析JVM指令时,脑海中对栈帧有个清晰的认识:

4.  机器指令的格式

 

所谓的机器指令,就是只有机器才能够认识的二进制代码。一个机器指令分为两部分组成:

注:

a).  如上图所示JVM虚拟机的操作码是由一个字节组成的,也就是说对于JVM虚拟机而言,其指令的数量最多为 2^8,即 256个;

b). 上图中的操作码如:b2,bb,59….等等都是表示某一特定的机器指令,为了方便我们识别,其分别有相应的助记符:getstatic,new,dup…. 这样方便我们理解。

5.  机器指令的执行模式—基于操作数栈的模式

对于传统的物理机而言,大部分的机器指令的设计都是寄存器的,物理机内设置若干个寄存器,用以存储机器指令运行过程中的值,寄存器的数量和支持的指令的个数决定了这个机器的处理能力。

但是Java虚拟机的设计的机制并不是这样的,Java虚拟机使用操作数栈 来存储机器指令的运算过程中的值。所有的操作数的操作,都要遵循出栈和入栈的规则,所以在《Java虚拟机规范》中,你会发现有很多机器指令都是关于出栈入栈的操作。

本文旨在介绍JVM虚拟机指令的运行原理,如果你想更深入地了解指令集的信息以及使用注意事项,请您阅读《Java虚拟机规范(Java Virtual Machine Specification)》 关于机器指令集的详细定义。

Java_Virtual_Machine_Specification_Java_SE_7_中文版 

JVM类加载器机制与类加载过程

zhouchong阅读(32)评论(0)

《Java虚拟机原理图解》5. JVM类加载器机制与类加载过程

标签: JVM原理JVM虚拟机跨平台class
6414人阅读 评论(6) 收藏 举报
分类:

目录(?)[+]

0、前言

读完本文,你将了解到:

一、为什么说Jabalpur语言是跨平台的

二、Java虚拟机启动、加载类过程分析

三、类加载器有哪些?其组织结构是怎样的?

四、双亲加载模型的逻辑和底层代码实现是怎样的?

五、类加载器与Class<T>  实例的关系

六、线程上下文加载器

一、为什么说Java语言是跨平台的?

Java语言之所以说它是跨平台的、可以在当前绝大部分的操作系统平台下运行,是因为Java语言的运行环境是在Java虚拟机中。
Java虚拟机消除了各个平台之间的差异,只要操作系统平台下安装了Java虚拟机,那么使用Java开发的东西都能在其上面运行。如下图所示:

这里写图片描述

Java虚拟机对各个平台而言,实质上是各个平台上的一个可执行程序。例如在windows平台下,java虚拟机对于windows而言,就是一个java.exe进程而已。

二、Java虚拟机启动、加载类过程分析

下面我将定义一个非常简单的java程序并运行它,来逐步分析java虚拟机启动的过程。

  1. package org.luanlouis.jvm.load;
  2. import sun.security.pkcs11.P11Util;
  3. /**
  4.  * Created by louis on 2016/1/16.
  5.  */
  6. public class Main{
  7.     public static void main(String[] args) {
  8.         System.out.println(“Hello,World!”);
  9.         ClassLoader loader = P11Util.class.getClassLoader();
  10.         System.out.println(loader);
  11.     }
  12. }

在windows命令行下输入:

java    org.luanlouis.jvm.load.Main

当输入上述的命令时:
windows开始运行{JRE_HOME}/bin/java.exe程序,java.exe 程序将完成以下步骤:

1.  根据JVM内存配置要求,为JVM申请特定大小的内存空间;
2.  创建一个引导类加载器实例,初步加载系统类到内存方法区区域中;

3.   创建JVM 启动器实例 Launcher,并取得类加载器ClassLoader;

4.  使用上述获取的ClassLoader实例加载我们定义的 org.luanlouis.jvm.load.Main类;
5.  加载完成时候JVM会执行Main类的main方法入口,执行Main类的main方法;

6.  结束,java程序运行结束,JVM销毁。

Step 1.根据JVM内存配置要求,为JVM申请特定大小的内存空间

为了不降低本文的理解难度,这里就不详细介绍JVM内存配置要求的话题,今概括地介绍一下内存的功能划分。

JVM启动时,按功能划分,其内存应该由以下几部分组成:
这里写图片描述
如上图所示,JVM内存按照功能上的划分,可以粗略地划分为方法区(Method Area)堆(Heap),而所有的类的定义信息都会被加载到方法区中。

关于具体方法区里有什么内容,读者可以参考我的另一篇博文:
《Java虚拟机原理图解》3、JVM运行时数据区

Step 2. 创建一个引导类加载器实例,初步加载系统类到内存方法区区域中;

JVM申请好内存空间后,JVM会创建一个引导类加载器(Bootstrap Classloader)实例,引导类加载器是使用C++语言实现的,负责加载JVM虚拟机运行时所需的基本系统级别的类,如java.lang.String, java.lang.Object等等。引导类加载器(Bootstrap Classloader)会读取 {JRE_HOME}/lib 下的jar包和配置,然后将这些系统类加载到方法区内。

本例中,引导类加载器是用 {JRE_HOME}/lib加载类的,不过,你也可以使用参数 -Xbootclasspath 或 系统变量sun.boot.class.path来指定的目录来加载类。

一般而言,{JRE_HOME}/lib下存放着JVM正常工作所需要的系统类,如下表所示:

文件名 描述
rt.jar 运行环境包,rt即runtime,J2SE 的类定义都在这个包内
charsets.jar 字符集支持包
jce.jar 是一组包,它们提供用于加密、密钥生成和协商以及 Message Authentication Code(MAC)算法的框架和实现
jsse.jar 安全套接字拓展包Java(TM) Secure Socket Extension
classlist 该文件内表示是引导类加载器应该加载的类的清单
net.properties JVM 网络配置信息

引导类加载器(Bootstrap ClassLoader) 加载系统类后,JVM内存会呈现如下格局:
这里写图片描述

  • 引导类加载器将类信息加载到方法区中,以特定方式组织,对于某一个特定的类而言,在方法区中它应该有 运行时常量池类型信息字段信息方法信息类加载器的引用对应class实例的引用等信息。
  • 类加载器的引用,由于这些类是由引导类加载器(Bootstrap Classloader)进行加载的,而 引导类加载器是有C++语言实现的,所以是无法访问的,故而该引用为NULL
  • 对应class实例的引用, 类加载器在加载类信息放到方法区中后,会创建一个对应的Class 类型的实例放到堆(Heap)中, 作为开发人员访问方法区中类定义的入口和切入点。
小测试:

当我们在代码中尝试获取系统类如java.lang.Object的类加载器时,你会始终得到NULL:

  1. System.out.println(String.class.getClassLoader());//null
  2. System.out.println(Object.class.getClassLoader());//null
  3. System.out.println(Math.class.getClassLoader());//null
  4. System.out.println(System.class.getClassLoader());//null

Step 3. 创建JVM 启动器实例 Launcher,并取得类加载器ClassLoader

上述步骤完成,JVM基本运行环境就准备就绪了。接着,我们要让JVM工作起来了:运行我们定义的程序 org.luanlouis,jvm.load.Main。

此时,JVM虚拟机调用已经加载在方法区的类sun.misc.Launcher 的静态方法getLauncher(),  获取sun.misc.Launcher 实例:

  1. sun.misc.Launcher launcher = sun.misc.Launcher.getLauncher(); //获取Java启动器
  2. ClassLoader classLoader = launcher.getClassLoader();          //获取类加载器ClassLoader用来加载class到内存来

sun.misc.Launcher 使用了单例模式设计,保证一个JVM虚拟机内只有一个sun.misc.Launcher实例。
在Launcher的内部,其定义了两个类加载器(ClassLoader),分别是sun.misc.Launcher.ExtClassLoadersun.misc.Launcher.AppClassLoader,这两个类加载器分别被称为拓展类加载器(Extension ClassLoader)应用类加载器(Application ClassLoader).如下图所示:

图例注释:除了引导类加载器(Bootstrap Class Loader )的所有类加载器,都有一个能力,就是判断某一个类是否被引导类加载器加载过,如果加载过,可以直接返回对应的Class<T> instance,如果没有,则返回null.  图上的指向引导类加载器的虚线表示类加载器的这个有限的访问 引导类加载器的功能。

此时的  launcher.getClassLoader() 方法将会返回 AppClassLoader 实例,AppClassLoaderExtClassLoader作为自己的父加载器。

AppClassLoader加载类时,会首先尝试让父加载器ExtClassLoader进行加载,如果父加载器ExtClassLoader加载成功,则AppClassLoader直接返回父加载器ExtClassLoader加载的结果;如果父加载器ExtClassLoader加载失败,AppClassLoader则会判断该类是否是引导的系统类(即是否是通过Bootstrap类加载器加载,这会调用Native方法进行查找);若要加载的类不是系统引导类,那么ClassLoader将会尝试自己加载,加载失败将会抛出“ClassNotFoundException”。

具体AppClassLoader的工作流程如下所示:

双亲委派模型(parent-delegation model):

上面讨论的应用类加载器AppClassLoader的加载类的模式就是我们常说的双亲委派模型(parent-delegation model).
对于某个特定的类加载器而言,应该为其指定一个父类加载器,当用其进行加载类的时候:

1. 委托父类加载器帮忙加载;
2. 父类加载器加载不了,则查询引导类加载器有没有加载过该类;
3. 如果引导类加载器没有加载过该类,则当前的类加载器应该自己加载该类;
4. 若加载成功,返回 对应的Class<T> 对象;若失败,抛出异常“ClassNotFoundException”。

请注意:
双亲委派模型中的”双亲”并不是指它有两个父类加载器的意思,一个类加载器只应该有一个父加载器。上面的步骤中,有两个角色:
1. 父类加载器(parent classloader):它可以替子加载器尝试加载类
2. 引导类加载器(bootstrap classloader): 子类加载器只能判断某个类是否被引导类加载器加载过,而不能委托它加载某个类;换句话说,就是子类加载器不能接触到引导类加载器,引导类加载器对其他类加载器而言是透明的。

一般情况下,双亲加载模型如下所示:

Step 4. 使用类加载器ClassLoader加载Main类

通过 launcher.getClassLoader()方法返回AppClassLoader实例,接着就是AppClassLoader加载 org.luanlouis.jvm.load.Main类的时候了。

  1. ClassLoader classloader = launcher.getClassLoader();//取得AppClassLoader类
  2. classLoader.loadClass(“org.luanlouis.jvm.load.Main”);//加载自定义类

上述定义的org.luanlouis.jvm.load.Main类被编译成org.luanlouis.jvm.load.Main class二进制文件,这个class文件中有一个叫常量池(Constant Pool)的结构体来存储该class的常亮信息。常量池中有CONSTANT_CLASS_INFO类型的常量,表示该class中声明了要用到那些类:

当AppClassLoader要加载 org.luanlouis.jvm.load.Main类时,会去查看该类的定义,发现它内部声明使用了其它的类: sun.security.pkcs11.P11Util、java.lang.Object、java.lang.System、java.io.PrintStream、java.lang.Class;org.luanlouis.jvm.load.Main类要想正常工作,首先要能够保证这些其内部声明的类加载成功。所以AppClassLoader要先将这些类加载到内存中。(注:为了理解方便,这里没有考虑懒加载的情况,事实上的JVM加载类过程比这复杂的多)

加载顺序:

1. 加载java.lang.Object、java.lang.System、java.io.PrintStream、java,lang.Class

AppClassLoader尝试加载这些类的时候,会先委托ExtClassLoader进行加载;而ExtClassLoader发现不是其加载范围,其返回null;AppClassLoader发现父类加载器ExtClassLoader无法加载,则会查询这些类是否已经被BootstrapClassLoader加载过,结果表明这些类已经被BootstrapClassLoader加载过,则无需重复加载,直接返回对应的Class<T>实例;

2. 加载sun.security.pkcs11.P11Util

此在{JRE_HOME}/lib/ext/sunpkcs11.jar包内,属于ExtClassLoader负责加载的范畴。AppClassLoader尝试加载这些类的时候,会先委托ExtClassLoader进行加载;而ExtClassLoader发现其正好属于加载范围,故ExtClassLoader负责将其加载到内存中。ExtClassLoader在加载sun.security.pkcs11.P11Util时也分析这个类内都使用了哪些类,并将这些类先加载内存后,才开始加载sun.security.pkcs11.P11Util,加载成功后直接返回对应的Class<sun.security.pkcs11.P11Util>实例;

3. 加载org.luanlouis.jvm.load.Main

AppClassLoader尝试加载这些类的时候,会先委托ExtClassLoader进行加载;而ExtClassLoader发现不是其加载范围,其返回null;AppClassLoader发现父类加载器ExtClassLoader无法加载,则会查询这些类是否已经被BootstrapClassLoader加载过。而结果表明BootstrapClassLoader 没有加载过它,这时候AppClassLoader只能自己动手负责将其加载到内存中,然后返回对应的Class<org.luanlouis.jvm.load.Main>实例引用;

以上三步骤都成功,才表示classLoader.loadClass(“org.luanlouis.jvm.load.Main”)完成,上述操作完成后,JVM内存方法区的格局会如下所示:

如上图所示:

  • JVM方法区的类信息区是按照类加载器进行划分的,每个类加载器会维护自己加载类信息;
  • 某个类加载器在加载相应的类时,会相应地在JVM内存堆(Heap)中创建一个对应的Class<T>,用来表示访问该类信息的入口

Step 5. 使用Main类的main方法作为程序入口运行程序

Step 6. 方法执行完毕,JVM销毁,释放内存

三、类加载器有哪些?其组织结构是怎样的?

类加载器(Class Loader):顾名思义,指的是可以加载类的工具。JVM自身定义了三个类加载器:引导类加载器(Bootstrap Class Loader)、拓展类加载器(Extension Class Loader )、应用加载器(Application Class Loader)。当然,我们有时候也会自己定义一些类加载器来满足自身的需要。

引导类加载器(Bootstrap Class Loader): 该类加载器使JVM使用C/C++底层代码实现的加载器,用以加载JVM运行时所需要的系统类,这些系统类在{JRE_HOME}/lib目录下。由于类加载器是使用平台相关的底层C/C++语言实现的, 所以该加载器不能被Java代码访问到。但是,我们可以查询某个类是否被引导类加载器加载过。我们经常使用的系统类如:java.lang.String,java.lang.Object,java.lang*……. 这些都被放在 {JRE_HOME}/lib/rt.jar包内, 当JVM系统启动的时候,引导类加载器会将其加载到 JVM内存的方法区中。

拓展类加载器(Extension Class Loader): 该加载器是用于加载 java 的拓展类 ,拓展类一般会放在 {JRE_HOME}/lib/ext/ 目录下,用来提供除了系统类之外的额外功能。拓展类加载器是是整个JVM加载器的Java代码可以访问到的类加载器的最顶端,即是超级父加载器,拓展类加载器是没有父类加载器的。

           应用类加载器(Applocatoin Class Loader): 该类加载器是用于加载用户代码,是用户代码的入口。我经常执行指令 java   xxx.x.xxx.x.x.XClass , 实际上,JVM就是使用的AppClassLoader加载 xxx.x.xxx.x.x.XClass 类的。应用类加载器将拓展类加载器当成自己的父类加载器,当其尝试加载类的时候,首先尝试让其父加载器-拓展类加载器加载;如果拓展类加载器加载成功,则直接返回加载结果Class<T> instance,加载失败,则会询问是否引导类加载器已经加载了该类;只有没有加载的时候,应用类加载器才会尝试自己加载。由于xxx.x.xxx.x.x.XClass是整个用户代码的入口,在Java虚拟机规范中,称其为 初始类(Initial Class).


用户自定义类加载器(Customized Class Loader):用户可以自己定义类加载器来加载类。所有的类加载器都要继承java.lang.ClassLoader类。

          

四、双亲加载模型的逻辑和底层代码实现是怎样的?

上面已经不厌其烦地讲解什么是双亲加载模型,以及其机制是什么,这些东西都是可以通过底层代码查看到的。             我们也可以通过JDK源码看java.lang.ClassLoader的核心方法 loadClass()的实现:

  1. //提供class类的二进制名称表示,加载对应class,加载成功,则返回表示该类对应的Class<T> instance 实例
  2. public Class<?> loadClass(String name) throws ClassNotFoundException {
  3.     return loadClass(name, false);
  4. }
  5. protected Class<?> loadClass(String name, boolean resolve)
  6.     throws ClassNotFoundException
  7. {
  8.     synchronized (getClassLoadingLock(name)) {
  9.         // 首先,检查是否已经被当前的类加载器记载过了,如果已经被加载,直接返回对应的Class<T>实例
  10.         Class<?> c = findLoadedClass(name);
  11.             //初次加载
  12.             if (c == null) {
  13.             long t0 = System.nanoTime();
  14.             try {
  15.                 if (parent != null) {
  16.                     //如果有父类加载器,则先让父类加载器加载
  17.                     c = parent.loadClass(name, false);
  18.                 } else {
  19.                     // 没有父加载器,则查看是否已经被引导类加载器加载,有则直接返回
  20.                     c = findBootstrapClassOrNull(name);
  21.                 }
  22.             } catch (ClassNotFoundException e) {
  23.                 // ClassNotFoundException thrown if class not found
  24.                 // from the non-null parent class loader
  25.             }
  26.             // 父加载器加载失败,并且没有被引导类加载器加载,则尝试该类加载器自己尝试加载
  27.             if (c == null) {
  28.                 // If still not found, then invoke findClass in order
  29.                 // to find the class.
  30.                 long t1 = System.nanoTime();
  31.                 // 自己尝试加载
  32.                 c = findClass(name);
  33.                 // this is the defining class loader; record the stats
  34.                 sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 – t0);
  35.                 sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
  36.                 sun.misc.PerfCounter.getFindClasses().increment();
  37.             }
  38.         }
  39.         //是否解析类 
  40.         if (resolve) {
  41.             resolveClass(c);
  42.         }
  43.         return c;
  44.     }
  45. }

相对应地,我们可以整理出双亲模型的工作流程图:

相信读者看过这张图后会对双亲加载模型有了非常清晰的脉络。当然,这是JDK自身默认的加载类的行为,我们可以通过继承复写该方法,改变其行为。

五、类加载器与Class<T>  实例的关系

六、线程上下文加载器

Java 任何一段代码的执行,都有对应的线程上下文。如果我们在代码中,想看当前是哪一个线程在执行当前代码,我们经常是使用如下方法:

  1. Thread  thread = Thread.currentThread();//返回对当当前运行线程的引用


相应地,我们可以为当前的线程指定类加载器。在上述的例子中, 当执行   java    org.luanlouis.jvm.load.Main  的时候,JVM会创建一个Main线程,而创建应用类加载器AppClassLoader的时候,会将AppClassLoader  设置成Main线程的上下文类加载器:

  1.   public Launcher() {
  2.       Launcher.ExtClassLoader var1;
  3.       try {
  4.           var1 = Launcher.ExtClassLoader.getExtClassLoader();
  5.       } catch (IOException var10) {
  6.           throw new InternalError(“Could not create extension class loader”, var10);
  7.       }
  8.       try {
  9.           this.loader = Launcher.AppClassLoader.getAppClassLoader(var1);
  10.       } catch (IOException var9) {
  11.           throw new InternalError(“Could not create application class loader”, var9);
  12.       }
  13. //将AppClassLoader设置成当前线程的上下文加载器
  14.       Thread.currentThread().setContextClassLoader(this.loader);
  15.       //…….
  16.   }

线程上下文类加载器是从线程的角度来看待类的加载,为每一个线程绑定一个类加载器,可以将类的加载从单纯的 双亲加载模型解放出来,进而实现特定的加载需求。

class文件中的方法表集合–method方法在class文件中是怎样组织的

zhouchong阅读(35)评论(0)

0. 前言

了解JVM虚拟机原理是每一个Java程序员修炼的必经之路。但是由于JVM虚拟机中有很多的东西讲述的比较宽泛,在当前接触到的关于JVM虚拟机原理的教程或者博客中,绝大部分都是充斥的文字性的描述,很难给人以形象化的认知,看完之后感觉还是稀里糊涂的。

感于以上的种种,我打算把我在学习JVM虚拟机的过程中学到的东西,结合自己的理解,总结成《Java虚拟机原理图解》 这个系列,以图解的形式,将抽象的JVM虚拟机的知识具体化,希望能够对想了解Java虚拟机原理的的Java程序员 提供点帮助。

读完本文,你将会学到:

1、类中定义的method方法是如何在class文件中组织的

2、method方法的表示-方法表集合在class文件的什么位置

3、类中的method方法的实现代码—即机器码指令存放到哪了,并初步了解机器指令

4. 为什么没有在类中定义自己的构造函数,却可以使用new ClassName()构造函数创建对象

5. IDE代码提示功能的基本原理

1.概述

      方法表集合是指由若干个方法表(method_info)组成的集合。对于在类中定义的若干个,经过JVM编译成class文件后,会将相应的method方法信息组织到一个叫做方法表集合的结构中,字段表集合是一个类数组结构,如下图所示:

2. method方法的描述-方法表集合在class文件中的位置

method方法的描述-方法表集合紧跟在字段表集合的后面(想了解字段表集合的读者可以点击我查看),如下图所示:

接下来让我们看看Method_info 结构体是怎么组织method方法信息的:

3. 一个类中的method方法应该包含哪些信息?—-method_info结构体的定义

对于一个方法的表示,我们根据我们可以概括的信息如下所示:

实际上JVM还会对method方法的描述添加其他信息,我们将在后面详细讨论。如上图中的method_info结构体的定义,该结构体的定义跟描述field字段field_info结构体的结构几乎完全一致,如下图所示。

方法表的结构体由:访问标志(access_flags)、名称索引(name_index)、描述索引(descriptor_index)、属性表(attribute_info)集合组成。

访问标志(access_flags):

method_info结构体最前面的两个字节表示的访问标志(access_flags),记录这这个方法的作用域、静态or非静态、可变性、是否可同步、是否本地方法、是否抽象等信息,实际上不止这些信息,我们后面会详细介绍访问标志这两个字节的每一位具体表示什么意思。

名称索引(name_index):

紧跟在访问标志(access_flags)后面的两个字节称为名称索引,这两个字节中的值指向了常量池中的某一个常量池项,这个方法的名称以UTF-8格式的字符串存储在这个常量池项中。如public void methodName(),很显然,“methodName”则表示着这个方法的名称,那么在常量池中会有一个CONSTANT_Utf8_info格式的常量池项,里面存储着“methodName”字符串,而mehodName()方法的方法表中的名称索引则指向了这个常量池项。

描述索引(descriptor_index):

描述索引表示的是这个方法的特征或者说是签名一个方法会有若干个参数和返回值,而若干个参数的数据类型和返回值的数据类型构成了这个方法的描述,其基本格式为:     (参数数据类型描述列表)返回值数据类型   。我们将在后面继续讨论。

属性表(attribute_info)集合:

    这个属性表集合非常重要,方法的实现被JVM编译成JVM的机器码指令机器码指令就存放在一个Code类型的属性表中;如果方法声明要抛出异常,那么异常信息会在一个Exceptions类型的属性表中予以展现。Code类型的属性表可以说是非常复杂的内容,也是本文最难的地方。

接下来,我们将一一击破它们,看看它们到底是怎么表示的。

4. 访问标志(access_flags)—记录着method方法的访问信息

访问标志(access_flags)共占有2 个字节,分为 16 位,这 16位 表示的含义如下所示:

举例:某个类中定义了如下方法:

  1. public static synchronized final void greeting(){
  2. }

greeting()方法的修饰符有:public、static、synchronized、final 这几个修饰符修饰,那么相对应地,greeting()方法的访问标志中的ACC_PUBLIC、ACC_STATIC、ACC_SYNCHRONIZED、ACC_FINAL标志位都应该是1,即:

从上图中可以看出访问标志的值应该是二进制00000000 00111001,即十六进制0x0039。我们将在文章的最后一个例子中证实这里点。

5. 名称索引和描述符索引—-一个方法的签名

    紧接着访问标志(access_flags)后面的两个字节,叫做名称索引(name_index),这两个字节中的值是指向了常量池中某个常量池项的索引,该常量池项表示这这个方法名称的字符串。

方法描述符索引(descrptor_index)是紧跟在名称索引后面的两个字节,这两个字节中的值跟名称索引中的值性质一样,都是指向了常量池中的某个常量池项。这两个字节中的指向的常量池项,是表示了方法描述符的字符串

所谓的方法描述符,实质上就是指用一个什么样的字符串来描述一个方法,方法描述符的组成如下图所示:

关于不同的数据类型的描述符是怎样的,我已经在《Java虚拟机原理图解》1.4 class文件中的字段表集合–field字段在class文件中是怎样组织的  第五部分字段的数据类型表示和字段名称表示 进行过详细的阐释,感兴趣的读者可以前去查看。

 

举例:对于如下定义的的greeting()方法,我们来看一下对应的method_info结构体中的名称索引和描述符索引信息是怎样组织的。

  1. public static synchronized final void greeting(){
  2. }

如下图所示,method_info结构体的名称索引中存储了一个索引值x,指向了常量池中的第x项,第 x项表示的是字符串”greeting“,即表示该方法名称是”greeting“;描述符索引中的y 值指向了常量池的第y项,该项表示字符串”()V“,即表示该方法没有参数,返回值是void类型。

6.属性表集合–记录方法的机器指令和抛出异常等信息

属性表集合记录了某个方法的一些属性信息,这些信息包括:

  • 这个方法的代码实现,即方法的可执行的机器指令
  • 这个方法声明的要抛出的异常信息
  • 这个方法是否被@deprecated注解表示
  • 这个方法是否是编译器自动生成的

属性表(attribute_info)结构体的一般结构如下所示:

6.1 Code类型的属性表–method方法中的机器指令的信息

     Code类型的属性表(attribute_info)可以说是class文件中最为重要的部分,因为它包含的是JVM可以运行的机器码指令,JVM能够运行这个类,就是从这个属性中取出机器码的。除了要执行的机器码,它还包含了一些其他信息,如下所示:

Code属性表的组成部分:

机器指令—-code:

目前的JVM使用一个字节表示机器操作码,即对JVM底层而言,它能表示的机器操作码不多于28 次方,即 256个。class文件中的机器指令部分是class文件中最重要的部分,并且非常复杂,本文的重点不止介绍它,我将专门在一片博文中讨论它,敬请期待。

异常处理跳转信息—exception_table:

如果代码中出现了try{}catch{}块,那么try{}块内的机器指令的地址范围记录下来,并且记录对应的catch{}块中的起始机器指令地址,当运行时在try块中有异常抛出的话,JVM会将catch{}块对应懂得其实机器指令地址传递给PC寄存器,从而实现指令跳转;

Java源码行号和机器指令的对应关系—LineNumberTable属性表:

编译器在将java源码编译成class文件时,会将源码中的语句行号跟编译好的机器指令关联起来,这样的class文件加载到内存中并运行时,如果抛出异常,JVM可以根据这个对应关系,抛出异常信息,告诉我们我们的源码的多少行有问题,方便我们定位问题。这个信息不是运行时必不可少的信息,但是默认情况下,编译器会生成这一项信息,如果你项取消这一信息,你可以使用-g:none-g:lines来取消或者要求设置这一项信息。如果使用了-g:none来生成class文件,class文件中将不会有LineNumberTable属性表,造成的影响就是 将来如果代码报错,将无法定位错误信息报错的行,并且如果项调试代码,将不能在此类中打断点(因为没有指定行号。)

局部变量表描述信息—-LocalVariableTable属性表:

局部变量表信息会记录栈帧局部变量表中的变量和java源码中定义的变量之间的关系,这个信息不是运行时必须的属性,默认情况下不会生成到class文件中。你可以根据javac指令的-g:none或者-g:vars选项来取消或者设置这一项信息。

它有什么作用呢?  当我们使用IDE进行开发时,最喜欢的莫过于它们的代码提示功能了。如果在项目中引用到了第三方的jar包,而第三方的包中的class文件中有无LocalVariableTable属性表的区别如下所示:

Code属性表结构体的解释:

1.attribute_name_index,属性名称索引,占有2个字节,其内的值指向了常量池中的某一项,该项表示字符串“Code”;
2. attribute_length,属性长度,占有 4个字节,其内的值表示后面有多少个字节是属于此Code属性表的;
3. max_stack,操作数栈深度的最大值,占有 2 个字节,在方法执行的任意时刻,操作数栈都不应该超过这个值,虚拟机的运行的时候,会根据这个值来设置该方法对应的栈帧(Stack Frame)中的操作数栈的深度;
4. max_locals,最大局部变量数目,占有 2个字节,其内的值表示局部变量表所需要的存储空间大小;
5. code_length,机器指令长度,占有 4 个字节,表示跟在其后的多少个字节表示的是机器指令;
6. code,机器指令区域,该区域占有的字节数目由 code_length中的值决定。JVM最底层的要执行的机器指令就存储在这里;
7. exception_table_length,显式异常表长度,占有2个字节,如果在方法代码中出现了try{} catch()形式的结构,该值不会为空,紧跟其后会跟着若干个exception_table结构体,以表示异常捕获情况;
8. exception_table显式异常表,占有8 个字节,start_pc,end_pc,handler_pc中的值都表示的是PC计数器中的指令地址。exception_table表示的意思是:如果字节码从第start_pc行到第end_pc行之间出现了catch_type所描述的异常类型,那么将跳转到handler_pc行继续处理。
9. attribute_count,属性计数器,占有 2 个字节,表示Code属性表的其他属性的数目
10. attribute_info,表示Code属性表具有的属性表,它主要分为两个类型的属性表:“LineNumberTable”类型和“LocalVariableTable”类型。
LineNumberTable”类型的属性表记录着Java源码和机器指令之间的对应关系
LocalVariableTable”类型的属性表记录着局部变量描述

举例:

如下定义Simple类,使用javac -g:none Simple.java 编译出Simple.class 文件,并使用javap -v Simple > Simple.txt 查看反编译的信息,然后看Simple.class文件中的方法表集合是怎样组织的:

  1. package com.louis.jvm;
  2. public class Simple {
  3.     public static synchronized final void greeting(){
  4.         int a = 10;
  5.     }
  6. }

1. Simple.class文件组织信息如下所示:

如上所示,方法表集合使用了蓝色线段圈了起来。

请注意:方法表集合的头两个字节,即方法表计数器(method_count)的值是0x0002,它表示该类中有2 个方法。细心的读者会注意到,我们的Simple.java中就定义了一个greeting()方法,为什么class文件中会显示有两个方法呢??

JVM为没有显式定义实例化构造方法的类,自动生成默认的实例化构造方法”<init>()”

      这是因为:如果我们在类中没有定义实例化构造方法,JVM编译器在将源码编译成class文件时,会自动地为这个类添加一个不带参数的实例化构造方法,这种添加是字节码级别的,JVM对所有的类实例化构造方法名采用了相同的名称:“<init>”。如果我们显式地如下定义Simple()构造函数,这个类编译出来的class文件和上面的不带Simple构造方法的Simple类生成的class文件是完全相同的:

  1. package com.louis.jvm;
  2. public class Simple {
  3.     public Simple(){
  4.         super();
  5.     }
  6.     public static synchronized final void greeting(){
  7.         int a = 10;
  8.     }
  9. }

这也就是为什么虽然我们显式地在类中定义类构造方法,却可以使用 new ClassName()创建实例了。    除了实例化构造方法,JVM还会在特殊的情况下生成一个叫类构造方法”<cinit>()“。如果我们在类中使用到了static修饰的代码块,那么,JVM会在class文件中生成一个“<cinit>()”构造方法。关于它们的具体细节,我将在后续的文章中详细讨论,在这里就不展开了。

Simple.classz中出现了两个方法表,分别代表构造方法<init>() greeting()方法,现在让我们分别来讨论这两个方法:

2.  Simple.class 中的<init>() 方法:

 解释:

 1. 方法访问标志(access_flags): 占有 2个字节,值为0x0001,即标志位的第 16 位为 1,所以该<init>()方法的修饰符是:ACC_PUBLIC;

2. 名称索引(name_index): 占有 2 个字节,值为 0x0004,指向常量池的第 4项,该项表示字符串“<init>”,即该方法的名称是“<init>”;

3.描述符索引(descriptor_index): 占有 2 个字节,值为0x0005,指向常量池的第 5 项,该项表示字符串“()V”,即表示该方法不带参数,并且无返回值(构造函数确实也没有返回值);

4. 属性计数器(attribute_count): 占有 2 个字节,值为0x0001,表示该方法表中含有一个属性表,后面会紧跟着一个属性表;

5. 属性表的名称索引(attribute_name_index):占有 2 个字节,值为0x0006,指向常量池中的第6 项,该项表示字符串“Code”,表示这个属性表是Code类型的属性表;

6. 属性长度(attribute_length):占有4个字节,值为0x0000 0011,即十进制的 17,表明后续的 17 个字节可以表示这个Code属性表的属性信息;

7. 操作数栈的最大深度(max_stack):占有2个字节,值为0x0001,表示栈帧中操作数栈的最大深度是1

8. 局部变量表的最大容量(max_variable):占有2个字节,值为0x0001, JVM在调用该方法时,根据这个值设置栈帧中的局部变量表的大小;

9. 机器指令数目(code_length):占有4个字节,值为0x0000 0005,表示后续的5 个字节 0x2A 、0xB7、 0x00、0x01、0xB1表示机器指令;

10. 机器指令集(code[code_length]):这里共有  5个字节,值为0x2A 、0xB7、 0x00、0x01、0xB1

机器指令集的解析

 JVM的机器指令中的操作码(Opcode)规定只用1 个字节表示,所以JVM的指令最多不超过256 个。

现在我们将上述的 5个字节按字节分析这些机器指令都是干什么用的:

第一个字节 0x2A,查询Java 虚拟机规范中关于操作码的解释,0x2A 对应的操作是”aload_0“,作用是将第一个引用类型局部变量推送至栈顶;

第二个字节 0xB7,0xB7 对应的操作是:”invokespecial“,作用是调用超类构造方法、实例初始化方法或私有方法;带有2个字节的参数,即后面的 0x00、0x01 是它的参数,这个参数是某个常量池中的索引,指向了常量池的第一项,该项表示一个方法引用项CONSTANT_Methodref_info结构体,表示java.lang.Object 类中的<init>()方法,即  java/lang/Object.”<init>”:()V。这条指令的意思就是调用父类Object的构造方法<init>()

接着第5个字符是0xB1 ,对应操作是:“Ireturn”,作用是表示无返回值的方法返回,结束方法调用,这条语句放在方法的机器码最后,表示方法结束调用,返回。

我们可以使用javap -v Simple > Simple.txt,查看反编译信息是怎样显示这一信息的:

注:关于Java机器指令的说明和解释,读者可以自行参考《Java Vritual Machine Specification –for Java  SE  7》《Java虚拟机规范–JavaSE 7》,在附录表中有详细的介绍。

JVM的机器指令集是很复杂的一部分,它的运行还涉及到它的体系结构的设计,在这里不好展开,我将在后续的章节中专门讨论JVM虚拟机的机器指令问题,敬请期待。

11. 显式异常表集合(exception_table_count): 占有2 个字节,值为0x0000,表示方法中没有需要处理的异常信息;

12. Code属性表的属性表集合(attribute_count): 占有2 个字节,值为0x0000,表示它没有其他的属性表集合,因为我们使用了-g:none 禁止编译器生成Code属性表 LineNumberTable 和LocalVariableTable;

B.  Simple.class 中的greeting() 方法:

解释:

1. 方法访问标志(access_flags): 占有 2个字节,值为 0x0039 ,即二进制的00000000 00111001,即标志位的第11、12、13、16位为1,根据上面讲的方法标志位的表示,可以得到该greeting()方法的修饰符有:ACC_SYNCHRONIZED、ACC_FINAL、ACC_STATIC、ACC_PUBLIC;

2. 名称索引(name_index): 占有 2 个字节,值为 0x0007,指向常量池的第 7 项,该项表示字符串“greeting”,即该方法的名称是“greeting”;

3. 描述符索引(descriptor_index): 占有 2 个字节,值为0x0005,指向常量池的第 5 项,该项表示字符串“()V”,即表示该方法不带参数,并且无返回值;

4. 属性计数器(attribute_count): 占有 2 个字节,值为0x0001,表示该方法表中含有一个属性表,后面会紧跟着一个属性表;

5.属性表的名称索引(attribute_name_index):占有 2 个字节,值为0x0006,指向常量池中的第6 项,该项表示字符串“Code”,表示这个属性表是Code类型的属性表;

6. 属性长度(attribute_length):占有4个字节,值为0x0000 0010,即十进制的16,表明后续的16个字节可以表示这个Code属性表的属性信息;

7. 操作数栈的最大深度(max_stack):占有2个字节,值为0x0001,表示栈帧中操作数栈的最大深度是1

8. 局部变量表的最大容量(max_variable):占有2个字节,值为0x0001, JVM在调用该方法时,根据这个值设置栈帧中的局部变量表的大小;

9. 机器指令数目(code_length):占有4 个字节,值为0x0000 0004,表示后续的4个字节0x10、 0x0A、 0x3B、0xB1的是表示机器指令;

10.机器指令集(code[code_length]):这里共有4 个字节,值为0x10、 0x0A、 0x3B、0xB1 ;

机器指令集的解析

第一个字节 0x10,查询Java虚拟机规范中关于操作码的解释,0x10 对应的操作是”bipush”,” 作用是将单字节的常量值(-128~127) 推送至栈顶,它要求一个参数,后面的 0x0A 即是需要推送到栈顶的单字节,注意这里的 0x0A 是16进制,就是我们在代码里写的”a=10″中的10。

第三个字节”3B”,“3B”对应的操作是:”istore_0″,作用是将栈顶int 型数值存入第一个局部变量。我们在greeting() 方法中就声明了一个局部变量a,JVM的运行的时候,将这个局部变量a解析,并放置到局部变量表中的第一个位置;上述的0x10 0x0A 指令已经将0x0A 推送到了栈顶了,然后 0x3B指令便将栈顶的0x0A 取出,赋值给局部变量表中的第一个参数,即局部变量a,

这样就完成了对局部变量a的赋值;

接着第4个字符是0xB1 ,对应操作是:“Ireturn”,作用是表示无返回值的方法返回,结束方法调用,这条语句放在方法的机器码最后,表示方法结束调用,返回。

我们可以使用javap -v Simple > Simple.txt,查看反编译信息是怎样显示这一信息的:

注:关于Java机器指令的说明和解释,读者可以自行参考《Java Vritual Machine Specification –for Java  SE  7》《Java虚拟机规范–javaSE 7》,在附录表中有详细的介绍。

JVM的机器指令集是很复杂的一部分,它的运行还涉及到它的体系结构的设计,在这里不好展开,我将在后续的章节中专门讨论JVM虚拟机的机器指令问题,敬请期待。

11. 显式异常表集合(exception_table_count): 占有2 个字节,值为0x0000,表示方法中没有需要处理的异常信息;

12. Code属性表的属性表集合(attribute_count): 占有2 个字节,值为0x0000,表示它没有其他的属性表集合,因为我们使用了-g:none 禁止编译器生成Code属性表 LineNumberTable 和LocalVariableTable;

6.2 Exceptions类型的属性表—-method方法声明的要抛出的异常信息

有些方法在定义的时候,会声明该方法会抛出什么类型的异常,如下定义一个Interface接口,它声明了sayHello()方法,抛出Exception异常:

  1. package com.louis.jvm;
  2. public interface Interface {
  3.     public  void sayHello() throws Exception;
  4. }

现在让我们看一下Exceptions类型的属性表(attribute_info)结构体是怎样组织的:

如上图所示,Exceptions类型的属性表(attribute_info)结构体由一下元素组成:

属性名称索引(attribute_name_index):占有 2个字节,其中的值指向了常量池中的表示”Exceptions“字符串的常量池项;

属性长度(attribute_length):它比较特殊,占有4个字节,它的值表示跟在其后面多少个字节表示异常信息;

异常数量(number_of_exceptions):占有2 个字节,它的值表示方法声明抛出了多少个异常,即表示跟在其后有多少个异常名称索引

异常名称索引(exceptions_index_table):占有2个字节,它的值指向了常量池中的某一项,该项是一个CONSTANT_Class_info类型的项,表示这个异常的完全限定名称;

Exceptions类型的属性表的长度计算

如果某个方法定义中,没有声明抛出异常,那么,表示该方法的方法表(method_info)结构体中的属性表集合中不会有Exceptions类型的属性表;换句话说,如果方法声明了要抛出的异常,方法表(method_info)结构体中的属性表集合中必然会有Exceptions类型的属性表,并且该属性表中的异常数量不小于1。

我们假设异常数量中的值为 N,那么后面的异常名称索引的数量就为N,它们总共占有的字节数为N*2,而异常数量占有2个字节,那么将有下面的这个关系式:

属性长度(attribute_length)中的值= 2  + 2*异常数量(number_of_exceptions)中的值

Exceptions类型的属性表(attribute_info)的长度=2+4+属性长度(attribute_length)中的值

举例:

将上面定义的Interface接口类编译成class文件,然后我们查看Interface.class文件,找出方法表集合所在位置和相应的数据,并辅助javap -v  Inerface 查看常量池信息,如下图所示:       

由于sayHello()方法是在的Interface接口类中声明的,它没有被实现,所以它对应的方法表(method_info)结构体中的属性表集合中没有Code类型的属性表

注:

1. 方法计数器(methods_count)中的值为0x0001,表明其后的方法表(method_info)就一个,即我们就定义了一个方法,其后会紧跟着一个方法表(method_info)结构体;

2. 方法的访问标志(access_flags)的值是0x0401,二进制是00000100 00000001,第6位和第16位是1,对应上面的标志位信息,可以得出它的访问标志符有:ACC_ABSTRACT、ACC_PUBLIC。细心的读者可能会发现,在上面声明的sayHello()方法中并没有声明为abstract类型啊。确实如此,这是因为编译器对于接口内声明的方法自动加上ACC_ABSTRACT标志

3. 名称索引(name_index)中的值为0x00050x0005指向了常量池的第5项,第五项表示的字符串为“sayHello”,即表示的方法名称是sayHello

4. 描述符索引(descriptor_index)中的值为0x0006,0x0006指向了常量池中的第6项,第6项表示的字符串为“()V” 表示这个方法的无入参,返回值为void类型

5. 属性表计数器(attribute_count)中的值为0x0001,表示后面的属性表的个数就1个,后面紧跟着一个attribute_info结构体;

6. 属性表(attribute_info)中的属性名称索引(attribute_name_index)中的值为0x00070x0007指向了常量池中的第7 项,第 7项指向字符串“Exceptions”,即表示该属性表表示的异常信息;

7. 属性长度(attribute_length)中的值为:0x00000004,即后续的4个字节将会被解析成属性值;

8. 异常数量(number_of_exceptions)中的值为0x0001,表示这个方法声明抛出的异常个数是1个;

9.异常名称索引(exception_index_table)中的值为0x0008,指向了常量池中的第8项,第8项表示的是CONSTANT_Class_info类型的常量池项,表示“java/lang/Exception”,即表示此方法抛出了java.lang.Exception异常。

 

7.  IDE代码提示功能实现的基本原理

现在对于企业级的开发,开发者们越来越依赖IDE如Intellij IDEA、Eclipse、MyEclipse、NetBeans等,利用他们提供的高级功能,可以极大地提高编码的速度和效率。

每个IDE都提供了代码提示功能,它们实现的基本原理其实就是IDE针对它们项目下的包中所有的class文件进行建模,解析出它们的方法信息,当我们一定的条件时,IDE会自动地将合适条件的方法列表展示给开发者,供开发者使用。

在上面将Code属性表的时候也讲了,如果编译的第三方包,没有LocalVariableTable属性表信息,IDE的提示信息会稍有不同:

8.  写在后面

以上就是Class文件的方法表集合的全部内容。

读者可能觉得本文关于方法表的Code属性表讨论的不够深入,在讨论Code属性表的时候,我简单介绍了它的两个属性表LineNumberTable 和LocalVariableTable这两个在有什么实际作用,但是没有详细第介绍它们,并且在列举的例子中,刻意地使用了  -g:none 选项 ,以使生成的class文件没有这两项信息,这么做是因为Code 属性太过复杂,而本文主要是想让读者了解的是 方法表集合,所以就生成了最精简的Code属性表,以减少读者的负担。

接下来的一篇文章,我打算专门来讨论Code属性表,揭开Code属性表的所有秘密,敬请关注~~

本文还引出了一个需要讨论的话题:就是Code属性表中的机器指令,机器指令的运行要依赖于JVM体系结构的设计机制,理解机器指令的运行机制,这将是根非常非常难啃的骨头惊恐…….

 

作者给读者的一些建议:

1. 由于class文件的信息繁杂,为了减少class文件的复杂程度,本文列举的例子都是针对特定情况精简的,尽量减少不必要的学习障碍,所以作者希望读者好好研究一下本文所列举的例子,读者最好自己动手,自己编译源代码,生成class文件,并查看class文件中的信息,然后逐字节分析,如果你真这么做了,你会发现,class文件的组织格式原来真的很简单..

2. 阅读了本文,并不能保证让你完全、系统地掌握class文件组织形式。如果你想全面系统地掌握它,你还需要阅读:

《Java Vritual Machine Specification _J2SE 7》(Java虚拟机规范 Java SE7 版)(可点击下载)

深入理解Java虚拟机:JVM高级特性与最佳实践》,周志明(可点击下载)

这两本书很系统地介绍了class文件的组织形式,如果你觉得这两本书中有的部分将的太抽象,不好理解,那么你再回头看看本文,本文能给你一个形象化和直观化的解释。

衷心希望《Java虚拟机原理图解》这个专栏能够帮助到广大的Java 程序员们!