Java基础篇
Java快速入门
java程序基础
JVM、JDK和JRE
- JVM:Java Virtual Machine
- JDK:Java Development Kit
- JRE:Java Runtime Environment
Java虚拟机(JVM)是运行 Java 字节码的虚拟机。JVM有针对不同系统的特定实现(Windows,Linux,macOS),目的是使用相同的字节码,它们都会给出相同的结果。
JDK是Java Development Kit,它是功能齐全的Java SDK。它拥有JRE所拥有的一切,还有编译器(javac)和工具(如javadoc和jdb)。它能够创建和编译程序。
JRE 是 Java运行时环境。它是运行已编译 Java 程序所需的所有内容的集合,包括 Java虚拟机(JVM),Java类库,java命令和其他的一些基础构件。但是,它不能用于创建新程序。
简单地说,JRE就是运行Java字节码的虚拟机。但是,如果只有Java源码,要编译成Java字节码,就需要JDK,因为JDK除了包含JRE,还提供了编译器、调试器等开发工具。
我们需要格外注意的是 .class->机器码 这一步。在这一步 JVM 类加载器首先加载字节码文件,然后通过解释器逐行解释执行,这种方式的执行速度会相对比较慢。而且,有些方法和代码块是经常需要被调用的(也就是所谓的热点代码),所以后面引进了 JIT 编译器,而JIT 属于运行时编译。当 JIT 编译器完成第一次编译后,其会将字节码对应的机器码保存下来,下次可以直接使用。而我们知道,机器码的运行效率肯定是高于 Java 解释器的。这也解释了我们为什么经常会说 Java 是编译与解释共存的语言。
基本数据类型
- 整数类型:byte,short,int,long
- 浮点数类型:float,double
- 字符类型:char
- 布尔类型:boolean
计算机内存的最小存储单元是字节(byte),一个字节就是一个8位二进制数,即8个bit。它的二进制表示范围从00000000255,换算成十六进制是11111111,换算成十进制是000~`ff`。
Java基本数据类型占用的字节数:
Boolean 占1个字节
1 | ┌───┐ |
常量
定义变量的时候,如果加上final修饰符,这个变量就变成了常量:
1 | final double PI = 3.14; // PI是一个常量 |
常量在定义时进行初始化后就不可再次赋值,再次赋值会导致编译错误。根据习惯,常量名通常全部大写。
var关键字
如果想省略变量类型,可以使用var关键字:
1 | var sb = new StringBuilder(); |
编译器会根据赋值语句自动推断出变量sb的类型是StringBuilder。对编译器来说,语句:
1 | var sb = new StringBuilder(); |
实际上会自动变成:
1 | StringBuilder sb = new StringBuilder(); |
因此,使用var定义变量,仅仅是少写了变量类型而已。
位运算
位运算是按位进行与、或、非和异或的运算。
与运算的规则是,必须两个数同时为1,结果才为1。
或运算的规则是,只要任意一个为1,结果就为1。
非运算的规则是,0和1互换。
异或运算的规则是,如果两个数不同,结果为1,否则为0。
运算优先级
在Java的计算表达式中,运算优先级从高到低依次是:
()!~++--*/%+-<<>>>>>&|+=-=*=/=
浮点数运算
浮点数运算和整数运算相比,只能进行加减乘除这些数值计算,不能做位运算和移位运算。
由于浮点数存在运算误差,所以比较两个浮点数是否相等常常会出现错误的结果。正确的比较方法是判断两个浮点数之差的绝对值是否小于一个很小的数:
1 | // 比较x和y是否相等,先计算其差的绝对值: |
整数运算在除数为0时会报错,而浮点数运算在除数为0时,不会报错,但会返回几个特殊值:
NaN表示Not a NumberInfinity表示无穷大-Infinity表示负无穷大
可以将浮点数强制转型为整数。在转型时,浮点数的小数部分会被丢掉。如果转型后超过了整型能表示的最大范围,将返回整型的最大值。
数组操作
数组
数组所有元素初始化为默认值,整型都是0,浮点型是0.0,布尔型是false;
数组一旦创建后,大小就不可改变。
面向对象编程
面向对象基础
可变参数
可变参数用类型...定义,可变参数相当于数组类型:
1 | class Group { |
1 | Group g = new Group(); |
方法重载
方法名相同,但各自的参数不同,称为方法重载(Overload)。
注意:方法重载的返回值类型通常都是相同的。
继承
super
super关键字表示父类(超类)。子类引用父类的字段时,可以用super.fieldName。例如:
1 | class Person { |
向上转型
如果一个引用变量的类型是Student,那么它可以指向一个Student类型的实例:
1 | Student s = new Student(); |
如果一个引用类型的变量是Person,那么它可以指向一个Person类型的实例:
1 | Person p = new Person(); |
现在问题来了:如果Student是从Person继承下来的,那么,一个引用类型为Person的变量,能否指向Student类型的实例?
1 | Person p = new Student(); // ??? |
测试一下就可以发现,这种指向是允许的!
这是因为Student继承自Person,因此,它拥有Person的全部功能。Person类型的变量,如果指向Student类型的实例,对它进行操作,是没有问题的!
这种把一个子类类型安全地变为父类类型的赋值,被称为向上转型(upcasting)。
向上转型实际上是把一个子类型安全地变为更加抽象的父类型:
1 | Student s = new Student(); |
注意到继承树是Student > Person > Object,所以,可以把Student类型转型为Person,或者更高层次的Object。
向下转型
和向上转型相反,如果把一个父类类型强制转型为子类类型,就是向下转型(downcasting)。例如:
1 | Person p1 = new Student(); // upcasting, ok |
如果测试上面的代码,可以发现:
Person类型p1实际指向Student实例,Person类型变量p2实际指向Person实例。在向下转型的时候,把p1转型为Student会成功,因为p1确实指向Student实例,把p2转型为Student会失败,因为p2的实际类型是Person,不能把父类变为子类,因为子类功能比父类多,多的功能无法凭空变出来。
向下转型时最好先用instanceof判断一下,避免转型失败。
多态
在继承关系中,子类如果定义了一个与父类方法签名完全相同的方法,被称为覆写(Override)。
Override和Overload不同的是,如果方法签名如果不同,就是Overload,Overload方法是一个新方法;如果方法签名相同,并且返回值也相同,就是Override。
多态是指,针对某个类型的方法调用,其真正执行的方法取决于运行时期实际类型的方法。例如:
1 | Person p = new Student(); |
有童鞋会问,从上面的代码一看就明白,肯定调用的是Student的run()方法啊。
但是,假设我们编写这样一个方法:
1 | public void runTwice(Person p) { |
它传入的参数类型是Person,我们是无法知道传入的参数实际类型究竟是Person,还是Student,还是Person的其他子类,因此,也无法确定调用的是不是Person类定义的run()方法。
多态具有一个非常强大的功能,就是允许添加更多类型的子类实现功能扩展,却不需要修改基于父类的代码。
final
继承可以允许子类覆写父类的方法。如果一个父类不允许子类对它的某个方法进行覆写,可以把该方法标记为final。用final修饰的方法不能被Override。
如果一个类不希望任何其他类继承自它,那么可以把这个类本身标记为final。用final修饰的类不能被继承。
对于一个类的实例字段,同样可以用final修饰。用final修饰的字段在初始化后不能被修改。
transient
我们都知道一个对象只要实现了Serilizable接口,这个对象就可以被序列化,java的这种序列化模式为开发者提供了很多便利,我们可以不必关系具体序列化的过程,只要这个类实现了Serilizable接口,这个类的所有属性和方法都会自动序列化。
然而在实际开发过程中,我们常常会遇到这样的问题,这个类的有些属性需要序列化,而其他属性不需要被序列化,打个比方,如果一个用户有一些敏感信息(如密码,银行卡号等),为了安全起见,不希望在网络操作(主要涉及到序列化操作,本地序列化缓存也适用)中被传输,这些信息对应的变量就可以加上transient关键字。换句话说,这个字段的生命周期仅存于调用者的内存中而不会写到磁盘里持久化。
一旦变量被transient修饰,变量将不再是对象持久化的一部分,该变量内容在序列化后无法获得访问。
transient关键字只能修饰变量,而不能修饰方法和类。注意,本地变量是不能被transient关键字修饰的。变量如果是用户自定义类变量,则该类需要实现Serializable接口。
被transient关键字修饰的变量不再能被序列化,一个静态变量不管是否被transient修饰,均不能被序列化。
抽象类
如果父类的方法本身不需要实现任何功能,仅仅是为了定义方法签名,目的是让子类去覆写它,那么,可以把父类的方法声明为抽象方法:
1 | class Person { |
把一个方法声明为abstract,表示它是一个抽象方法,本身没有实现任何方法语句。因为这个抽象方法本身是无法执行的,所以,Person类也无法被实例化。编译器会告诉我们,无法编译Person类,因为它包含抽象方法。
必须把Person类本身也声明为abstract,才能正确编译它:
1 | abstract class Person { |
使用abstract修饰的类就是抽象类。我们无法实例化一个抽象类。
无法实例化的抽象类有什么用?
因为抽象类本身被设计成只能用于被继承,因此,抽象类可以强迫子类实现其定义的抽象方法,否则编译会报错。因此,抽象方法实际上相当于定义了“规范”。
面向抽象编程
当我们定义了抽象类Person,以及具体的Student、Teacher子类的时候,我们可以通过抽象类Person类型去引用具体的子类的实例:
1 | Person s = new Student(); |
这种引用抽象类的好处在于,我们对其进行方法调用,并不关心Person类型变量的具体子类型:
1 | // 不关心Person变量的具体子类型: |
同样的代码,如果引用的是一个新的子类,我们仍然不关心具体类型:
1 | // 同样不关心新的子类是如何实现run()方法的: |
这种尽量引用高层类型,避免引用实际子类型的方式,称之为面向抽象编程。
面向抽象编程的本质就是:
- 上层代码只定义规范(例如:
abstract class Person); - 不需要子类就可以实现业务逻辑(正常编译);
- 具体的业务逻辑由不同的子类实现,调用者并不关心。
接口
在抽象类中,抽象方法本质上是定义接口规范:即规定高层类的接口,从而保证所有子类都有相同的接口实现,这样,多态就能发挥出威力。
如果一个抽象类没有字段,所有方法全部都是抽象方法:
1 | abstract class Person { |
就可以把该抽象类改写为接口:interface。
在Java中,使用interface可以声明一个接口:
1 | interface Person { |
所谓interface,就是比抽象类还要抽象的纯抽象接口,因为它连字段都不能有。因为接口定义的所有方法默认都是public abstract的,所以这两个修饰符不需要写出来(写不写效果都一样)。
当一个具体的class去实现一个interface时,需要使用implements关键字。
我们知道,在Java中,一个类只能继承自另一个类,不能从多个类继承。但是,一个类可以实现多个interface。
抽象类和接口的对比如下:
| abstract class | interface | |
|---|---|---|
| 继承 | 只能extends一个class | 可以implements多个interface |
| 字段 | 可以定义实例字段 | 不能定义实例字段 |
| 抽象方法 | 可以定义抽象方法 | 可以定义抽象方法 |
| 非抽象方法 | 可以定义非抽象方法 | 可以定义default方法 |
接口继承
一个interface可以继承自另一个interface。interface继承自interface使用extends,它相当于扩展了接口的方法。例如:
1 | interface Hello { |
此时,Person接口继承自Hello接口,因此,Person接口现在实际上有3个抽象方法签名,其中一个来自继承的Hello接口。
继承关系
合理设计interface和abstract class的继承关系,可以充分复用代码。一般来说,公共逻辑适合放在abstract class中,具体逻辑放到各个子类,而接口层次代表抽象程度。可以参考Java的集合类定义的一组接口、抽象类以及具体子类的继承关系:
1 | ┌───────────────┐ |
在使用的时候,实例化的对象永远只能是某个具体的子类,但总是通过接口去引用它,因为接口比抽象类更抽象:
1 | List list = new ArrayList(); // 用List接口引用具体子类的实例 |
default方法
接口可以定义default方法(JDK>=1.8)。
在接口中,可以定义default方法。例如,把Person接口的run()方法改为default方法:
1 | public class Main { |
实现类可以不必覆写default方法。default方法的目的是,当我们需要给接口新增一个方法时,会涉及到修改全部子类。如果新增的是default方法,那么子类就不必全部修改,只需要在需要覆写的地方去覆写新增方法。
default方法和抽象类的普通方法是有所不同的。因为interface没有字段,default方法无法访问字段,而抽象类的普通方法可以访问实例字段。
静态字段和静态方法
静态字段
在一个class中定义的字段,我们称之为实例字段。实例字段的特点是,每个实例都有独立的字段,各个实例的同名字段互不影响。
还有一种字段,是用static修饰的字段,称为静态字段:static field。
静态字段只有一个共享“空间”,所有实例都会共享该字段。
虽然实例可以访问静态字段,但是它们指向的其实都是Person class的静态字段。所以,所有实例共享一个静态字段。
因此,不推荐用实例变量.静态字段去访问静态字段,因为在Java程序中,实例对象并没有静态字段。在代码中,实例对象能访问静态字段只是因为编译器可以根据实例类型自动转换为类名.静态字段来访问静态对象。
推荐用类名来访问静态字段。可以把静态字段理解为描述class本身的字段(非实例字段)。
静态方法
用static修饰的方法称为静态方法。
因为静态方法属于class而不属于实例,因此,静态方法内部,无法访问this变量,也无法访问实例字段,它只能访问静态字段。
静态方法经常用于工具类。例如:
- Arrays.sort()
- Math.random()
接口的静态字段
因为interface是一个纯抽象类,所以它不能定义实例字段。但是,interface是可以有静态字段的,并且静态字段必须为final类型:
1 | public interface Person { |
实际上,因为interface的字段只能是public static final类型,所以我们可以把这些修饰符都去掉,上述代码可以简写为:
1 | public interface Person { |
编译器会自动把该字段变为public static final类型。
包
包的定义
在现实中,如果小明写了一个Person类,小红也写了一个Person类,现在,小白既想用小明的Person,也想用小红的Person,怎么办?
在Java中,我们使用package来解决名字冲突。
Java定义了一种名字空间,称之为包:package。一个类总是属于某个包,类名(比如Person)只是一个简写,真正的完整类名是包名.类名。
包没有父子关系。java.util和java.util.zip是不同的包,两者没有任何继承关系。
包作用域
位于同一个包的类,可以访问包作用域的字段和方法。不用public、protected、private修饰的字段和方法就是包作用域。例如,Person类定义在hello包下面:
1 | package hello; |
Main类也定义在hello包下面:
1 | package hello; |
还有一种import static的语法,它可以导入可以导入一个类的静态字段和静态方法:
1 | package main; |
Java编译器最终编译出的.class文件只使用完整类名,因此,在代码中,当编译器遇到一个class名称时:
- 如果是完整类名,就直接根据完整类名查找这个
class; - 如果是简单类名,按下面的顺序依次查找:
- 查找当前
package是否存在这个class; - 查找
import的包是否包含这个class; - 查找
java.lang包是否包含这个class。
- 查找当前
编写class的时候,编译器会自动帮我们做两个import动作:
- 默认自动
import当前package的其他class; - 默认自动
import java.lang.*。
*在写import的时候,可以使用*,表示把这个包下面的所有class都导入进来(但不包括子包的class)。
作用域
public、protected、private这些修饰符可以用来限定访问作用域。
public
定义为public的class、interface可以被其他任何类访问。
定义为public的field、method可以被其他类访问,前提是首先有访问class的权限。
private
定义为private的field、method无法被其他类访问。
实际上,确切地说,private访问权限被限定在class的内部,而且与方法声明顺序无关。
定义在一个class内部的class称为嵌套类(nested class),Java支持好几种嵌套类。
由于Java支持嵌套类,如果一个类内部还定义了嵌套类,那么,嵌套类拥有访问该类private的权限。
protected
protected作用于继承关系。定义为protected的字段和方法可以被子类访问,以及子类的子类。
package
包作用域是指一个类允许访问同一个package的没有public、private修饰的class,以及没有public、protected、private修饰的字段和方法。
只要在同一个包,就可以访问package权限的class、field和method。
注意,包名必须完全一致,包没有父子关系,com.apache和com.apache.abc是不同的包。
局部变量
在方法内部定义的变量称为局部变量,局部变量作用域从变量声明处开始到对应的块结束。方法参数也是局部变量。
使用局部变量时,应该尽可能把局部变量的作用域缩小,尽可能延后声明局部变量。
final
Java还提供了一个final修饰符。final与访问权限不冲突,它有很多作用。
- 用
final修饰class可以阻止被继承; - 用
final修饰method可以阻止被子类覆写; - 用
final修饰field可以阻止被重新赋值; - 用
final修饰局部变量可以阻止被重新赋值。
最佳实践
如果不确定是否需要public,就不声明为public,即尽可能少地暴露对外的字段和方法。
把方法定义为package权限有助于测试,因为测试类和被测试类只要位于同一个package,测试代码就可以访问被测试类的package权限方法。
一个.java文件只能包含一个public类,但可以包含多个非public类。如果有public类,文件名必须和public类的名字相同。
classpath和jar
classpath
classpath是JVM用到的一个环境变量,它用来指示JVM如何搜索class。
因为Java是编译型语言,源码文件是.java,而编译后的.class文件才是真正可以被JVM执行的字节码。因此,JVM需要知道,如果要加载一个abc.xyz.Hello的类,应该去哪搜索对应的Hello.class文件。
jar包
如果有很多.class文件,散落在各层目录中,肯定不便于管理。如果能把目录打一个包,变成一个文件,就方便多了。
jar包就是用来干这个事的,它可以把package组织的目录层级,以及各个目录下的所有文件(包括.class文件和其他文件)都打成一个jar文件,这样一来,无论是备份,还是发给客户,就简单多了。
jar包实际上就是一个zip格式的压缩文件,而jar包相当于目录。如果我们要执行一个jar包的class,就可以把jar包放到classpath。
模块
从Java 9开始,JDK又引入了模块(Module)。
Jar只是用于存放class的容器,它并不关心class之间的依赖。
从Java 9开始引入的模块,主要是为了解决“依赖”这个问题。如果a.jar必须依赖另一个b.jar才能运行,那我们应该给a.jar加点说明啥的,让程序在编译和运行的时候能自动定位到b.jar,这种自带“依赖关系”的class容器就是模块。
打包jre
过去发布一个Java应用程序,要运行它,必须下载一个完整的JRE,再运行jar包。而完整的JRE块头很大,有100多M。怎么给JRE瘦身呢?
现在,JRE自身的标准库已经分拆成了模块,只需要带上程序用到的模块,其他的模块就可以被裁剪掉。
访问权限
前面我们讲过,Java的class访问权限分为public、protected、private和默认的包访问权限。引入模块后,这些访问权限的规则就要稍微做些调整。
确切地说,class的这些访问权限只在一个模块内有效,模块和模块之间,例如,a模块要访问b模块的某个class,必要条件是b模块明确地导出了可以访问的包。
举个例子:我们编写的模块hello.world用到了模块java.xml的一个类javax.xml.XMLConstants,我们之所以能直接使用这个类,是因为模块java.xml的module-info.java中声明了若干导出:
1 | module java.xml { |
只有它声明的导出的包,外部代码才被允许访问。因此,模块进一步隔离了代码的访问权限。
Java核心类
字符串和编码
String
在Java中,String是一个引用类型,它本身也是一个class。
实际上字符串在String内部是通过一个char[]数组表示的,因此,按下面的写法也是可以的:
1 | String s2 = new String(new char[] {'H', 'e', 'l', 'l', 'o', '!'}); |
Java字符串的一个重要特点就是字符串不可变。这种不可变性是通过内部的private final char[]字段,以及没有任何修改char[]的方法实现的。
字符串比较
当我们想要比较两个字符串是否相同时,要特别注意,我们实际上是想比较字符串的内容是否相同。必须使用equals()方法而不能用==。
1 | public class Main { |
从表面上看,两个字符串用==和equals()比较都为true,但实际上那只是Java编译器在编译期,会自动把所有相同的字符串当作一个对象放入常量池,自然s1和s2的引用就是相同的。
两个字符串比较,必须总是使用equals()方法。
要忽略大小写比较,使用equalsIgnoreCase()方法。
String类常用的方法:
1 | // 是否包含子串: |
使用trim()方法可以移除字符串首尾空白字符。空白字符包括空格,\t,\r,\n:
1 | " \tHello\r\n ".trim(); // "Hello" |
注意:trim()并没有改变字符串的内容,而是返回了一个新字符串。
另一个strip()方法也可以移除字符串首尾空白字符。它和trim()不同的是,类似中文的空格字符\u3000也会被移除:
1 | "\u3000Hello\u3000".strip(); // "Hello" |
String还提供了isEmpty()和isBlank()来判断字符串是否为空和空白字符串:
1 | "".isEmpty(); // true,因为字符串长度为0 |
字符编码
在早期的计算机系统中,为了给字符编码,美国国家标准学会(American National Standard Institute:ANSI)制定了一套英文字母、数字和常用符号的编码,它占用一个字节,编码范围从0到127,最高位始终为0,称为ASCII编码。
为了统一全球所有语言的编码,全球统一码联盟发布了Unicode编码,它把世界上主要语言都纳入同一个编码,这样,中文、日文、韩文和其他语言就不会冲突。
那我们经常使用的UTF-8又是什么编码呢?因为英文字符的Unicode编码高字节总是00,包含大量英文的文本会浪费空间,所以,出现了UTF-8编码,它是一种变长编码,用来把固定长度的Unicode编码变成1~4字节的变长编码。
UTF-8编码的另一个好处是容错能力强。如果传输过程中某些字符出错,不会影响后续字符,因为UTF-8编码依靠高字节位来确定一个字符究竟是几个字节,它经常用来作为传输编码。
在Java中,char类型实际上就是两个字节的Unicode编码。
Java的String和char在内存中总是以Unicode编码表示。
延伸阅读
对于不同版本的JDK,String类在内存中有不同的优化方式。具体来说,早期JDK版本的String总是以char[]存储,它的定义如下:
1 | public final class String { |
而较新的JDK版本的String则以byte[]存储:如果String仅包含ASCII字符,则每个byte存储一个字符,否则,每两个byte存储一个字符,这样做的目的是为了节省内存,因为大量的长度较短的String通常仅包含ASCII字符:
1 | public final class String { |
对于使用者来说,String内部的优化不影响任何已有代码,因为它的public方法签名是不变的。
StringBuilder
Java编译器对String做了特殊处理,使得我们可以直接用+拼接字符串。
虽然可以直接拼接字符串,但是,在循环中,每次循环都会创建新的字符串对象,然后扔掉旧的字符串。这样,绝大部分字符串都是临时对象,不但浪费内存,还会影响GC效率。
为了能高效拼接字符串,Java标准库提供了StringBuilder,它是一个可变对象,可以预分配缓冲区,这样,往StringBuilder中新增字符时,不会创建新的临时对象。
你可能还听说过StringBuffer,这是Java早期的一个StringBuilder的线程安全版本,它通过同步来保证多个线程操作StringBuffer也是安全的,但是同步会带来执行速度的下降。
StringBuilder和StringBuffer接口完全相同,现在完全没有必要使用StringBuffer。
StringJoiner
要高效拼接字符串,应该使用StringBuilder。
1 | import java.util.StringJoiner; |
那么StringJoiner内部是如何拼接字符串的呢?如果查看源码,可以发现,StringJoiner内部实际上就是使用了StringBuilder,所以拼接效率和StringBuilder几乎是一模一样的。
String.join()
String还提供了一个静态方法join(),这个方法在内部使用了StringJoiner来拼接字符串,在不需要指定“开头”和“结尾”的时候,用String.join()更方便:
1 | String[] names = {"Bob", "Alice", "Grace"}; |
包装类型
我们已经知道,Java的数据类型分两种:
- 基本类型:
byte,short,int,long,boolean,float,double,char - 引用类型:所有
class和interface类型
注:String就是一个class。
那么,如何把一个基本类型视为对象(引用类型)?
比如,想要把int基本类型变成一个引用类型,我们可以定义一个Integer类,它只包含一个实例字段int,这样,Integer类就可以视为int的包装类(Wrapper Class):
1 | public class Integer { |
实际上,因为包装类型非常有用,Java核心库为每种基本类型都提供了对应的包装类型:
| 基本类型 | 对应的引用类型 |
|---|---|
| boolean | java.lang.Boolean |
| byte | java.lang.Byte |
| short | java.lang.Short |
| int | java.lang.Integer |
| long | java.lang.Long |
| float | java.lang.Float |
| double | java.lang.Double |
| char | java.lang.Character |
我们可以直接使用这些基本类型的包装类。
自动装箱和自动拆箱
1 | Integer n = 100; // 编译器自动使用Integer.valueOf(int) |
这种直接把int变为Integer的赋值写法,称为自动装箱(Auto Boxing),反过来,把Integer变为int的赋值写法,称为自动拆箱(Auto Unboxing)。
注意:自动装箱和自动拆箱只发生在编译阶段,目的是为了少写代码。
装箱和拆箱会影响代码的执行效率,因为编译后的class代码是严格区分基本类型和引用类型的。并且,自动拆箱执行时可能会报NullPointerException。
不变类
所有的包装类型都是不变类。我们查看Integer的源码可知,它的核心代码如下:
1 | public final class Integer { |
因此,一旦创建了Integer对象,该对象就是不变的。
对两个Integer实例进行比较要特别注意:绝对不能用==比较,因为Integer是引用类型,必须使用equals()比较。
因为Integer.valueOf()可能始终返回同一个Integer实例,因此,在我们自己创建Integer的时候,以下两种方法:
- 方法1:
Integer n = new Integer(100); - 方法2:
Integer n = Integer.valueOf(100);
方法2更好,因为方法1总是创建新的Integer实例,方法2把内部优化留给Integer的实现者去做,即使在当前版本没有优化,也有可能在下一个版本进行优化。
我们把能创建“新”对象的静态方法称为静态工厂方法。Integer.valueOf()就是静态工厂方法,它尽可能地返回缓存的实例以节省内存。
进制转换
Integer类本身还提供了大量方法,例如,最常用的静态方法parseInt()可以把字符串解析成一个整数:
1 | int x1 = Integer.parseInt("100"); // 100 |
Java的包装类型还定义了一些有用的静态变量
1 | // boolean只有两个值true/false,其包装类型只需要引用Boolean提供的静态字段: |
JavaBean
如果读写方法符合以下这种命名规范:
1 | // 读方法: |
那么这种class被称为JavaBean。
JavaBean的作用
JavaBean主要用来传递数据,即把一组数据组合成一个JavaBean便于传输。此外,JavaBean可以方便地被IDE工具分析,生成读写属性的代码,主要用在图形界面的可视化设计中。
枚举JavaBean属性
要枚举一个JavaBean的所有属性,可以直接使用Java核心库提供的Introspector。
1 | import java.beans.*; |
枚举类
1 | enum Weekday { |
注意到定义枚举类是通过关键字enum实现的,我们只需依次列出枚举的常量名。
首先,enum常量本身带有类型信息,即Weekday.SUN类型是Weekday,编译器会自动检查出类型错误。
其次,不可能引用到非枚举的值,因为无法通过编译。
引用类型比较,要始终使用equals()方法,但enum类型可以例外。
这是因为enum类型的每个常量在JVM中只有一个唯一实例,所以可以直接用==比较。
通过enum定义的枚举类,和其他的class有什么区别?
答案是没有任何区别。enum定义的类型就是class,只不过它有以下几个特点:
- 定义的
enum类型总是继承自java.lang.Enum,且无法被继承; - 只能定义出
enum的实例,而无法通过new操作符创建enum的实例; - 定义的每个实例都是引用类型的唯一实例;
- 可以将
enum类型用于switch语句。
通过name()获取常量定义的字符串,注意不要使用toString();
通过ordinal()返回常量定义的顺序(无实质意义);
可以为enum编写构造方法、字段和方法
enum的构造方法要声明为private,字段强烈建议声明为final;
enum适合用在switch语句中。
BigInteger
在Java中,由CPU原生提供的整型最大范围是64位long型整数。使用long型整数可以直接通过CPU指令进行计算,速度非常快。
如果我们使用的整数范围超过了long型怎么办?java.math.BigInteger就是用来表示任意大小的整数。BigInteger内部用一个int[]数组来模拟一个非常大的整数。
对BigInteger做运算的时候,只能使用实例方法,例如,加法运算:
1 | BigInteger i1 = new BigInteger("1234567890"); |
BigInteger和Integer、Long一样,也是不可变类,并且也继承自Number类。因为Number定义了转换为基本类型的几个方法:
- 转换为
byte:byteValue() - 转换为
short:shortValue() - 转换为
int:intValue() - 转换为
long:longValue() - 转换为
float:floatValue() - 转换为
double:doubleValue()
BigDecimal
和BigInteger类似,BigDecimal可以表示一个任意大小且精度完全准确的浮点数。
1 | BigDecimal bd = new BigDecimal("123.4567"); |
BigDecimal用scale()表示小数位数,例如:
1 | BigDecimal d1 = new BigDecimal("123.45"); |
通过BigDecimal的stripTrailingZeros()方法,可以将一个BigDecimal格式化为一个相等的,但去掉了末尾0的BigDecimal。
如果一个BigDecimal的scale()返回负数,例如,-2,表示这个数是个整数,并且末尾有2个0。
可以对一个BigDecimal设置它的scale,如果精度比原始值低,那么按照指定的方法进行四舍五入或者直接截断:
1 | import java.math.BigDecimal; |
对BigDecimal做加、减、乘时,精度不会丢失,但是做除法时,存在无法除尽的情况,这时,就必须指定精度以及如何进行截断。
还可以对BigDecimal做除法的同时求余数。
比较BigDecimal
在比较两个BigDecimal的值是否相等时,要特别注意,使用equals()方法不但要求两个BigDecimal的值相等,还要求它们的scale()相等。
必须使用compareTo()方法来比较,它根据两个值的大小分别返回负数、正数和0,分别表示小于、大于和等于。
如果查看BigDecimal的源码,可以发现,实际上一个BigDecimal是通过一个BigInteger和一个scale来表示的,即BigInteger表示一个完整的整数,而scale表示小数位数。
BigDecimal也是从Number继承的,也是不可变对象。
Object类
object类中包含的方法:
1 | public final native Class<?> getClass(); |
int和Integer的区别
1、Integer是int的包装类,int则是java的一种基本数据类型
2、Integer变量必须实例化后才能使用,而int变量不需要
3、Integer实际是对象的引用,当new一个Integer时,实际上是生成一个指针指向此对象;而int则是直接存储数据值
4、Integer的默认值是null,int的默认值是0
延伸:
关于Integer和int的比较
1、由于Integer变量实际上是对一个Integer对象的引用,所以两个通过new生成的Integer变量永远是不相等的(因为new生成的是两个对象,其内存地址不同)。
1 | Integer i = new Integer(100); |
2、Integer变量和int变量比较时,只要两个变量的值是向等的,则结果为true(因为包装类Integer和基本数据类型int比较时,java会自动拆包装为int,然后进行比较,实际上就变为两个int变量的比较)
1 | Integer i = new Integer(100); |
3、非new生成的Integer变量和new Integer()生成的变量比较时,结果为false。(因为 ①当变量值在-128127之间时,非new生成的Integer变量指向的是java常量池中的对象,而new Integer()生成的变量指向堆中新建的对象,两者在内存中的地址不同;②当变量值不在-128127之间时,非new生成Integer变量时,java API中最终会按照new Integer(i)进行处理(参考下面第4条),最终两个Interger的地址同样是不相同的)
1 | Integer i = new Integer(100); |
4、对于两个非new生成的Integer对象,进行比较时,如果两个变量的值在区间-128到127之间,则比较结果为true,如果两个变量的值不在此区间,则比较结果为false
1 | Integer i = 100; |
对于第4条的原因:
java在编译Integer i = 100 ;时,会翻译成为Integer i = Integer.valueOf(100);,而java API中对Integer类型的valueOf的定义如下:
1 | public static Integer valueOf(int i){ |
java对于-128到127之间的数,会进行缓存,Integer i = 127时,会将127进行缓存,下次再写Integer j = 127时,就会直接从缓存中取,就不会new了
如果有错误的地方,还请指正。
参考:
http://blog.csdn.net/you23hai45/article/details/50734274
http://www.cnblogs.com/liuling/archive/2013/05/05/intAndInteger.html
Java String、StringBuffer 和 StringBuilder 的区别
String
String:字符串常量,字符串长度不可变。Java 中 String 是 immutable(不可变)的。
String 类的包含如下定义:
1 | /** The value is used for character storage. */ |
用于存放字符的数组被声明为 final 的,因此只能赋值一次,不可再更改。
StringBuffer(JDK1.0)
StringBuffer:字符串变量(Synchronized,即线程安全)。如果要频繁对字符串内容进行修改,出于效率考虑最好使用 StringBuffer,如果想转成 String 类型,可以调用 StringBuffer 的 toString() 方法。
Java.lang.StringBuffer 线程安全的可变字符序列。在任意时间点上它都包含某种特定的字符序列,但通过某些方法调用可以改变该序列的长度和内容。可将字符串缓冲区安全地用于多个线程。
StringBuffer 上的主要操作是 append 和 insert 方法,可重载这些方法,以接受任意类型的数据。每个方法都能有效地将给定的数据转换成字符串,然后将该字符串的字符追加或插入到字符串缓冲区中。
- append 方法始终将这些字符添加到缓冲区的末端;
- insert 方法则在指定的点添加字符。
例如,如果 z 引用一个当前内容是 start 的字符串缓冲区对象,则此方法调用 z.append(“le”) 会使字符串缓冲区包含 startle ,而 z.insert(4, “le”) 将更改字符串缓冲区,使之包含 starlet 。
StringBuilder(JDK5.0)
StringBuilder:字符串变量(非线程安全)。在内部,StringBuilder 对象被当作是一个包含字符序列的变长数组。
java.lang.StringBuilder 是一个可变的字符序列,是 JDK5.0 新增的。此类提供一个与 StringBuffer 兼容的 API,但不保证同步。该类被设计用作 StringBuffer 的一个简易替换,用在字符串缓冲区被单个线程使用的时候(这种情况很普遍)。
其构造方法如下:
| 构造方法 | 描述 |
|---|---|
| StringBuilder() | 创建一个容量为16的StringBuilder对象(16个空元素) |
| StringBuilder(CharSequence cs) | 创建一个包含cs的StringBuilder对象,末尾附加16个空元素 |
| StringBuilder(int initCapacity) | 创建一个容量为initCapacity的StringBuilder对象 |
| StringBuilder(String s) | 创建一个包含s的StringBuilder对象,末尾附加16个空元素 |
在大部分情况下,StringBuilder > StringBuffer。这主要是由于前者不需要考虑线程安全。
三者区别
String 类型和 StringBuffer 的主要性能区别:String 是不可变的对象, 因此在每次对 String 类型进行改变的时候,都会生成一个新的 String 对象,然后将指针指向新的 String 对象,所以经常改变内容的字符串最好不要用 String ,因为每次生成对象都会对系统性能产生影响,特别当内存中无引用对象多了以后, JVM 的 GC 就会开始工作,性能就会降低。
使用 StringBuffer 类时,每次都会对 StringBuffer 对象本身进行操作,而不是生成新的对象并改变对象引用。所以多数情况下推荐使用 StringBuffer ,特别是字符串对象经常改变的情况下。
在某些特别情况下, String 对象的字符串拼接其实是被 Java Compiler 编译成了 StringBuffer 对象的拼接,所以这些时候 String 对象的速度并不会比 StringBuffer 对象慢,例如:
1 | String s1 = “This is only a” + “ simple” + “ test”; |
生成 String s1 对象的速度并不比 StringBuffer 慢。其实在 Java Compiler 里,自动做了如下转换:
Java Compiler直接把上述第一条语句编译为:
1 | String s1 = “This is only a simple test”; |
所以速度很快。但要注意的是,如果拼接的字符串来自另外的 String 对象的话,Java Compiler 就不会自动转换了,速度也就没那么快了,例如:
1 | String s2 = “This is only a”; |
这时候,Java Compiler 会规规矩矩的按照原来的方式去做,String 的 concatenation(即+)操作利用了 StringBuilder(或StringBuffer)的append 方法实现,此时,对于上述情况,若 s2,s3,s4 采用 String 定义,拼接时需要额外创建一个 StringBuffer(或StringBuilder),之后将StringBuffer 转换为 String,若采用 StringBuffer(或StringBuilder),则不需额外创建 StringBuffer。
使用策略
(1)基本原则:如果要操作少量的数据,用String ;单线程操作大量数据,用StringBuilder ;多线程操作大量数据,用StringBuffer。
(2)不要使用String类的”+”来进行频繁的拼接,因为那样的性能极差的,应该使用StringBuffer或StringBuilder类,这在Java的优化上是一条比较重要的原则。例如:
1
2
3
4
5
6
7
8
9
10
11String result = "";
for (String s : hugeArray) {
result = result + s;
}
// 使用StringBuilder
StringBuilder sb = new StringBuilder();
for (String s : hugeArray) {
sb.append(s);
}
String result = sb.toString();当出现上面的情况时,显然我们要采用第二种方法,因为第一种方法,每次循环都会创建一个String result用于保存结果,除此之外二者基本相同(对于jdk1.5及之后版本)。
(3)为了获得更好的性能,在构造 StringBuffer 或 StringBuilder 时应尽可能指定它们的容量。当然,如果你操作的字符串长度(length)不超过 16 个字符就不用了,当不指定容量(capacity)时默认构造一个容量为16的对象。不指定容量会显著降低性能。
(4)StringBuilder 一般使用在方法内部来完成类似 + 功能,因为是线程不安全的,所以用完以后可以丢弃。StringBuffer 主要用在全局变量中。
(5)相同情况下使用 StringBuilder 相比使用 StringBuffer 仅能获得 10%~15% 左右的性能提升,但却要冒多线程不安全的风险。而在现实的模块化编程中,负责某一模块的程序员不一定能清晰地判断该模块是否会放入多线程的环境中运行,因此:除非确定系统的瓶颈是在 StringBuffer 上,并且确定你的模块不会运行在多线程模式下,才可以采用 StringBuilder;否则还是用 StringBuffer。
常用工具类
Math
顾名思义,Math类就是用来进行数学计算的,它提供了大量的静态方法来便于我们实现数学计算:
求绝对值:
1 | Math.abs(-100); // 100 |
取最大或最小值:
1 | Math.max(100, 99); // 100 |
计算xy次方:
1 | Math.pow(2, 10); // 2的10次方=1024 |
计算√x:
1 | Math.sqrt(2); // 1.414... |
计算ex次方:
1 | Math.exp(2); // 7.389... |
计算以e为底的对数:
1 | Math.log(4); // 1.386... |
计算以10为底的对数:
1 | Math.log10(100); // 2 |
三角函数:
1 | Math.sin(3.14); // 0.00159... |
Math还提供了几个数学常量:
1 | double pi = Math.PI; // 3.14159... |
生成一个随机数x,x的范围是0 <= x < 1:
1 | Math.random(); // 0.53907... 每次都不一样 |
Random
Random用来创建伪随机数。所谓伪随机数,是指只要给定一个初始的种子,产生的随机数序列是完全一样的。
要生成一个随机数,可以使用nextInt()、nextLong()、nextFloat()、nextDouble():
1 | Random r = new Random(); |
有童鞋问,每次运行程序,生成的随机数都是不同的,没看出伪随机数的特性来。
这是因为我们创建Random实例时,如果不给定种子,就使用系统当前时间戳作为种子,因此每次运行时,种子不同,得到的伪随机数序列就不同。
如果我们在创建Random实例时指定一个种子,就会得到完全确定的随机数序列。
SecureRandom
有伪随机数,就有真随机数。实际上真正的真随机数只能通过量子力学原理来获取,而我们想要的是一个不可预测的安全的随机数,SecureRandom就是用来创建安全的随机数的:
1 | SecureRandom sr = new SecureRandom(); |
SecureRandom的安全性是通过操作系统提供的安全的随机种子来生成随机数。这个种子是通过CPU的热噪声、读写磁盘的字节、网络流量等各种随机事件产生的“熵”。
在密码学中,安全的随机数非常重要。如果使用不安全的伪随机数,所有加密体系都将被攻破。因此,时刻牢记必须使用SecureRandom来产生安全的随机数。
异常处理
Java的异常
因为Java的异常是class,它的继承关系如下:
1 | ┌───────────┐ |
从继承关系可知:Throwable是异常体系的根,它继承自Object。Throwable有两个体系:Error和Exception,Error表示严重的错误,程序对此一般无能为力,例如:
OutOfMemoryError:内存耗尽NoClassDefFoundError:无法加载某个ClassStackOverflowError:栈溢出
而Exception则是运行时的错误,它可以被捕获并处理。
某些异常是应用程序逻辑处理的一部分,应该捕获并处理。例如:
NumberFormatException:数值类型的格式错误FileNotFoundException:未找到文件SocketException:读取网络失败
还有一些异常是程序逻辑编写不对造成的,应该修复程序本身。例如:
NullPointerException:对某个null的对象调用方法或字段IndexOutOfBoundsException:数组索引越界
Exception又分为两大类:
RuntimeException以及它的子类;- 非
RuntimeException(包括IOException、ReflectiveOperationException等等)
Java规定:
- 必须捕获的异常,包括
Exception及其子类,但不包括RuntimeException及其子类,这种类型的异常称为Checked Exception。 - 不需要捕获的异常,包括
Error及其子类,RuntimeException及其子类。
捕获异常
在Java中,凡是可能抛出异常的语句,都可以用try ... catch捕获。把可能发生异常的语句放在try { ... }中,然后使用catch捕获对应的Exception及其子类。
可以使用多个catch语句,每个catch分别捕获对应的Exception及其子类。JVM在捕获到异常后,会从上到下匹配catch语句,匹配到某个catch后,执行catch代码块,然后不再继续匹配。
存在多个catch的时候,catch的顺序非常重要:子类必须写在前面。
注意finally有几个特点:
finally语句不是必须的,可写可不写;finally总是最后执行。
抛出异常
通常不要在finally中抛出异常。如果在finally中抛出异常,应该原始异常加入到原有异常中。调用方可通过Throwable.getSuppressed()获取所有添加的Suppressed Exception。
1 | public class Main { |
断言
断言(Assertion)是一种调试程序的方式。在Java中,使用assert关键字来实现断言。
1 | public static void main(String[] args) { |
语句assert x >= 0;即为断言,断言条件x >= 0预期为true。如果计算结果为false,则断言失败,抛出AssertionError。
JVM默认关闭断言指令,即遇到assert语句就自动忽略了,不执行。
要执行assert语句,必须给Java虚拟机传递-enableassertions(可简写为-ea)参数启用断言。
反射
Class类
反射就是Reflection,Java的反射是指程序在运行期可以拿到一个对象的所有信息。
反射是为了解决在运行期,对某个实例一无所知的情况下,如何调用其方法。
class(包括interface)的本质是数据类型(Type)。无继承关系的数据类型无法赋值:
1 | Number n = new Double(123.456); // OK |
而class是由JVM在执行过程中动态加载的。JVM在第一次读取到一种class类型时,将其加载进内存。
每加载一种class,JVM就为其创建一个Class类型的实例,并关联起来。注意:这里的Class类型是一个名叫Class的class。它长这样:
1 | public final class Class { |
以String类为例,当JVM加载String类时,它首先读取String.class文件到内存,然后,为String类创建一个Class实例并关联起来:
1 | Class cls = new Class(String); |
这个Class实例是JVM内部创建的,如果我们查看JDK源码,可以发现Class类的构造方法是private,只有JVM能创建Class实例,我们自己的Java程序是无法创建Class实例的。
由于JVM为每个加载的class创建了对应的Class实例,并在实例中保存了该class的所有信息,包括类名、包名、父类、实现的接口、所有方法、字段等,因此,如果获取了某个Class实例,我们就可以通过这个Class实例获取到该实例对应的class的所有信息。
这种通过Class实例获取class信息的方法称为反射(Reflection)。
如何获取一个class的Class实例?有三个方法:
方法一:直接通过一个class的静态变量class获取:
1 | Class cls = String.class; |
方法二:如果我们有一个实例变量,可以通过该实例变量提供的getClass()方法获取:
1 | String s = "Hello"; |
方法三:如果知道一个class的完整类名,可以通过静态方法Class.forName()获取:
1 | Class cls = Class.forName("java.lang.String"); |
因为Class实例在JVM中是唯一的,所以,上述方法获取的Class实例是同一个实例。
注意一下Class实例比较和instanceof的差别:
1 | Integer n = new Integer(123); |
用instanceof不但匹配指定类型,还匹配指定类型的子类。而用==判断class实例可以精确地判断数据类型,但不能作子类型比较。
通常情况下,我们应该用instanceof判断数据类型,因为面向抽象编程的时候,我们不关心具体的子类型。只有在需要精确判断一个类型是不是某个class的时候,我们才使用==判断class实例。
如果获取到了一个Class实例,我们就可以通过该Class实例来创建对应类型的实例:
1 | // 获取String的Class实例: |
上述代码相当于new String()。通过Class.newInstance()可以创建类实例,它的局限是:只能调用public的无参数构造方法。带参数的构造方法,或者非public的构造方法都无法通过Class.newInstance()被调用
动态加载
JVM在执行Java程序的时候,并不是一次性把所有用到的class全部加载到内存,而是第一次需要用到class时才加载。例如:
1 | // Main.java |
当执行Main.java时,由于用到了Main,因此,JVM首先会把Main.class加载到内存。然而,并不会加载Person.class,除非程序执行到create()方法,JVM发现需要加载Person类时,才会首次加载Person.class。如果没有执行create()方法,那么Person.class根本就不会被加载。
访问字段
对任意的一个Object实例,只要我们获取了它的Class,就可以获取它的一切信息。
我们先看看如何通过Class实例获取字段信息。Class类提供了以下几个方法来获取字段:
- Field getField(name):根据字段名获取某个public的field(包括父类)
- Field getDeclaredField(name):根据字段名获取当前类的某个field(不包括父类)
- Field[] getFields():获取所有public的field(包括父类)
- Field[] getDeclaredFields():获取当前类的所有field(不包括父类)
一个Field对象包含了一个字段的所有信息:
getName():返回字段名称,例如,"name";getType():返回字段类型,也是一个Class实例,例如,String.class;getModifiers():返回字段的修饰符,它是一个int,不同的bit表示不同的含义。
通过反射可以获取字段值和设置字段值。
调用方法
我们已经能通过Class实例获取所有Field对象,同样的,可以通过Class实例获取所有Method信息。Class类提供了以下几个方法来获取Method:
Method getMethod(name, Class...):获取某个public的Method(包括父类)Method getDeclaredMethod(name, Class...):获取当前类的某个Method(不包括父类)Method[] getMethods():获取所有public的Method(包括父类)Method[] getDeclaredMethods():获取当前类的所有Method(不包括父类)
一个Method对象包含一个方法的所有信息:
getName():返回方法名称,例如:"getScore";getReturnType():返回方法返回值类型,也是一个Class实例,例如:String.class;getParameterTypes():返回方法的参数类型,是一个Class数组,例如:{String.class, int.class};getModifiers():返回方法的修饰符,它是一个int,不同的bit表示不同的含义。
调用方法
1 | // reflection |
调用静态方法
1 | import java.lang.reflect.Method; |
使用反射调用方法时,仍然遵循多态原则:即总是调用实际类型的覆写方法(如果存在)。
通过设置setAccessible(true)来访问非public方法;(可能会设置失败)
调用构造方法
如果通过反射来创建新的实例,可以调用Class提供的newInstance()方法:
1 | Person p = Person.class.newInstance(); |
调用Class.newInstance()的局限是,它只能调用该类的public无参数构造方法。如果构造方法带有参数,或者不是public,就无法直接通过Class.newInstance()来调用。
为了调用任意的构造方法,Java的反射API提供了Constructor对象,它包含一个构造方法的所有信息,可以创建一个实例。Constructor对象和Method非常类似,不同之处仅在于它是一个构造方法,并且,调用结果总是返回实例。
1 | import java.lang.reflect.Constructor; |
通过Class实例获取Constructor的方法如下:
getConstructor(Class...):获取某个public的Constructor;getDeclaredConstructor(Class...):获取某个Constructor;getConstructors():获取所有public的Constructor;getDeclaredConstructors():获取所有Constructor。
注意Constructor总是当前类定义的构造方法,和父类无关,因此不存在多态的问题。
调用非public的Constructor时,必须首先通过setAccessible(true)设置允许访问。setAccessible(true)可能会失败。
获取继承关系
通过Class对象可以获取继承关系:
Class getSuperclass():获取父类类型;Class[] getInterfaces():获取当前类实现的所有接口。
要特别注意:getInterfaces()只返回当前类直接实现的接口类型,并不包括其父类实现的接口类型。
此外,对所有interface的Class调用getSuperclass()返回的是null,获取接口的父接口要用getInterfaces()。
如果一个类没有实现任何interface,那么getInterfaces()返回空数组。
通过Class对象的isAssignableFrom()方法可以判断一个向上转型是否可以实现。
动态代理
我们来比较Java的class和interface的区别:
- 可以实例化
class(非abstract); - 不能实例化
interface。
所有interface类型的变量总是通过向上转型并指向某个实例的。
Java标准库提供了一种动态代理(Dynamic Proxy)的机制:可以在运行期动态创建某个interface的实例。
什么叫运行期动态创建?听起来好像很复杂。所谓动态代理,是和静态相对应的。我们来看静态代码怎么写:
定义接口:
1 | public interface Hello { |
编写实现类:
1 | public class HelloWorld implements Hello { |
创建实例,转型为接口并调用:
1 | Hello hello = new HelloWorld(); |
这种方式就是我们通常编写代码的方式。
还有一种方式是动态代码,我们仍然先定义了接口Hello,但是我们并不去编写实现类,而是直接通过JDK提供的一个Proxy.newProxyInstance()创建了一个Hello接口对象。这种没有实现类但是在运行期动态创建了一个接口对象的方式,我们称为动态代码。JDK提供的动态创建接口对象的方式,就叫动态代理。
1 | import java.lang.reflect.InvocationHandler; |
在运行期动态创建一个interface实例的方法如下:
- 定义一个
InvocationHandler实例,它负责实现接口的方法调用; - 通过
Proxy.newProxyInstance()创建interface实例,它需要3个参数:- 使用的
ClassLoader,通常就是接口类的ClassLoader; - 需要实现的接口数组,至少需要传入一个接口进去;
- 用来处理接口方法调用的
InvocationHandler实例。
- 使用的
- 将返回的
Object强制转型为接口。
动态代理实际上是JDK在运行期动态创建class字节码并加载的过程,它并没有什么黑魔法,把上面的动态代理改写为静态实现类大概长这样:
1 | public class HelloDynamicProxy implements Hello { |
其实就是JDK帮我们自动编写了一个上述类(不需要源码,可以直接生成字节码),并不存在可以直接实例化接口的黑魔法。
注解
使用注解
什么是注解(Annotation)?注解是放在Java源码的类、方法、字段、参数前的一种特殊“注释”。
注释会被编译器直接忽略,注解则可以被编译器打包进入class文件,因此,注解是一种用作标注的“元数据”。
从JVM的角度看,注解本身对代码逻辑没有任何影响,如何使用注解完全由工具决定。
Java的注解可以分为三类:
第一类是由编译器使用的注解,例如:
@Override:让编译器检查该方法是否正确地实现了覆写;@SuppressWarnings:告诉编译器忽略此处代码产生的警告。
这类注解不会被编译进入.class文件,它们在编译后就被编译器扔掉了。
第二类是由工具处理.class文件使用的注解,比如有些工具会在加载class的时候,对class做动态修改,实现一些特殊的功能。这类注解会被编译进入.class文件,但加载结束后并不会存在于内存中。这类注解只被一些底层库使用,一般我们不必自己处理。
第三类是在程序运行期能够读取的注解,它们在加载后一直存在于JVM中,这也是最常用的注解。例如,一个配置了@PostConstruct的方法会在调用构造方法后自动被调用(这是Java代码读取该注解实现的功能,JVM并不会识别该注解)。
注解可以配置参数,没有指定配置的参数使用默认值;
定义注解
Java语言使用@interface语法来定义注解(Annotation),它的格式如下:
1 | public Report { |
元注解
有一些注解可以修饰其他注解,这些注解就称为元注解(meta annotation)。Java标准库已经定义了一些元注解,我们只需要使用元注解,通常不需要自己去编写元注解。
@Target
最常用的元注解是@Target。使用@Target可以定义Annotation能够被应用于源码的哪些位置:
- 类或接口:
ElementType.TYPE; - 字段:
ElementType.FIELD; - 方法:
ElementType.METHOD; - 构造方法:
ElementType.CONSTRUCTOR; - 方法参数:
ElementType.PARAMETER。
例如,定义注解@Report可用在方法上,我们必须添加一个@Target(ElementType.METHOD):
1 |
|
@Retention
另一个重要的元注解@Retention定义了Annotation的生命周期:
- 仅编译期:
RetentionPolicy.SOURCE; - 仅class文件:
RetentionPolicy.CLASS; - 运行期:
RetentionPolicy.RUNTIME。
如果@Retention不存在,则该Annotation默认为CLASS。因为通常我们自定义的Annotation都是RUNTIME,所以,务必要加上@Retention(RetentionPolicy.RUNTIME)这个元注解。
@Repeatable
使用@Repeatable这个元注解可以定义Annotation是否可重复。
经过@Repeatable修饰后,在某个类型声明处,就可以添加多个@Report注解:
1 |
|
@Inherited
使用@Inherited定义子类是否可继承父类定义的Annotation。@Inherited仅针对@Target(ElementType.TYPE)类型的annotation有效,并且仅针对class的继承,对interface的继承无效。
其中,必须设置@Target和@Retention,@Retention一般设置为RUNTIME,因为我们自定义的注解通常要求在运行期读取。一般情况下,不必写@Inherited和@Repeatable。
处理注解
Java的注解本身对代码逻辑没有任何影响。根据@Retention的配置:
SOURCE类型的注解在编译期就被丢掉了;CLASS类型的注解仅保存在class文件中,它们不会被加载进JVM;RUNTIME类型的注解会被加载进JVM,并且在运行期可以被程序读取。
如何使用注解完全由工具决定。SOURCE类型的注解主要由编译器使用,因此我们一般只使用,不编写。CLASS类型的注解主要由底层工具库使用,涉及到class的加载,一般我们很少用到。只有RUNTIME类型的注解不但要使用,还经常需要编写。
因此,我们只讨论如何读取RUNTIME类型的注解。
因为注解定义后也是一种class,所有的注解都继承自java.lang.annotation.Annotation,因此,读取注解,需要使用反射API。
使用注解
注解如何使用,完全由程序自己决定。例如,JUnit是一个测试框架,它会自动运行所有标记为@Test的方法。
我们来看一个@Range注解,我们希望用它来定义一个String字段的规则:字段长度满足@Range的参数定义:
1 |
|
在某个JavaBean中,我们可以使用该注解:
1 | public class Person { |
但是,定义了注解,本身对程序逻辑没有任何影响。我们必须自己编写代码来使用注解。这里,我们编写一个Person实例的检查方法,它可以检查Person实例的String字段长度是否满足@Range的定义:
1 | void check(Person person) throws IllegalArgumentException, ReflectiveOperationException { |
这样一来,我们通过@Range注解,配合check()方法,就可以完成Person实例的检查。注意检查逻辑完全是我们自己编写的,JVM不会自动给注解添加任何额外的逻辑。
泛型
泛型就是定义一种模板,例如ArrayList,然后在代码中为用到的类创建对应的ArrayList<类型>:
1 | ArrayList<String> strList = new ArrayList<String>(); |
要特别注意:不能把ArrayList向上转型为ArrayList或List。
擦拭法
Java语言的泛型实现方式是擦拭法(Type Erasure)。
所谓擦拭法是指,虚拟机对泛型其实一无所知,所有的工作都是编译器做的。
Java使用擦拭法实现泛型,导致了:
- 编译器把类型
<T>视为Object; - 编译器根据
<T>实现安全的强制转型。
使用泛型的时候,我们编写的代码也是编译器看到的代码:
1 | Pair<String> p = new Pair<>("Hello", "world"); |
而虚拟机执行的代码并没有泛型:
1 | Pair p = new Pair("Hello", "world"); |
所以,Java的泛型是由编译器在编译时实行的,编译器内部永远把所有类型T视为Object处理,但是,在需要转型的时候,编译器会根据T的类型自动为我们实行安全地强制转型。
擦拭法决定了泛型<T>:
- 不能是基本类型,例如:
int; - 不能获取带泛型类型的
Class,例如:Pair.class; - 不能判断带泛型类型的类型,例如:
x instanceof Pair; - 不能实例化
T类型,例如:new T()。
泛型方法要防止重复定义方法,例如:public boolean equals(T obj);
子类可以获取父类的泛型类型<T>。
extends通配符
1 | public class Main { |
这样一来,给方法传入Pair<Integer>类型时,它符合参数Pair<? extends Number>类型。这种使用<? extends Number>的泛型定义称之为上界通配符(Upper Bounds Wildcards),即把泛型类型T的上界限定在Number了。
除了可以传入Pair<Integer>类型,我们还可以传入Pair<Double>类型,Pair<BigDecimal>类型等等,因为Double和BigDecimal都是Number的子类。
使用类似Pair<? extends Number>通配符作为方法参数时表示:
- 方法内部可以调用获取
Number引用的方法,例如:Number n = obj.getFirst();; - 方法内部无法调用传入
Number引用的方法(null除外),例如:obj.setFirst(Number n);。
即一句话总结:使用extends通配符表示可以读,不能写。
使用类似<T extends Number>定义泛型类时表示:
- 泛型类型限定为
Number以及Number的子类。
super通配符
1 | void set(Pair<? super Integer> p, Integer first, Integer last) { |
注意到Pair<? super Integer>表示,方法参数接受所有泛型类型为Integer或Integer父类的Pair类型。
使用Pair<? super Integer>通配符表示:
- 允许调用
set(? super Integer)方法传入Integer的引用; - 不允许调用
get()方法获得Integer的引用。
唯一例外是可以获取Object的引用:Object o = p.getFirst()。
换句话说,使用 super通配符作为方法参数,表示方法内部代码对于参数只能写,不能读。
PESC原则
何时使用extends,何时使用super?为了便于记忆,我们可以用PECS原则:Producer Extends Consumer Super。
即:如果需要返回T,它是生产者(Producer),要使用extends通配符;如果需要写入T,它是消费者(Consumer),要使用super通配符。
无限定通配符
我们已经讨论了<? extends T>和<? super T>作为方法参数的作用。实际上,Java的泛型还允许使用无限定通配符(Unbounded Wildcard Type),即只定义一个?:
1 | void sample(Pair<?> p) { |
因为<?>通配符既没有extends,也没有super,因此:
- 不允许调用
set(T)方法并传入引用(null除外); - 不允许调用
T get()方法并获取T引用(只能获取Object引用)。
换句话说,既不能读,也不能写,那只能做一些null判断:
1 | static boolean isNull(Pair<?> p) { |
大多数情况下,可以引入泛型参数<T>消除<?>通配符:
1 | static <T> boolean isNull(Pair<T> p) { |
<?>通配符有一个独特的特点,就是:Pair<?>是所有Pair<T>的超类。
泛型和反射
部分反射API是泛型,例如:Class,Constructor;
可以声明带泛型的数组,但不能直接创建带泛型的数组,必须强制转型;
可以通过Array.newInstance(Class, int)创建T[]数组,需要强制转型;
同时使用泛型和可变参数时需要特别小心。
集合
Java集合简介
在Java中,如果一个Java对象可以在内部持有若干其他Java对象,并对外提供访问接口,我们把这种Java对象称为集合。
Java标准库自带的java.util包提供了集合类:Collection,它是除Map外所有其他集合类的根接口。Java的java.util包主要提供了以下三种类型的集合:
List:一种有序列表的集合,例如,按索引排列的Student的List;Set:一种保证没有重复元素的集合,例如,所有无重复名称的Student的Set;Map:一种通过键值(key-value)查找的映射表集合,例如,根据Student的name查找对应Student的Map。
最后,Java访问集合总是通过统一的方式——迭代器(Iterator)来实现,它最明显的好处在于无需知道集合内部元素是按什么方式存储的。
由于Java的集合设计非常久远,中间经历过大规模改进,我们要注意到有一小部分集合类是遗留类,不应该继续使用:
Hashtable:一种线程安全的Map实现;Vector:一种线程安全的List实现;Stack:基于Vector实现的LIFO的栈。
还有一小部分接口是遗留接口,也不应该继续使用:
Enumeration:已被Iterator取代。
List
List和Array互转
1 | import java.util.List; |
HashMap(基于jdk 1.8和jdk 1.7)
HashMap这个笔记有点多,我专门整理成一个专题,可以去看hashmap详解专题
Java多线程与高并发
详细内容见多线程和高并发。
IO
IO简介
IO是指Input/Output,即输入和输出。以内存为中心:
- Input指从外部读入数据到内存,例如,把文件从磁盘读取到内存,从网络读取数据到内存等等。
- Output指把数据从内存输出到外部,例如,把数据从内存写入到文件,把数据从内存输出到网络等等。
InputStream / OutputStream
IO流以byte(字节)为最小单位,因此也称为字节流。
在Java中,InputStream代表输入字节流,OuputStream代表输出字节流,这是最基本的两种IO流。
Reader / Writer
如果我们需要读写的是字符,并且字符不全是单字节表示的ASCII字符,那么,按照char来读写显然更方便,这种流称为字符流。
Java提供了Reader和Writer表示字符流,字符流传输的最小数据单位是char。
例如,我们把char[]数组Hi你好这4个字符用Writer字符流写入文件,并且使用UTF-8编码,得到的最终文件内容是8个字节,英文字符H和i各占一个字节,中文字符你好各占3个字节。
使用Reader,数据源虽然是字节,但我们读入的数据都是char类型的字符,原因是Reader内部把读入的byte做了解码,转换成了char。使用InputStream,我们读入的数据和原始二进制数据一模一样,是byte[]数组,但是我们可以自己把二进制byte[]数组按照某种编码转换为字符串。究竟使用Reader还是InputStream,要取决于具体的使用场景。如果数据源不是文本,就只能使用InputStream,如果数据源是文本,使用Reader更方便一些。Writer和OutputStream是类似的。
同步和异步
同步IO是指,读写IO时代码必须等待数据返回后才继续执行后续代码,它的优点是代码编写简单,缺点是CPU执行效率低。
而异步IO是指,读写IO时仅发出请求,然后立刻执行后续代码,它的优点是CPU执行效率高,缺点是代码编写复杂。
Java标准库的包java.io提供了同步IO,而java.nio则是异步IO。上面我们讨论的InputStream、OutputStream、Reader和Writer都是同步IO的抽象类,对应的具体实现类,以文件为例,有FileInputStream、FileOutputStream、FileReader和FileWriter。
小结
IO流是一种流式的数据输入/输出模型:
- 二进制数据以
byte为最小单位在InputStream/OutputStream中单向流动; - 字符数据以
char为最小单位在Reader/Writer中单向流动。
Java标准库的java.io包提供了同步IO功能:
- 字节流接口:
InputStream/OutputStream; - 字符流接口:
Reader/Writer。
File对象
在计算机系统中,文件是非常重要的存储方式。Java的标准库java.io提供了File对象来操作文件和目录。
File对象既可以表示文件,也可以表示目录。特别要注意的是,构造一个File对象,即使传入的文件或目录不存在,代码也不会出错,因为构造一个File对象,并不会导致任何磁盘操作。只有当我们调用File对象的某些方法的时候,才真正进行磁盘操作。
Java标准库的java.io.File对象表示一个文件或者目录:
- 创建
File对象本身不涉及IO操作; - 可以获取路径/绝对路径/规范路径:
getPath()/getAbsolutePath()/getCanonicalPath(); - 可以获取目录的文件和子目录:
list()/listFiles(); - 可以创建或删除文件和目录。
InputStream
InputStream就是Java标准库提供的最基本的输入流。它位于java.io这个包里。java.io包提供了所有同步IO的功能。
要特别注意的一点是,InputStream并不是一个接口,而是一个抽象类,它是所有输入流的超类。这个抽象类定义的一个最重要的方法就是int read(),签名如下:
1 | public abstract int read() throws IOException; |
这个方法会读取输入流的下一个字节,并返回字节表示的int值(0~255)。如果已读到末尾,返回-1表示不能继续读取了。
FileInputStream是InputStream的一个子类。顾名思义,FileInputStream就是从文件流中读取数据。下面的代码演示了如何完整地读取一个FileInputStream的所有字节:
1 | public void readFile() throws IOException { |
在计算机中,类似文件、网络端口这些资源,都是由操作系统统一管理的。应用程序在运行的过程中,如果打开了一个文件进行读写,完成后要及时地关闭,以便让操作系统把资源释放掉,否则,应用程序占用的资源会越来越多,不但白白占用内存,还会影响其他应用程序的运行。
InputStream和OutputStream都是通过close()方法来关闭流。关闭流就会释放对应的底层资源。
缓冲
在读取流的时候,一次读取一个字节并不是最高效的方法。很多流支持一次性读取多个字节到缓冲区,对于文件和网络流来说,利用缓冲区一次性读取多个字节效率往往要高很多。InputStream提供了两个重载方法来支持读取多个字节:
int read(byte[] b):读取若干字节并填充到byte[]数组,返回读取的字节数int read(byte[] b, int off, int len):指定byte[]数组的偏移量和最大填充数
利用上述方法一次读取多个字节时,需要先定义一个byte[]数组作为缓冲区,read()方法会尽可能多地读取字节到缓冲区, 但不会超过缓冲区的大小。read()方法的返回值不再是字节的int值,而是返回实际读取了多少个字节。如果返回-1,表示没有更多的数据了。
利用缓冲区一次读取多个字节的代码如下:
1 | public void readFile() throws IOException { |
阻塞
在调用InputStream的read()方法读取数据时,我们说read()方法是阻塞(Blocking)的。它的意思是,对于下面的代码:
1 | int n; |
执行到第二行代码时,必须等read()方法返回后才能继续。因为读取IO流相比执行普通代码,速度会慢很多,因此,无法确定read()方法调用到底要花费多长时间。
小结
Java标准库的java.io.InputStream定义了所有输入流的超类:
FileInputStream实现了文件流输入;ByteArrayInputStream在内存中模拟一个字节流输入。
总是使用try(resource)来保证InputStream正确关闭。
OutputStream
和InputStream相反,OutputStream是Java标准库提供的最基本的输出流。
和InputStream类似,OutputStream也是抽象类,它是所有输出流的超类。这个抽象类定义的一个最重要的方法就是void write(int b),签名如下:
1 | public abstract void write(int b) throws IOException; |
这个方法会写入一个字节到输出流。要注意的是,虽然传入的是int参数,但只会写入一个字节,即只写入int最低8位表示字节的部分(相当于b & 0xff)。
和InputStream类似,OutputStream也提供了close()方法关闭输出流,以便释放系统资源。要特别注意:OutputStream还提供了一个flush()方法,它的目的是将缓冲区的内容真正输出到目的地。
为什么要有flush()?因为向磁盘、网络写入数据的时候,出于效率的考虑,操作系统并不是输出一个字节就立刻写入到文件或者发送到网络,而是把输出的字节先放到内存的一个缓冲区里(本质上就是一个byte[]数组),等到缓冲区写满了,再一次性写入文件或者网络。对于很多IO设备来说,一次写一个字节和一次写1000个字节,花费的时间几乎是完全一样的,所以OutputStream有个flush()方法,能强制把缓冲区内容输出。
通常情况下,我们不需要调用这个flush()方法,因为缓冲区写满了OutputStream会自动调用它,并且,在调用close()方法关闭OutputStream之前,也会自动调用flush()方法。
但是,在某些情况下,我们必须手动调用flush()方法。举个栗子:
小明正在开发一款在线聊天软件,当用户输入一句话后,就通过OutputStream的write()方法写入网络流。小明测试的时候发现,发送方输入后,接收方根本收不到任何信息,怎么肥四?
原因就在于写入网络流是先写入内存缓冲区,等缓冲区满了才会一次性发送到网络。如果缓冲区大小是4K,则发送方要敲几千个字符后,操作系统才会把缓冲区的内容发送出去,这个时候,接收方会一次性收到大量消息。
解决办法就是每输入一句话后,立刻调用flush(),不管当前缓冲区是否已满,强迫操作系统把缓冲区的内容立刻发送出去。
实际上,InputStream也有缓冲区。例如,从FileInputStream读取一个字节时,操作系统往往会一次性读取若干字节到缓冲区,并维护一个指针指向未读的缓冲区。然后,每次我们调用int read()读取下一个字节时,可以直接返回缓冲区的下一个字节,避免每次读一个字节都导致IO操作。当缓冲区全部读完后继续调用read(),则会触发操作系统的下一次读取并再次填满缓冲区。
FileOutputStream
我们以FileOutputStream为例,演示如何将若干个字节写入文件流:
1 | public void writeFile() throws IOException { |
每次写入一个字节非常麻烦,更常见的方法是一次性写入若干字节。这时,可以用OutputStream提供的重载方法void write(byte[])来实现:
1 | public void writeFile() throws IOException { |
和InputStream一样,上述代码没有考虑到在发生异常的情况下如何正确地关闭资源。写入过程也会经常发生IO错误,例如,磁盘已满,无权限写入等等。我们需要用try(resource)来保证OutputStream在无论是否发生IO错误的时候都能够正确地关闭:
1 | public void writeFile() throws IOException { |
阻塞
和InputStream一样,OutputStream的write()方法也是阻塞的。
OutputStream实现类
用FileOutputStream可以从文件获取输出流,这是OutputStream常用的一个实现类。此外,ByteArrayOutputStream可以在内存中模拟一个OutputStream:
1 | import java.io.*; |
ByteArrayOutputStream实际上是把一个byte[]数组在内存中变成一个OutputStream,虽然实际应用不多,但测试的时候,可以用它来构造一个OutputStream。
小结
Java标准库的java.io.OutputStream定义了所有输出流的超类:
FileOutputStream实现了文件流输出;ByteArrayOutputStream在内存中模拟一个字节流输出。
某些情况下需要手动调用OutputStream的flush()方法来强制输出缓冲区。
总是使用try(resource)来保证OutputStream正确关闭。
序列化
序列化是指把一个Java对象变成二进制内容,本质上就是一个byte[]数组。
为什么要把Java对象序列化呢?因为序列化后可以把byte[]保存到文件中,或者把byte[]通过网络传输到远程,这样,就相当于把Java对象存储到文件或者通过网络传输出去了。
有序列化,就有反序列化,即把一个二进制内容(也就是byte[]数组)变回Java对象。有了反序列化,保存到文件中的byte[]数组又可以“变回”Java对象,或者从网络上读取byte[]并把它“变回”Java对象。
我们来看看如何把一个Java对象序列化。
一个Java对象要能序列化,必须实现一个特殊的java.io.Serializable接口,它的定义如下:
1 | public interface Serializable { |
Serializable接口没有定义任何方法,它是一个空接口。我们把这样的空接口称为“标记接口”(Marker Interface),实现了标记接口的类仅仅是给自身贴了个“标记”,并没有增加任何方法。
序列化
把一个Java对象变为byte[]数组,需要使用ObjectOutputStream。它负责把一个Java对象写入一个字节流:
1 | import java.io.*; |
ObjectOutputStream既可以写入基本类型,如int,boolean,也可以写入String(以UTF-8编码),还可以写入实现了Serializable接口的Object。
因为写入Object时需要大量的类型信息,所以写入的内容很大。
反序列化
和ObjectOutputStream相反,ObjectInputStream负责从一个字节流读取Java对象:
1 | try (ObjectInputStream input = new ObjectInputStream(...)) { |
除了能读取基本类型和String类型外,调用readObject()可以直接返回一个Object对象。要把它变成一个特定类型,必须强制转型。
readObject()可能抛出的异常有:
ClassNotFoundException:没有找到对应的Class;InvalidClassException:Class不匹配。
对于ClassNotFoundException,这种情况常见于一台电脑上的Java程序把一个Java对象,例如,Person对象序列化以后,通过网络传给另一台电脑上的另一个Java程序,但是这台电脑的Java程序并没有定义Person类,所以无法反序列化。
对于InvalidClassException,这种情况常见于序列化的Person对象定义了一个int类型的age字段,但是反序列化时,Person类定义的age字段被改成了long类型,所以导致class不兼容。
为了避免这种class定义变动导致的不兼容,Java的序列化允许class定义一个特殊的serialVersionUID静态变量,用于标识Java类的序列化“版本”,通常可以由IDE自动生成。如果增加或修改了字段,可以改变serialVersionUID的值,这样就能自动阻止不匹配的class版本:
1 | public class Person implements Serializable { |
要特别注意反序列化的几个重要特点:
反序列化时,由JVM直接构造出Java对象,不调用构造方法,构造方法内部的代码,在反序列化时根本不可能执行。
安全性
因为Java的序列化机制可以导致一个实例能直接从byte[]数组创建,而不经过构造方法,因此,它存在一定的安全隐患。一个精心构造的byte[]数组被反序列化后可以执行特定的Java代码,从而导致严重的安全漏洞。
实际上,Java本身提供的基于对象的序列化和反序列化机制既存在安全性问题,也存在兼容性问题。更好的序列化方法是通过JSON这样的通用数据结构来实现,只输出基本类型(包括String)的内容,而不存储任何与代码相关的信息。
小结
可序列化的Java对象必须实现java.io.Serializable接口,类似Serializable这样的空接口被称为“标记接口”(Marker Interface);
反序列化时不调用构造方法,可设置serialVersionUID作为版本号(非必需);
Java的序列化机制仅适用于Java,如果需要与其它语言交换数据,必须使用通用的序列化方法,例如JSON。
Reader
Reader是Java的IO库提供的另一个输入流接口。和InputStream的区别是,InputStream是一个字节流,即以byte为单位读取,而Reader是一个字符流,即以char为单位读取:
| InputStream | Reader |
|---|---|
字节流,以byte为单位 |
字符流,以char为单位 |
读取字节(-1,0~255):int read() |
读取字符(-1,0~65535):int read() |
读到字节数组:int read(byte[] b) |
读到字符数组:int read(char[] c) |
java.io.Reader是所有字符输入流的超类,它最主要的方法是:
1 | public int read() throws IOException; |
这个方法读取字符流的下一个字符,并返回字符表示的int,范围是0~`65535。如果已读到末尾,返回-1`。
FileReader
FileReader是Reader的一个子类,它可以打开文件并获取Reader。下面的代码演示了如何完整地读取一个FileReader的所有字符:
1 | public void readFile() throws IOException { |
如果我们读取一个纯ASCII编码的文本文件,上述代码工作是没有问题的。但如果文件中包含中文,就会出现乱码,因为FileReader默认的编码与系统相关,例如,Windows系统的默认编码可能是GBK,打开一个UTF-8编码的文本文件就会出现乱码。
要避免乱码问题,我们需要在创建FileReader时指定编码:
1 | Reader reader = new FileReader("src/readme.txt", StandardCharsets.UTF_8); |
和InputStream类似,Reader也是一种资源,需要保证出错的时候也能正确关闭,所以我们需要用try (resource)来保证Reader在无论有没有IO错误的时候都能够正确地关闭:
1 | try (Reader reader = new FileReader("src/readme.txt", StandardCharsets.UTF_8) { |
Reader还提供了一次性读取若干字符并填充到char[]数组的方法:
1 | public int read(char[] c) throws IOException |
它返回实际读入的字符个数,最大不超过char[]数组的长度。返回-1表示流结束。
利用这个方法,我们可以先设置一个缓冲区,然后,每次尽可能地填充缓冲区:
1 | public void readFile() throws IOException { |
CharArrayReader
CharArrayReader可以在内存中模拟一个Reader,它的作用实际上是把一个char[]数组变成一个Reader,这和ByteArrayInputStream非常类似:
1 | try (Reader reader = new CharArrayReader("Hello".toCharArray())) { |
StringReader
StringReader可以直接把String作为数据源,它和CharArrayReader几乎一样:
1 | try (Reader reader = new StringReader("Hello")) { |
InputStreamReader
Reader和InputStream有什么关系?
除了特殊的CharArrayReader和StringReader,普通的Reader实际上是基于InputStream构造的,因为Reader需要从InputStream中读入字节流(byte),然后,根据编码设置,再转换为char就可以实现字符流。如果我们查看FileReader的源码,它在内部实际上持有一个FileInputStream。
既然Reader本质上是一个基于InputStream的byte到char的转换器,那么,如果我们已经有一个InputStream,想把它转换为Reader,是完全可行的。InputStreamReader就是这样一个转换器,它可以把任何InputStream转换为Reader。示例代码如下:
1 | // 持有InputStream: |
构造InputStreamReader时,我们需要传入InputStream,还需要指定编码,就可以得到一个Reader对象。上述代码可以通过try (resource)更简洁地改写如下:
1 | try (Reader reader = new InputStreamReader(new FileInputStream("src/readme.txt"), "UTF-8")) { |
上述代码实际上就是FileReader的一种实现方式。
使用try (resource)结构时,当我们关闭Reader时,它会在内部自动调用InputStream的close()方法,所以,只需要关闭最外层的Reader对象即可。
使用InputStreamReader,可以把一个InputStream转换成一个Reader。
小结
Reader定义了所有字符输入流的超类:
FileReader实现了文件字符流输入,使用时需要指定编码;CharArrayReader和StringReader可以在内存中模拟一个字符流输入。
Reader是基于InputStream构造的:可以通过InputStreamReader在指定编码的同时将任何InputStream转换为Reader。
总是使用try (resource)保证Reader正确关闭。
Writer
Reader是带编码转换器的InputStream,它把byte转换为char,而Writer就是带编码转换器的OutputStream,它把char转换为byte并输出。
Writer和OutputStream的区别如下:
| OutputStream | Writer |
|---|---|
字节流,以byte为单位 |
字符流,以char为单位 |
写入字节(0~255):void write(int b) |
写入字符(0~65535):void write(int c) |
写入字节数组:void write(byte[] b) |
写入字符数组:void write(char[] c) |
| 无对应方法 | 写入String:void write(String s) |
Writer是所有字符输出流的超类,它提供的方法主要有:
- 写入一个字符(0~65535):
void write(int c); - 写入字符数组的所有字符:
void write(char[] c); - 写入String表示的所有字符:
void write(String s)。
FileWriter
FileWriter就是向文件中写入字符流的Writer。它的使用方法和FileReader类似:
1 | try (Writer writer = new FileWriter("readme.txt", StandardCharsets.UTF_8)) { |
CharArrayWriter
CharArrayWriter可以在内存中创建一个Writer,它的作用实际上是构造一个缓冲区,可以写入char,最后得到写入的char[]数组,这和ByteArrayOutputStream非常类似:
1 | try (CharArrayWriter writer = new CharArrayWriter()) { |
StringWriter
StringWriter也是一个基于内存的Writer,它和CharArrayWriter类似。实际上,StringWriter在内部维护了一个StringBuffer,并对外提供了Writer接口。
OutputStreamWriter
除了CharArrayWriter和StringWriter外,普通的Writer实际上是基于OutputStream构造的,它接收char,然后在内部自动转换成一个或多个byte,并写入OutputStream。因此,OutputStreamWriter就是一个将任意的OutputStream转换为Writer的转换器:
1 | try (Writer writer = new OutputStreamWriter(new FileOutputStream("readme.txt"), "UTF-8")) { |
上述代码实际上就是FileWriter的一种实现方式。这和上一节的InputStreamReader是一样的。
小结
Writer定义了所有字符输出流的超类:
FileWriter实现了文件字符流输出;CharArrayWriter和StringWriter在内存中模拟一个字符流输出。
使用try (resource)保证Writer正确关闭。
Writer是基于OutputStream构造的,可以通过OutputStreamWriter将OutputStream转换为Writer,转换时需要指定编码。
Files
从Java 7开始,提供了Files和Paths这两个工具类,能极大地方便我们读写文件。
虽然Files和Paths是java.nio包里面的类,但他俩封装了很多读写文件的简单方法,例如,我们要把一个文件的全部内容读取为一个byte[],可以这么写:
1 | byte[] data = Files.readAllBytes(Paths.get("/path/to/file.txt")); |
如果是文本文件,可以把一个文件的全部内容读取为String:
1 | // 默认使用UTF-8编码读取: |
写入文件也非常方便:
1 | // 写入二进制文件: |
此外,Files工具类还有copy()、delete()、exists()、move()等快捷方法操作文件和目录。
最后需要特别注意的是,Files提供的读写方法,受内存限制,只能读写小文件,例如配置文件等,不可一次读入几个G的大文件。读写大型文件仍然要使用文件流,每次只读写一部分文件内容。
小结
对于简单的小文件读写操作,可以使用Files工具类简化代码。
加密与安全
在计算机系统中,什么是加密与安全呢?
我们举个栗子:假设Bob要给Alice发一封邮件,在邮件传送的过程中,黑客可能会窃取到邮件的内容,所以需要防窃听。黑客还可能会篡改邮件的内容,Alice必须有能力识别出邮件有没有被篡改。最后,黑客可能假冒Bob给Alice发邮件,Alice必须有能力识别出伪造的邮件。
所以,应对潜在的安全威胁,需要做到三防:
- 防窃听
- 防篡改
- 防伪造
常用设计模式
详细设计模式见Notes
工厂方法
定义一个用于创建对象的接口,让子类决定实例化哪一个类。
工厂方法是指定义工厂接口和产品接口,但如何创建实际工厂和实际产品被推迟到子类实现,从而使调用方只和抽象工厂与抽象产品打交道。
实际更常用的是更简单的静态工厂方法,它允许工厂内部对创建产品进行优化。
调用方尽量持有接口或抽象类,避免持有具体类型的子类,以便工厂方法能随时切换不同的子类返回,却不影响调用方代码。
原型
用原型实例指定创建对象的种类,并且通过拷贝这些原型创建新的对象。
原型模式,即Prototype,是指创建新对象的时候,根据现有的一个原型来创建。
单例
保证一个类仅有一个实例,并提供一个访问它的全局访问点。
单例模式(Singleton)的目的是为了保证在一个进程中,某个类有且仅有一个实例。
1 | public class Singleton { |
装饰器模式
动态地给一个对象添加一些额外的职责。就增加功能来说,相比生成子类更为灵活。
装饰器(Decorator)模式,是一种在运行期动态给某个对象的实例增加功能的方法。
我们还是举个栗子:假设我们需要渲染一个HTML的文本,但是文本还可以附加一些效果,比如加粗、变斜体、加下划线等。为了实现动态附加效果,可以采用Decorator模式。
顶层接口TextNode,写一个它的实现类SpanNode,实现TextNode的一个抽象类NodeDecorator,并在抽象类中定义一个TextNode字段,再去实现这个NodeDecorator类,就是装饰者模式。
代理模式
代理模式通过封装一个已有接口,并向调用方返回相同的接口类型,能让调用方在不改变任何代码的前提下增强某些功能(例如,鉴权、延迟加载、连接池复用等)。
使用Proxy模式要求调用方持有接口,作为Proxy的类也必须实现相同的接口类型。
策略模式
策略模式是为了允许调用方选择一个算法,从而通过不同策略实现不同的计算结果。
通过扩展策略,不必修改主逻辑,即可获得新策略的结果。
模板方法
定义一个操作中的算法的骨架,而将一些步骤延迟到子类中,使得子类可以不改变一个算法的结构即可重定义该算法的某些特定步骤。
模板方法(Template Method)是一个比较简单的模式。它的主要思想是,定义一个操作的一系列步骤,对于某些暂时确定不下来的步骤,就留给子类去实现好了,这样不同的子类就可以定义出不同的步骤。









