Java语法糖以及实现
什么是语法糖?
不知道大家有没有经历过,刚学编程时,看到一些java代码会觉得非常别扭。例如编译器使用idea。再用匿名内部类创建一个线程时,程序是这样的。
过一阵子重新打开这个文件
嗯?这是个啥?于是去找大佬问。大佬:”这就是个语法糖”。”哦~”伴随着一声似懂非懂的声音,心里还在嘀咕着:”语法糖又是个啥?”。
可能看到这篇博客的各位都比我当时强,没遇到过我当时出现的问题。但是我还是讲讲我对语法糖的理解。
几乎所有的编程语言都会或多或少的提供一下语法糖来方便程序员开发代码。语法糖实际上就是前段编译的一些”小把戏”。虽然这些语法糖并不能带来代码实际上的执行效率优化。但是它们可以使我们的代码看起来更加简洁与优雅。熟练掌握了语法糖可以使我们提升开发效率,或提高程序的严谨性,或减少代码出错的机会。而现在有一种观点认为,语法糖也不是一定有益的。因为容易让程序员产生依赖,无法看清语法糖的糖衣背后,程序代码的真实面目。(这个时代语法糖必须得玩熟,例如常见的泛型,JDK底层源码使用的频率相当之高。不玩熟练对阅读源码就是一个很大的障碍)。
方法签名
方法签名是指:函数的名称,参数的个数,参数的类型和顺序,参数的修饰符。
签名不包括:返回类型,参数的名称如:
Long GetValue(int a,String b){......}。
Java泛型的发展史与弊端
泛型的本质是参数化类型,或者参数化多态的应用。即可以将操作的数据类型指定为方法签名中的一种特殊参数。这种参数类型能够用在类,接口和方法的创建中。分别构成泛型类,泛型接口和泛型方法。泛型让程序员能够针对泛化的数据类型编写相同的算法,这极大地增强了编程语言的类型系统以及抽象能力。
在2004年,Java和C#两门语言同年更新了一个重要大版本,即Java5.0和C#2.0。这个大版本中,两门语言又不约而同地各自添加了泛型的语法特性。不过两门语言对泛型的实现却截然不同。本来Java和C#天生就存在着比较和竞争,因此自然免不了被大家审视一番。其结论就是Java的泛型直到今天依然作为Java语言不如C#语言好用的”铁证”被众人嘲讽。本文不会去推翻这个结论,相反还回去举例来揭示Java泛型的缺陷所在。同时也要像不了解Java泛型机制的读者说清楚。Java选择这样的泛型实现,是出于当时语言现状的权衡,而不是C#语言先进,Java语言开发者水平不如C#开发者之类的。
Java与C#泛型
Java选择的实现方式是”类型擦除式泛型”,而C#选择的泛型实现方式是”具现化式泛型”。具现化,特化,偏特化最初都是源自C++模板语法中的概念。如果读者本身不使用C++的话,也不必太纠结,当成一个技术名词即可。
对于C#而言,List<string>和list<int>就是两个不同的类型。它们由系统在运行时期生成。各自有各自的虚方法表和类型数据。而Java语言中的泛型则不同。它只在程序源码(.java)中存在。当程序编译成字节码时,全部的字节码都会被替换成裸类型(Raw Type,稍后我们会将裸类型是什么),并且在相应的类型中插入了强转类型代码。因此对于Java而言,ArrayList<int>与ArrayList<string>在运行时数据区其实是同一个类型。由此可以猜测为什么Java的泛型使用的是”类型擦除”这个模式。
无需纠结概念,但却需要关注这两种实现方式会给使用者带来什么样的影响。Java的泛型确实会在实际使用中有部分限制。比如下面这个例子,C#开发人员就很难理解下面这种写法是不合法的。
如果说上面的写法Java不支持可以靠其他方式来弥补完成的话,那么性能上的差距则更是难以弥补的。在C#2.0引入泛型后,再使用平台提供的容器如(List<T>,Dictionary<TKey,TValue>)时,无需向Java一样频繁的装箱拆箱。Java要避免这种损失就必须构造一个与数据类型相关的容器类(譬如IntFloatHashMap这样的容器),显然这除了引入了更多代码,造成复杂度提高,复用性降低,更是丧失了泛型语法糖的核心意义。
Java的类型擦除式泛型无论在使用效果上运行效率上都全面落后于C#。而Java泛型唯一的优势就是擦除式泛型几乎只需要在Javac编译器上做出改进即可,不需要改的字节码,不需要改动java虚拟机,也保证了以前没有使用泛型的库可以直接运行在Java 5.0之上。但这种偷工减料的优势就显得非常微不足道。Java之所以这么做,是需要考虑当时的时代背景的。
泛型的历史背景
其实Java的的泛型一开始是移植Scala语言的前身语言Pizza语言的。移植的过程并不是一开始就朝着类型擦除式泛型去的。事实上Pizza语言中的泛型更接近现在C#的泛型。Java开发者当时收到了层层约束。最最难得是被破要向后兼容无泛型Java。因为在《Java语言规范》中对Java语言使用者严肃承诺。譬如在JDK1.2中编译出来的Class文件,必须保证在未来的JDK12也能运行。这就意味着以前没有的限制不能突然冒出来。
举例:在没有泛型的年代,Java数组是支持协变的,对应的集合类也可以存入不同类型的元素。类似于下面代码,尽管不提倡,但是可以正常编译成Class文件。
开发人员此时面临一个问题。如何在加入泛型后,这些程序依旧可以成功编译呢?大致有两条路。
- 需要泛型话化的类型(主要是容器类型)。以前的保持不变,然后平行的加一套泛型化版本的新类型(ArrayList,ArrayList<String>,ArrayList<Integer>各是各的类型)。
- 把已有的类型泛型化,即让所有需要泛型化的已有类型都原地泛型化,不添加其他泛型版本(万法合一啊,直接升级!)。
C#选择了第一条路,新增了容器类,原有容器类继续保留。
但Java可能就不同了。Java此时已经问世10年,C#也就刚出来2年。再加上流行程度不同,两者遗留代码的规格压根儿已经不在一个数量级上了。在JDK1.2时,Java规模尚小,Java就走的第一条路引用新的集合类。并保留了旧集合类不动。这导致了直到现在标准库中还有Vector(老),ArrayList(新)。Hashtable(老),HashMap(新)等两套容器的并存。如果仿照当时的做法弄出Vector<T>,ArrayList<T>这样的集合,可能骂的人会更多。
此时可能稍稍理解了一点为什么Java只能选第二条路了。那么我们来看看类型擦除式泛型的实现到底在哪里偷懒了呢?
类型擦除
我们以ArrayList为例来介绍Java泛型的类型擦除具体是如何实现的。要让以前所有需要泛型化的已有类型,譬如ArrayList原地泛型化为ArrayListM<T>,并且保证以前直接使用ArrayList的代码泛型版本里必须还能继续使用这个容器,就必须让所有泛型化的实际类型,譬如ArrayList<Integer>,ArrayList<String>都能自动转化为 ArrayList的子类型才可以。否则类型转换就是不安全的。由此就引入了”裸类型”(Raw Type)的概念。裸类型被视为所有该类型泛型实例的共同父类型(Super Type)。只有这样的赋值才是被系统允许的子类到父类的安全转型。
接下来的问题是该如何实现裸类型。这里有两种选择。一种是运行期由Java虚拟机来自动的,真实的构造出ArrayList<Integer>这样的类型,并且自动实现从ArrayList<Integer>派生自ArrayList的继承关系来满足裸类型的定义(听着很牛逼,可惜不是你)。另一种是索性简单粗暴的把ArrayList<Integer>还原回ArrayList。只在元素访问,修改时自动的插入一些强制类型转换和检查指令。我们来通过例子看一下Java开发人员当时的选择。
把上述代码用jd-gui工具进行反编译后(有点小翻车)
其实类型擦除的真正含义是图中标记处的泛型在javac编译后被擦除。但是由于类型擦除其实并不是彻底擦除。我们在元数据中依然保留了部分泛型的痕迹。因此推测反编译工具根据各种线索再次将泛型呈现了出来。但是对于赤裸裸的class文件,这个泛型应该是不存在的!本行代码在class文件中的意义应为:Map map = new HashMap();
此时你会发现,泛型都不见了(是真的不见了,反编译工具太强大了强行呈现)。程序又变回了最开始的写法,泛型类型都变为了裸类型。只是在元素插入的时候进行了强转。
类型擦除的弊端
语法不支持
那么我们知道了底层class文件中的泛型形同虚设之后,那么就很好理解最开始这个例子了。到class文件中泛型就消失了,更别提运行时数据区了。这样的泛型确实只能在前期编译,也就是javac编译时做处理了。
被迫装拆箱
这种情况下一旦把泛型擦除后,要强制转型代码的地方就没办法往下做了。因为基础类型与Object类型是没法强转的。既然这里不支持,那么Java依然简单粗暴。既然你需要有没办法用,那我就给你自动装,拆箱好了。这也导致后续无数构造包装类和装箱,拆箱的操作,严重影响效率。
泛型重载失败
这里因为擦除,意味着在class文件中参数都代表着List这个裸类型。
Java中的泛型
Java泛型的用法
泛型,即参数化类型。最熟悉的就是定义方法时有形参,然后调用此方法时传递实参。
引入一个变量T(可以是任意字母,但是常用的是T,E,K,V等等),并用<>括起来,并放在类型的后面。泛型类是允许有多个类型变量的。
按照约定,类型参数名称命名为单个大写字母,以便可以在使用普通类或借口时能够容易地区分类型参数。以下是常用的类型参数名称列表:
- E - 元素,主要有Java集合(Collection)框架使用。
- K - 键,主要用于表映射中的键的参数类型。
- V - 值,主要用于表映射中的值的参数类型。
- N - 数组,主要用于表示数字。
- T - 类型,主要用于表示第一类通用型参数。
- S - 类型,主要用于表示第二类通用型参数。
- U - 类型,主要用于表示第三类通用型参数。
- V - 类型,主要用于表示第四类通用型参数。
Java泛型类与泛型接口
可以为任何类,接口增加泛型声明。
泛型接口与类的定义基本相同
Java泛型类和泛型接口的使用
实现泛型接口的类有两种实现方式:
未传入泛型参数时
此时继承的类时需要继续携带这个模糊类型。在实例化这个类时需要指定实际类型。
传入泛型实参
在此处明确参数类型和,后续的继承类以及实例化阶段都和普通类没区别。
泛型方法
因此可以这么写
还可以这么写
泛型方法,是在调用方法的时候指明泛型的具体类型。泛型方法可以再任何地方,任何场景使用。包括普通类和泛型类。
为什么我们需要泛型?
不适用泛型,我们如果执行结构相同但参数可能不同的方法,我们就必须要不断的重载去实现。有了泛型就可以减少重复方法的创建,使代码更加简洁优雅,调用者也不需要注意记那么多方法,使用时想传什么类型传什么类型。
弱记忆
之前我们讲述了Java使用的类型参数完成的泛型。也就是.class文件中是不包括泛型的。但实际这么说也不除了擦除,其实还是保留了泛型信息(Signature 是其中最重要的一项属性,它的作用就是存储一个方法在字节码层面的特征签名,这个属性中保存的参数类型并不是原生类型,而是包括了参数化类型的信息)----弱记忆
另外,从 Signature 属性的出现我们还可以得出结论,擦除法所谓的擦除,仅仅是对方法的 Code 属性中的字节码进行擦除,实际上元数据(描述数据属性(property)的信息)中还是保留了泛型信息,这也是我们能通过反射手段取得参数化类型的根本依据。
自动装拆箱
就纯技术的角度而论,自动装箱,自动拆箱与遍历循环(for-each循环)这些语法糖,无论是实现复杂度上海市其中蕴含的思想上都不能和之前讲的泛型相提并论。两者设计的难度与深度都有较大差异。但自动装拆箱的使用场景相当之多,因此在此再提一下。
现有如下代码:
在通过反编译后
只此一小段代码就包含了泛型,自动装箱,自动拆箱,遍历循环与变长参数5种语法糖。具体用法已在图中详细标注。
即使这些语法糖看起来简单,但也不见得没有我们需要关注的地方。
再看下面的例子:
反编译后的结果:
此案例的陷阱为。
- 包装类型的”==”运算不遇到算数运算的情况下不会自动拆箱。
- 包装类型的equals不处理数据转型关系。
因此在实际开发中应尽量避免这种写法
Stream与Lambda表达式
什么是Stream?
Java8中,Collection新增了两个流方法。分别是Stream()和parallelStream()
Java8中添加了一个新的接口类Stream,相当于高级的Iteratpr。它可以通过Lambda表达式对集合进行大批量数据操作。或者各种非常便利,高效的聚合数据操作。
为什么要使用Stream?
Java8之前,我们通常是通过for循环或者Iterator迭代来重排序合并数据,又或者通过重新定义Collections.sorts的Comparator方法来实现。这两种方式对于大数据量系统来说,效率并不是很理想。
Stream的聚合操作与数据库SQL的聚合操作sorted,filter,map等类似。我们在应用层就可以高效地实现类似数据库SQL的聚合操作了。而在数据操作方面,Stream不仅可以通过串行的方式实现数据操作,还可以通过并行的方式处理大批量数据,提高数据的处理效率。
Stream使用入门
如果我们需要从一个list中根据某个条件筛选查询,不使用Stream的方式如下:
需要6行代码完成。
使用Stream改进后:
一行搞定。
Stream操作分类
官方将Stream中的操作分为两大类:终结操作和中间操作。
中间操作会返回一个新的流,一个流可以后面跟随零个或多个中间操作。其目的主要是打开流,做出某种程度的数据映射/过滤,然后返回一个新的流,交给下一个流操作使用。这类操作都是惰性化的(lazy),也就是说,仅仅调用到这个类,还没有开始流的遍历(其实就是给接下来的终结操作先加入条件)。而真正的流遍历是在终结操作开始的时候才真正开始执行。
中间操作又可以分为无状态(Stateless)与有状态(Stateful)操作。无状态是指元素的处理不受之前元素的影响。有状态是指该操作需要在之前的操作的基础上继续执行。
终结操作是指返回最终的结果。一个流只能有一个终结操作。当这个操作执行后,这个流就被用”光”了,无法再被操作。所以这必定是这个流的最后一个操作。终结操作才会开始进行流的遍历,并且生成结果。
终结操作又可以分为短路与非短路。
短路是指遇到某些符合条件的元素就可以得到最终结果。
非短路是指必须处理完所有元素才能得到最终结果。操作分类详情如下:
因为Stream操作类型非常多,总结一下常用的:
- mapToXXX():将流中的原本类型的元素挨个加工变为XXX类型元素。
- filter():对流中的元素进行遍历筛选,流下符合条件的数据组成新的流。
- limit():返回指定数量的流元素。返回的是Stream里前n个元素。
- skip():将指定数量的元素从流程排除,剩下的元素组成新的流并返回。
- sorted():将流中的元素按自然排序进行排序。
- distinct():将流中的元素去重后输出。
- map():将流中的元素进行再次加工形成一个新的流(常用的有整个流留的小写转大写)
- peek():与map类似,但与map的区别是它相当于在操作的时候生成一个新的流,并且该操作不会影响到原本流的执行结果。因此基本用于debug。
- collect():就整个流进行集合转换(转为list,set,map等)
Stream的底层实现
Stream操作叠加
一个Stream的各个操作是由处理管道组装。并统一完成数据处理的。
我们知道Stream有中间操作和终结操作,那么对于一个写好的Stream处理代码来说,中间操作是通过AbstractPipeline生成了一个中间操作Sink链表。当我们调用终结操作时,会生成一个最终的ReducingSink。通过这个ReducingSink触发之前的中间操作,从最后一个ReducingSink开始,递归产生一个Sink链。如下图所示:
Stream之peek和map的区别
刚开始使用Stream的时候,看定义没懂peek是什么意思。看代码感觉用法和map很像。那么二者之间的区别是什么呢?
现有如下代码:
可以看到我们map()在执行打印时编译会报错,这是为什么呢?
从peek方法中,我们看到形参是Consumer。Consumer是没有返回值的,它只是对Stream中的元素进行某些操作。但是操作之后并不会影响整个流的数据。因此后续打印返回的依旧是原来的元素。
可以看到map方法中,形参是Function。Function是又返回值的。所以经过map中间操作的流都会收到该操作影响。
而又由于它们各自的特性,打印操作这种无法返回值的就交给peek来处理。而大小写转换这种操作就交给map来处理。
因此,我们常常使用peek作为中间操作的”debug”。
Stream的其它案例
现有一个List
按性别分组
按身高过滤
按身高求和
按身高找最大最小值
Stream的性能
需求
我们写三个方法,寻找list的最小值。来对比他们的执行效率。
常规迭代
串行Stream
并行Stream
list中100个元素效率对比
解释原因
- 常规的迭代代码简单,越简单的代码执行效率越高。
- Stream串行迭代,使用了复杂设计,导致执行效率低。所以性能最低。
- Stream并行迭代,使用了Fork-Join线程池,所以效率比Stream串行高。但还是比常规迭代慢。
list一个亿元素(使用默认CPU核心数)
解释原因
- Stream 并行迭代 使用了 Fork-Join 线程池, 而线程池线程数为 cpu 的核心数(我的电脑为 12 核),大数据场景下,能够利用多线程机制,所以效率比 Stream 串行迭代快,同时多线程机制切换带来的开销相对来说还不算多,所以对比常规迭代还是要快(虽然设计和代码复杂)
- 常规迭代代码简单,越简单的代码执行效率越高。
- Stream 串行迭代,使用了复杂的设计,导致执行速度偏低。所以是性能最低的。
list一个亿元素(使用默认CPU=2)
解释原因
Stream 并行迭代 使用了 Fork-Join 线程池,大数据场景下,虽然利用多线程机制,但是线程池线程数为 2,我们的Forkjoin体现的分而治之的思想,将任务划分为多份。如果线程数只有2个,任务数大于CPU核心数,就会发生任务对CPU资源的争夺。所以对比常规迭代还是要慢(虽然用到了多线程技术)
list一个亿元素(使用默认CPU=240)
解释原因
Stream 并行迭代 使用了 Fork-Join 线程池, 而线程池线程数为 240,大数据场景下,虽然利用多线程机制,但是线程太多,线程的上下文切换成本过高,所以导致了执行效率反而没有常规迭代快。
如何合理使用 Stream?
我们可以看到:在循环迭代次数较少的情况下,常规的迭代方式性能反而更好;而在大数据循环迭代中, parallelStream(合理的线程池数上)有一定的优势。
但是由于所有使用并行流 parallelStream 的地方都是使用同一个 Fork-Join 线程池(当并行的Stream操作变多时,这个设置很难控制),而线程池线程数仅为 cpu 的核心数。切记,如果对底层不太熟悉的话请不要乱用并行流 parallerStream(尤其是你的服务器核心数比较少的情况下)。
另外如果对线程池CPU核心数配置感兴趣的朋友,可以了解一下CPU密集型数据以及IO密集型数据下线程池的创建,本文不再多提,以后会详细讲解。