Java常识
任何编程语言,除了一些大的、方向性的概念之外,还会有很多常识性的、往往被忽略的、小的技术细节,Java也不例外。
了解这些技术细节既能提高工作效率,也能在面试中赢得更多的加分。
下面是我认为作为一个Java工程师可能会混淆、遗漏且必须要知晓的部分
常识(仅仅只是部分)。
至于更多的Java技术细节,都涵盖在了它的各个组成部分之中。
数组
操作符
流控
初始化与清理
内部类
接口与抽象类
集合
函数式编程
流式编程
代码校验
异常捕获
I/O
反射
泛型
注解
并发
序列化
数据存储
Java有五个不同地方用来存储数据。
CPU寄存器
:程序员无法干预此处的数据存储,由Java内部实现。栈(Stack)
:这是内存中的一块区域,通过栈指针实现内存的分配与释放。堆(Heap)
:同样位于内存中,所有被创建出来的Java对象都保存在这里面。常量池
:存储所有常量值,非RAM存储
:存储序列化对象及持久化对象,比如Java代码文件就存储在磁盘上。
基本类型
基本类型 | 大小 | 最小值 | 最大值 | 包装类型 | 默认值 |
---|---|---|---|---|---|
boolean | — | — | — | Boolean | false |
char | 16 bits | Unicode 0 | Unicode 216 -1 | Character | \u0000 (null) |
byte | 8 bits | -128 | +127 | Byte | (byte) 0 |
short | 16 bits | - 215 | + 215 -1 | Short | (short) 0 |
int | 32 bits | - 231 | + 231 -1 | Integer | 0 |
long | 64 bits | - 263 | + 263 -1 | Long | 0L |
float | 32 bits | IEEE754 | IEEE754 | Float | 0.0f |
double | 64 bits | IEEE754 | IEEE754 | Double | 0.0d |
void | — | — | — | Void |
作用域
{
int x = 0;
// 第1处。此处仅x变量可用
{
int y = 1;
Person person = new Person();
// 第2处。这里x、y和person变量都可用
}
// 第3处。此处仅x变量可用,变量y和person不在作用域内
}
虽然在第3处无法引用被实例化的person
对象,但它所指向的对象引用仍然还保留在内存的堆中。
已经定义过的变量名称不能重复定义。
{
int x = 0;
{
int x = 1; // 非法定义
}
}
static
如果只想为特定属性(或成员变量)指定一个共享存储空间,而不想创建任何对象,可以使用
static
关键字修饰属性。如果只想执行特定方法,而不想创建任何对象,可以使用
static
关键字修饰方法。
class StaticClass {
private static int i = 0;
private static void test() {
// TODO
}
}
......
StaticClass.i;
StaticClass.test();
通常情况下,必须先实例化,也就是用new
关键字创建一个StaticClass
类的对象实例,才能引用它内部的属性和方法,但有了static
关键字修饰以后,就不需要new
了,可以直接通过类名限定来引用。
static
关键字还可以修饰代码块。
// 第1处
static {
// 第2处
}
// 第3处
之所以这样做,是因为有时候需要在所有的代码被加载之前先给某些变量赋值、加载静态资源、驱动程序或实现某些一次性的操作。
上面的static
会让程序的第2处
在第1处
和第3处
被执行前就先得到执行,并且在程序启动时就执行,且仅执行一次。
- 当
static
用来修饰类的时候,那么该类就被称为嵌套类。
public class OuterClass {
static class NestedClass {
public void showme() {
System.out.println("我是嵌套类");
}
}
}
嵌套类同样可以放在接口之中。
public interface OuterInterface {
void first();
static class NestedClass implements OuterInterface {
@Override
public void first() {
System.out.println("我是嵌套类");
}
}
}
手误陷阱
使用运算符时很容易犯的一个低级错误是这样的。
while(x = y) {
// ...
}
这段代码的本意应该是为了做一个等价性测试==
,也就是x == y
,结果不小心写成了x = y
。不过在Java中,编译器会尝试把小括号()
中int
类型的数据转换为boolean
类型,如果转换不了就会收到编译错误。因此,Java天生就避免了这种低级错误的发生。
另一方面,在过去IDE还不那么流行的时候,那时候的大神们有时候会通过文本编辑器来写代码(不仅仅是Java代码)。所以为了防止这种低级错误,他们往往会这么写。
while(0 == x) {
// ...
}
而不是这么写。
while(x == 0) {
// ...
}
之所以要把0
放在x
之前,是因为常量是无法被赋值的,即使万一将==
误写成=
,0
也不会被x
赋值,而是会抛出赋值异常,以此来避免低级错误的发生。
久而久之,这种习惯也就被保留了下来。在一些早期的C/C++或Java源码中,还能见到这种写法。
不过随着编译器的不断进步和日益高级,这种写法的痕迹已经越来越淡了。
类型提升
在Java的基本数据类型做运算的时候,会自动将精度较低的类型,转换为精度较高的类型。
例如,如果对精度小于int
的基本数据类型(即char
、byte
或short
)执行任何算术运算或位运算操作,这些值会在执行操作之前被提升为int类型,并且结果就是int
类型,除非使用强制类型转换,得到它们原本的数据类型。
也就是说,表达式结果的数据类型是由其参与运算的最大的数据类型决定的。
float
类型和double
类型相乘,结果是double
类型的。int
和long
相加,结果是long
类型的。
控制流程
goto
虽然臭名昭著,但它仍是Java的一个保留关键字,只是未被正式启用。
Java并不支持goto
。然而,却可以通过搭配使用标签
、break
和continue
来实现类似于goto
的效果。
public class LabeledForCirculate {
public static void main(String[] args) {
int i = 0;
outer: // 外层标签
for(; true ;) { // 无限循环
inner: // 内层标签
for(; i < 10; i++) {
System.out.println("i = " + i);
if(i == 2) {
System.out.println("continue");
continue;
}
if(i == 3) {
System.out.println("break");
i++;
break;
}
if(i == 7) {
System.out.println("continue outer");
i++;
continue outer;
}
if(i == 8) {
System.out.println("break outer");
break outer;
}
for(int k = 0; k < 5; k++) {
if(k == 3) {
System.out.println("continue inner");
continue inner;
}
}
}
}
// 在此处无法 break 或 continue 标签
}
}
可以尝试着执行这段代码,看看输出了什么。
即使将for
换成while
也是一样的。
switch
从JDK 12开始,switch
语法支持函数式接口和Lambda表达式
。
老语法。
switch (dayOfWeek) {
case 1:
System.out.println("星期一");
break;
case 2:
System.out.println("星期二");
break;
case 3:
System.out.println("星期三");
break;
case 4:
System.out.println("星期四");
break;
case 5:
System.out.println("星期五");
break;
case 6:
System.out.println("星期六");
break;
default:
System.out.println("星期天");
}
JDK 12新语法。
int dayOfWeek = 2;
switch (dayOfWeek) {
case 1 -> System.out.println("星期一");
case 2 -> System.out.println("星期二");
case 3 -> System.out.println("星期三");
case 4 -> System.out.println("星期四");
case 5 -> System.out.println("星期五");
case 6 -> System.out.println("星期六");
default -> System.out.println("星期日");
}
从JDK 14开始,switch
增加了yield
关键字,它是break
和return
的组合。
JDK 14新语法。
int nums = switch(season){
case "Spring" -> {
System.out.println("spring");
yield 1;
}
case "Summer", "Winter" -> {yield 2;}
case "Fall" -> 3;
default -> -1;
};
System.out.println(nums);
初始化的顺序
总体上,类中变量定义的顺序决定了它们初始化的顺序,即使变量定义散布在方法定义之间也是如此。
class Work {
Work(int id) {
System.out.println("Work " + id);
}
}
class Rocket {
int val = 1;
{
System.out.println("val = " + val);
}
Work work1 = new Work(1);
int val = 1;
System.out.println("val = " + val);
Rocket() {
System.out.println("Rocket()");
work3 = new Work(4);
}
Work work2 = new Work(2);
void fire() {
System.out.println("fire()");
}
Work work3 = new Work(3);
}
public class OrderOfInitialization {
public static void main(String[] args) {
Rocket rocket = new Rocket();
rocket.fire();
}
}
另外一个规则是:静态成员变量的初始化
>(优先于) 非静态成员变量的初始化
>(优先于) 构造函数
。
静态数据的初始化
用一段代码就可以看清楚静态数据是如何初始化的。
class Work {
Work(int id) {
System.out.println("Bowl" + id);
}
void f1(int id) {
System.out.println("f1" + id);
}
}
class Factory {
static Work work1 = new Work(1);
Factory() {
System.out.println("Factory()");
work2.f1(1);
}
void f2(int id) {
System.out.println("f2" + id);
}
static Work work2 = new Work(2);
}
class Cupboard {
Work work3 = new Work(3);
static Work work4 = new Work(4);
Cupboard() {
System.out.println("Cupboard()");
work4.f1(2);
}
void f3(int id) {
System.out.println("f3" + id);
}
static Work work5 = new Work(5);
}
class StaticInitialization {
public static void main(String[] args) {
System.out.println("creating new Cupboard()");
new Cupboard();
System.out.println("creating new Cupboard()");
new Cupboard();
factory.f2(1);
cupboard.f3(1);
}
static Factory factory = new Factory();
static Cupboard cupboard = new Cupboard();
}
Work
类展示了类的创建过程,而Factory
和Cupboard
则在它们的类定义中包含了Work
类的静态数据成员。而在静态数据成员定义之前,Cupboard
类中先定义了一个Work
类的非静态成员变量work3
。
静态初始化只有在必要时刻才会进行(如果不创建
Factory
对象,也不引用Factory.work1
或Factory.work2
,那么静态的Work
类对象work1
和work2
永远不会被创建);只有在第一个
Factory
对象被创建后,它们才会被初始化。此后,静态对象不会再被初始化。
所以,可以稍微概括一下对象的创建过程,假设有一个Person
类。
即使没有显式地使用
static
关键字,构造器实际上也是静态方法。所以,当首次创建Person
类的对象,或是首次访问Person
类的静态方法或属性时,Java解释器必须在类路径中查找定位Person.class
。当加载完
Person.class
后将创建一个Class
对象,此时有关静态初始化的所有动作都会执行。因此,静态初始化只会在首次加载Class
对象时进行。当用
new Person()
创建对象时,首先会在堆上为Person
对象分配足够的存储空间。分配的存储空间首先会被清零,也就是说它会将
Person
对象中的所有基本类型数据设为默认值,引用类型被设为null
。执行所有出现在字段定义处的初始化动作。
执行构造器,这可能会牵涉到很多动作,尤其当涉及继承的时候。
构造器调用顺序
在子类的实例化过程中总会隐式地调用到父类的构造器。初始化会自动按继承结构向上移动,因此每个父类的构造器都会被调用到。
class Meal {
Meal() {
System.out.println("Meal()");
}
}
class Bread {
Bread() {
System.out.println("Bread()");
}
}
class Cheese {
Cheese() {
System.out.println("Cheese()");
}
}
class Lettuce {
Lettuce() {
System.out.println("Lettuce()");
}
}
class Lunch extends Meal {
Lunch() {
System.out.println("Lunch()");
}
}
class PortableLunch extends Lunch {
PortableLunch() {
System.out.println("PortableLunch()");
}
}
public class Sandwich extends PortableLunch {
private Bread b = new Bread();
private Cheese c = new Cheese();
private Lettuce l = new Lettuce();
public Sandwich() {
System.out.println("Sandwich()");
}
public static void main(String[] args) {
new Sandwich();
}
}
从创建Sandwich
对象实例的过程中可以很清楚地看到对象的构造器调用顺序。
接口中的静态方法
Java 8及以后版本允许在接口中添加静态方法,这么做能把接口变成一个通用的工具。
public interface Operations {
void execute();
static void runOps(Operations... ops) {
for (Operations op: ops) {
op.execute();
}
}
static void show(String msg) {
System.out.println(msg);
}
}
class Begin implements Operations {
@Override
public void execute() {
Operations.show("Begin");
}
}
public class Test {
public static void main(String[] args) {
Operations.runOps(new Bing());
}
}
抽象类和接口
特性 | 接口 | 抽象类 |
---|---|---|
组合 | 新类可以组合多个接口 | 只能继承单一抽象类 |
状态 | 不能包含属性(除了静态属性,不支持对象状态) | 可以包含属性,非抽象方法可能引用这些属性 |
默认方法 和 抽象方法 | 不需要在子类中实现默认方法。默认方法可以引用其他接口的方法 | 必须在子类中实现抽象方法 |
构造器 | 没有构造器 | 可以有构造器 |
可见性 | 隐式 public | 可以是 protected 或友元 |
在实际开发中,减量使用接口而非抽象类。
接口字段
接口中的字段都自动是public
、static
和final
的,所以不需要再显式地将属性都声明为static
和final
。
public interface Constant {
int JANUARY = 1;
}
等价于下面的代码。
public interface Constant {
public static final int JANUARY = 1;
}
内部类与内隐类
这是内部类。
public class OuterClass {
class InnerClass {
public void showme() {
System.out.println("我是内部类");
}
}
public static void main(String[] args) {
InnerClass innerClass = new OuterClass().new InnerClass();
innerClass.showme();
}
}
这是内隐类。
class InnerClass {
public void showme() {
System.out.println("我是内隐类");
}
}
public class OuterClass {
public void testInnerClass() {
new InnerClass().showme();
}
public static void main(String[] args) {
new OuterClass().testInnerClass();
}
}
内部类可以无条件地调用任意外部类的任意方法,private
方法也是一样的。
class A {
private void d() {
System.out.println("类A的d()方法");
}
class B {
private void e() {
System.out.println("类B的e()方法");
}
public class C {
void f() {
System.out.println("类C的f()方法");
d();
e();
}
}
}
}
public class G {
public static void main(String[] args) {
A a = new A();
A.B b = a.new B();
A.B.C c = b.new C();
c.f();
}
}
另外,内部类是无法被覆盖
的,因为它和外部类是完全独立的两个类,不存在被覆盖
的问题。
class A {
private B b;
class B {
public B() {
System.out.println("A.B()");
}
}
A() {
System.out.println("New A()");
b = new B();
}
}
public class C extends A {
private B b;
class B {
public B() {
System.out.println("C.B()");
}
}
C() {
System.out.println("New C()");
b = new B();
}
public static void main(String[] args) {
new C();
}
}
从结果可以看到,类A
中的类B
和类C
中的类B
是两个完全不同的类,所以类A
中的类B
并不会被类C
中的类B
给覆盖
掉。
闭包
闭包(closure)是一个可调用的对象,它记录了一些信息,这些信息来自于创建它的作用域。
闭包可以封装行为,将它像一个对象一样进行传递,而且不用担心它会泄露代码。
也就是说,闭包其实就是一个内部类,它可以自由地访问外部类的一切数据,但反过来却不行。
因此,内部类本质上就是一种提供了代码隐藏的机制,而且也能像一个正常的类那样工作。
interface IncrementFunction {
int increment(int a, int b);
}
class Closure {
static int calculate(IncrementFunction function, int a, int b) {
return function.increment(a, b);
}
}
public class OuterClass {
private static int selfNum = 1;
public static void main(String[] args) {
IncrementFunction addClosure = new IncrementFunction() {
int sum = 0;
@Override
public int increment(int a, int b) {
sum += a + b + selfNum;
return sum;
}
};
System.out.println("Closure result: " + calculate(addClosure, 1, 2));
System.out.println("Closure result: " + calculate(addClosure, 3, 4));
}
}
通过内隐类(匿名内部类)IncrementFunction
的行为来实现闭包。
集合中的堆栈
大多数提到集合的内容中只有List
、Map
和Set
,但完整的应该是List
、Map
、Set
和Queue(队列)
。
虽然不常用,但队列类一直都存在于集合中。
public class StackTest {
public static void main(String[] args) {
Deque<String> stack = new ArrayDeque<>();
for(String s : "Who am I".split(" ")) {
stack.push(s);
}
while(!stack.isEmpty()) {
System.out.print(stack.pop() + " ");
}
}
}
柯里化
柯里化(Currying)的名称来自于其发明者之一Haskell Curry。他可能是计算机领域唯一名字被命名重要概念的人(另外就是Haskell 编程语言)。 柯里化的意思是:将一个多参数的函数转换为一系列单参数函数。
public class Curry3Args {
public static void main(String[] args) {
Function<String, Function<String, Function<String, String>>> sum = a -> b -> c -> a + b + c;
Function<String, Function<String, String>> hi = sum.apply("Hip");
Function<String, String> ho = hi.apply("Hop");
System.out.println(ho.apply("Hup"));
}
}
为了实现柯里化,Java巧妙地利用->
将三个Lambda表达式
串在了一起,从而实现了三个参数函数的柯里化。
异常丢失
如果程序中抛出异常,那么应该第一时间处理它。但是如果不小心的话,它也会被吞
掉。
class VeryImportantException extends Exception {
@Override
public String toString() {
return "A very important exception";
}
}
class NotImportantException extends Exception {
@Override
public String toString() {
return "A not important exception";
}
}
public class LoseException {
void important() throws VeryImportantException {
throw new VeryImportantException();
}
void dispose() throws NotImportantException {
throw new NotImportantException();
}
public static void main(String[] args) {
try {
LoseException lose = new LoseException();
try {
lose.important();
} finally {
lose.dispose();
}
} catch(VeryImportantException | NotImportantException e) {
System.out.println(e);
}
}
}
执行上面这段代码,会发现输出的只有A not important exception
,而原本更应该抛出来的A very important exception
却被吞
掉了。
即使我用的是JDK 21版本,依然有这个问题。这应该算是一个缺陷吧,可能在未来的Java版本中会得到修正。
所以,finally
虽然好用,但也要慎用。
try-with-resource
JDK 1.7之后增加了try-with-resource
语法,它确保每个资源在使用结束时被释放。
所谓的资源基本上指的都是流对象。
try (创建流对象语句,如果有多个,使用';'隔开) {
// TODO
} catch (IOException e) {
e.printStackTrace();
}
在try()
中的流对象都实现了自动关闭接口AutoCloseable
。
JDK 1.7之前。
FileWriter writer = null;
try {
writer = new FileWriter("test.txt");
writer.write("test");
} catch (Exception ex) {
ex.printStackTrace();
} finally {
if (writer != null) {
try {
writer.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
JDK 1.7之后。
try(FileWriter writer = new FileWriter("test.txt")) {
writer.write("test");
} catch(Exception ex) {
ex.printStackTrace();
}
相比较于tyr-catch
,Java更推荐使用try-with-resource
。
意外递归
运行下面一段简单的代码,将直接抛出一大串异常。
public class AccidentRecursion {
@Override
public String toString() {
return "AccidentRecursion: " + this + "\n";
}
public static void main(String[] args) {
Stream.generate(AccidentRecursion::new).forEach(System.out::println);
}
}
因为当字符串"AccidentRecursion: "
碰到+
时,会发生操作符重载,将+
后面的对象也转换为字符串。
但+
后面的是this
关键字,所以编译器就尝试将this
转换为字符串。怎么转换呢?当然是调用对象的toString()
方法了,于是就发生了递归调用——直到耗尽内存空间,堆栈溢出,发生异常。
所以,在toString()
方法中需要小心应对,不然一不小心就会陷入死循环。
Optional
虽然Optional
是Java 8为了支持流式编程引入的一个新类,但它可以当作普通的工具类来用,尤其是越靠近数据的地方越有用
。
例如以大多数网站中都会有的用户(User
)实体类为例,可以看看Optional
是怎么起作用的。
class User {
public final Optional<String> first;
public final Optional<String> last;
public final Optional<String> address;
// ......
public final Boolean empty;
User(String first, String last, String address) {
this.first = Optional.ofNullable(first);
this.last = Optional.ofNullable(last);
this.address = Optional.ofNullable(address);
empty = !this.first.isPresent()
&& !this.last.isPresent()
&& !this.address.isPresent();
}
User(String first, String last) {
this(first, last, null);
}
User(String last) {
this(null, last, null);
}
User() {
this(null, null, null);
}
@Override
public String toString() {
if (empty) {
return "<Empty>";
}
return (first.orElse("") + " " + last.orElse("") + " " + address.orElse("")).trim();
}
public static void main(String[] args) {
System.out.println(new User());
System.out.println(new User("Xiang"));
System.out.println(new User("Xiang", "Wang"));
System.out.println(new User("Xiang", "Wang", "China, Hubei, Wuhan"));
}
}
其实这也是个习惯问题,即使不用Optional
,也可以通过一堆if-else
达到同样的效果,就看个人怎么选择了。
并行流
如果在流式编程中要使用.parallelStream()
或.parallel()
这样的并行流方法,那么最好使用线程安全的容器或原子类。
public class ParallelStreamSecurity {
static class IntGenerator implements Supplier<Integer> {
private AtomicInteger current = new AtomicInteger();
@Override
public Integer get() {
return current.getAndIncrement();
}
}
public static void main(String[] args) throws Exception {
List<Integer> x = Stream.generate(new IntGenerator()).limit(10)
.parallel()
.collect(Collectors.toList());
System.out.println(x);
}
}
可以尝试,如果在上面的代码中不使用AtomicInteger
的话,会得到什么结果。
感谢支持
更多内容,请移步《超级个体》。