JAVA-反射

分類 编程语言, Java

什么是反射?

反射就是Reflection,Java的反射是指程序在运行期可以拿到一个对象的所有信息。

正常情况下,如果我们要调用一个对象的方法,或者访问一个对象的字段,通常会传入对象实例:

1
2
3
4
5
6
7
8
// Main.java
import com.itranswarp.learnjava.Person;

public class Main {
String getFullName(Person p) {
return p.getFirstName() + " " + p.getLastName();
}
}

但是,如果不能获得Person类,只有一个Object实例,比如这样:

1
2
3
String getFullName(Object obj) {
return ???
}

怎么办?有童鞋会说:强制转型啊!

1
2
3
4
String getFullName(Object obj) {
Person p = (Person) obj;
return p.getFirstName() + " " + p.getLastName();
}

强制转型的时候,你会发现一个问题:编译上面的代码,仍然需要引用Person类。不然,去掉import语句,你看能不能编译通过?

所以,反射是为了解决在运行期,对某个实例一无所知的情况下,如何调用其方法。

reflection

除了int等基本类型外,Java的其他类型全部都是class(包括interface)。例如:

  • String
  • Object
  • Runnable
  • Exception

仔细思考,我们可以得出结论:class(包括interface)的本质是数据类型(Type)。无继承关系的数据类型无法赋值:

1
2
Number n = new Double(123.456); // OK
String s = new Double(123.456); // compile error!

class是由JVM在执行过程中动态加载的。JVM在第一次读取到一种class类型时,将其加载进内存。

每加载一种class,JVM就为其创建一个Class类型的实例,并关联起来。注意:这里的Class类型是一个名叫Classclass。它长这样:

1
2
3
public final class Class {
private 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实例都指向一个数据类型(classinterface):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
┌───────────────────────────┐
│ Class Instance │────▶ String
├───────────────────────────┤
│name = "java.lang.String" │
└───────────────────────────┘
┌───────────────────────────┐
│ Class Instance │────▶ Random
├───────────────────────────┤
│name = "java.util.Random" │
└───────────────────────────┘
┌───────────────────────────┐
│ Class Instance │────▶ Runnable
├───────────────────────────┤
│name = "java.lang.Runnable"│
└───────────────────────────┘

一个Class实例包含了该class的所有完整信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
┌───────────────────────────┐
│ Class Instance │────▶ String
├───────────────────────────┤
│name = "java.lang.String" │
├───────────────────────────┤
│package = "java.lang" │
├───────────────────────────┤
│super = "java.lang.Object" │
├───────────────────────────┤
│interface = CharSequence...│
├───────────────────────────┤
│field = value[],hash,... │
├───────────────────────────┤
│method = indexOf()... │
└───────────────────────────┘

由于JVM为每个加载的class创建了对应的Class实例,并在实例中保存了该class的所有信息,包括类名、包名、父类、实现的接口、所有方法、字段等,因此,如果获取了某个Class实例,我们就可以通过这个Class实例获取到该实例对应的class的所有信息。

这种通过Class实例获取class信息的方法称为反射(Reflection)。

如何获取一个classClass实例?有三个方法:

方法一:直接通过一个class的静态变量class获取:

1
Class cls = String.class;

方法二:如果我们有一个实例变量,可以通过该实例变量提供的getClass()方法获取:

1
2
String s = "Hello";
Class cls = s.getClass();

方法三:如果知道一个class的完整类名,可以通过静态方法Class.forName()获取:

1
Class cls = Class.forName("java.lang.String");

因为Class实例在JVM中是唯一的,所以,上述方法获取的Class实例是同一个实例。可以用==比较两个Class实例:

1
2
3
4
5
6
Class cls1 = String.class;

String s = "Hello";
Class cls2 = s.getClass();

boolean sameClass = cls1 == cls2; // true

注意一下Class实例比较和instanceof的差别:

1
2
3
4
5
6
7
8
9
Integer n = new Integer(123);

boolean b1 = n instanceof Integer; // true,因为n是Integer类型
boolean b2 = n instanceof Number; // true,因为n是Number类型的子类

boolean b3 = n.getClass() == Integer.class; // true,因为n.getClass()返回Integer.class
Class c1 = n.getClass();
Class c2 = Number.class;
boolean b4 = c1 == c2; // false,因为Integer.class != Number.class

instanceof不但匹配指定类型,还匹配指定类型的子类。而用==判断class实例可以精确地判断数据类型,但不能作子类型比较。

通常情况下,我们应该用instanceof判断数据类型,因为面向抽象编程的时候,我们不关心具体的子类型。只有在需要精确判断一个类型是不是某个class的时候,我们才使用==判断class实例。

因为反射的目的是为了获得某个实例的信息。因此,当我们拿到某个Object实例时,我们可以通过反射获取该Objectclass信息:

1
2
3
void printObjectInfo(Object obj) {
Class cls = obj.getClass();
}

要从Class实例获取获取的基本信息,参考下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// reflection
public class Main {
public static void main(String[] args) {
printClassInfo("".getClass());
printClassInfo(Runnable.class);
printClassInfo(java.time.Month.class);
printClassInfo(String[].class);
printClassInfo(int.class);
}

static void printClassInfo(Class cls) {
System.out.println("Class name: " + cls.getName());
System.out.println("Simple name: " + cls.getSimpleName());
if (cls.getPackage() != null) {
System.out.println("Package name: " + cls.getPackage().getName());
}
System.out.println("is interface: " + cls.isInterface());
System.out.println("is enum: " + cls.isEnum());
System.out.println("is array: " + cls.isArray());
System.out.println("is primitive: " + cls.isPrimitive());
}
}

注意到数组(例如String[])也是一种类,而且不同于String.class,它的类名是[Ljava.lang.String;。此外,JVM为每一种基本类型如int也创建了Class实例,通过int.class访问。

如果获取到了一个Class实例,我们就可以通过该Class实例来创建对应类型的实例:

1
2
3
4
// 获取String的Class实例:
Class cls = String.class;
// 创建一个String实例:
String s = (String) cls.newInstance();

上述代码相当于new String()。通过Class.newInstance()可以创建类实例,它的局限是:只能调用public的无参数构造方法。带参数的构造方法,或者非public的构造方法都无法通过Class.newInstance()被调用。

动态加载

JVM在执行Java程序的时候,并不是一次性把所有用到的class全部加载到内存,而是第一次需要用到class时才加载。例如:

1
2
3
4
5
6
7
8
9
10
11
12
// Main.java
public class Main {
public static void main(String[] args) {
if (args.length > 0) {
create(args[0]);
}
}

static void create(String name) {
Person p = new Person(name);
}
}

当执行Main.java时,由于用到了Main,因此,JVM首先会把Main.class加载到内存。然而,并不会加载Person.class,除非程序执行到create()方法,JVM发现需要加载Person类时,才会首次加载Person.class。如果没有执行create()方法,那么Person.class根本就不会被加载。

这就是JVM动态加载class的特性。

动态加载class的特性对于Java程序非常重要。利用JVM动态加载class的特性,我们才能在运行期根据条件加载不同的实现类。例如,Commons Logging总是优先使用Log4j,只有当Log4j不存在时,才使用JDK的logging。利用JVM动态加载特性,大致的实现代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// Commons Logging优先使用Log4j:
LogFactory factory = null;
if (isClassPresent("org.apache.logging.log4j.Logger")) {
factory = createLog4j();
} else {
factory = createJdkLog();
}

boolean isClassPresent(String name) {
try {
Class.forName(name);
return true;
} catch (Exception e) {
return false;
}
}

这就是为什么我们只需要把Log4j的jar包放到classpath中,Commons Logging就会自动使用Log4j的原因。

小结

JVM为每个加载的classinterface创建了对应的Class实例来保存classinterface的所有信息;

获取一个class对应的Class实例后,就可以获取该class的所有信息;

通过Class实例获取class信息的方法称为反射(Reflection);

JVM总是动态加载class,可以在运行期根据条件来控制加载class。

对任意的一个Object实例,只要我们获取了它的Class,就可以获取它的一切信息。

我们先看看如何通过Class实例获取字段信息。Class类提供了以下几个方法来获取字段:

  • Field getField(name):根据字段名获取某个public的field(包括父类)
  • Field getDeclaredField(name):根据字段名获取当前类的某个field(不包括父类)
  • Field[] getFields():获取所有public的field(包括父类)
  • Field[] getDeclaredFields():获取当前类的所有field(不包括父类)

我们来看一下示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// reflection
public class Main {
public static void main(String[] args) throws Exception {
Class stdClass = Student.class;
// 获取public字段"score":
System.out.println(stdClass.getField("score"));
// 获取继承的public字段"name":
System.out.println(stdClass.getField("name"));
// 获取private字段"grade":
System.out.println(stdClass.getDeclaredField("grade"));
}
}

class Student extends Person {
public int score;
private int grade;
}

class Person {
public String name;
}

上述代码首先获取StudentClass实例,然后,分别获取public字段、继承的public字段以及private字段,打印出的Field类似:

1
2
3
public int Student.score
public java.lang.String Person.name
private int Student.grade

一个Field对象包含了一个字段的所有信息:

  • getName():返回字段名称,例如,"name"
  • getType():返回字段类型,也是一个Class实例,例如,String.class
  • getModifiers():返回字段的修饰符,它是一个int,不同的bit表示不同的含义。

String类的value字段为例,它的定义是:

1
2
3
public final class String {
private final byte[] value;
}

我们用反射获取该字段的信息,代码如下:

1
2
3
4
5
6
7
8
9
Field f = String.class.getDeclaredField("value");
f.getName(); // "value"
f.getType(); // class [B 表示byte[]类型
int m = f.getModifiers();
Modifier.isFinal(m); // true
Modifier.isPublic(m); // false
Modifier.isProtected(m); // false
Modifier.isPrivate(m); // true
Modifier.isStatic(m); // false

获取字段值

利用反射拿到字段的一个Field实例只是第一步,我们还可以拿到一个实例对应的该字段的值。

例如,对于一个Person实例,我们可以先拿到name字段对应的Field,再获取这个实例的name字段的值:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// reflection
import java.lang.reflect.Field;
public class Main {

public static void main(String[] args) throws Exception {
Object p = new Person("Xiao Ming");
Class c = p.getClass();
Field f = c.getDeclaredField("name");
Object value = f.get(p);
System.out.println(value); // "Xiao Ming"
}
}

class Person {
private String name;

public Person(String name) {
this.name = name;
}
}

上述代码先获取Class实例,再获取Field实例,然后,用Field.get(Object)获取指定实例的指定字段的值。

运行代码,如果不出意外,会得到一个IllegalAccessException,这是因为name被定义为一个private字段,正常情况下,Main类无法访问Person类的private字段。要修复错误,可以将private改为public,或者,在调用Object value = f.get(p);前,先写一句:

1
f.setAccessible(true);

调用Field.setAccessible(true)的意思是,别管这个字段是不是public,一律允许访问。

可以试着加上上述语句,再运行代码,就可以打印出private字段的值。

有童鞋会问:如果使用反射可以获取private字段的值,那么类的封装还有什么意义?

答案是正常情况下,我们总是通过p.name来访问Personname字段,编译器会根据publicprotectedprivate决定是否允许访问字段,这样就达到了数据封装的目的。

而反射是一种非常规的用法,使用反射,首先代码非常繁琐,其次,它更多地是给工具或者底层框架来使用,目的是在不知道目标实例任何信息的情况下,获取特定字段的值。

此外,setAccessible(true)可能会失败。如果JVM运行期存在SecurityManager,那么它会根据规则进行检查,有可能阻止setAccessible(true)。例如,某个SecurityManager可能不允许对javajavax开头的package的类调用setAccessible(true),这样可以保证JVM核心库的安全。

设置字段值

通过Field实例既然可以获取到指定实例的字段值,自然也可以设置字段的值。

设置字段值是通过Field.set(Object, Object)实现的,其中第一个Object参数是指定的实例,第二个Object参数是待修改的值。示例代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// reflection
import java.lang.reflect.Field;

public class Main {

public static void main(String[] args) throws Exception {
Person p = new Person("Xiao Ming");
System.out.println(p.getName()); // "Xiao Ming"
Class c = p.getClass();
Field f = c.getDeclaredField("name");
f.setAccessible(true);
f.set(p, "Xiao Hong");
System.out.println(p.getName()); // "Xiao Hong"
}
}

class Person {
private String name;

public Person(String name) {
this.name = name;
}

public String getName() {
return this.name;
}
}

运行上述代码,打印的name字段从Xiao Ming变成了Xiao Hong,说明通过反射可以直接修改字段的值。

同样的,修改非public字段,需要首先调用setAccessible(true)

练习

利用反射给字段赋值。

下载练习

小结

Java的反射API提供的Field类封装了字段的所有信息:

通过Class实例的方法可以获取Field实例:getField()getFields()getDeclaredField()getDeclaredFields()

通过Field实例可以获取字段信息:getName()getType()getModifiers()

通过Field实例可以读取或设置某个对象的字段,如果存在访问限制,要首先调用setAccessible(true)来访问非public字段。

通过反射读写字段是一种非常规方法,它会破坏对象的封装。

我们已经能通过Class实例获取所有Field对象,同样的,可以通过Class实例获取所有Method信息。Class类提供了以下几个方法来获取Method

  • Method getMethod(name, Class...):获取某个publicMethod(包括父类)
  • Method getDeclaredMethod(name, Class...):获取当前类的某个Method(不包括父类)
  • Method[] getMethods():获取所有publicMethod(包括父类)
  • Method[] getDeclaredMethods():获取当前类的所有Method(不包括父类)

我们来看一下示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// reflection
public class Main {
public static void main(String[] args) throws Exception {
Class stdClass = Student.class;
// 获取public方法getScore,参数为String:
System.out.println(stdClass.getMethod("getScore", String.class));
// 获取继承的public方法getName,无参数:
System.out.println(stdClass.getMethod("getName"));
// 获取private方法getGrade,参数为int:
System.out.println(stdClass.getDeclaredMethod("getGrade", int.class));
}
}

class Student extends Person {
public int getScore(String type) {
return 99;
}
private int getGrade(int year) {
return 1;
}
}

class Person {
public String getName() {
return "Person";
}
}

上述代码首先获取StudentClass实例,然后,分别获取public方法、继承的public方法以及private方法,打印出的Method类似:

1
2
3
public int Student.getScore(java.lang.String)
public java.lang.String Person.getName()
private int Student.getGrade(int)

一个Method对象包含一个方法的所有信息:

  • getName():返回方法名称,例如:"getScore"
  • getReturnType():返回方法返回值类型,也是一个Class实例,例如:String.class
  • getParameterTypes():返回方法的参数类型,是一个Class数组,例如:{String.class, int.class}
  • getModifiers():返回方法的修饰符,它是一个int,不同的bit表示不同的含义。

调用方法

当我们获取到一个Method对象时,就可以对它进行调用。我们以下面的代码为例:

1
2
String s = "Hello world";
String r = s.substring(6); // "world"

如果用反射来调用substring方法,需要以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// reflection
import java.lang.reflect.Method;

public class Main {
public static void main(String[] args) throws Exception {
// String对象:
String s = "Hello world";
// 获取String substring(int)方法,参数为int:
Method m = String.class.getMethod("substring", int.class);
// 在s对象上调用该方法并获取结果:
String r = (String) m.invoke(s, 6);
// 打印调用结果:
System.out.println(r); // "world"
}
}

注意到substring()有两个重载方法,我们获取的是String substring(int)这个方法。思考一下如何获取String substring(int, int)方法。

Method实例调用invoke就相当于调用该方法,invoke的第一个参数是对象实例,即在哪个实例上调用该方法,后面的可变参数要与方法参数一致,否则将报错。

调用静态方法

如果获取到的Method表示一个静态方法,调用静态方法时,由于无需指定实例对象,所以invoke方法传入的第一个参数永远为null。我们以Integer.parseInt(String)为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
// reflection
import java.lang.reflect.Method;

public class Main {
public static void main(String[] args) throws Exception {
// 获取Integer.parseInt(String)方法,参数为String:
Method m = Integer.class.getMethod("parseInt", String.class);
// 调用该静态方法并获取结果:
Integer n = (Integer) m.invoke(null, "12345");
// 打印调用结果:
System.out.println(n);
}
}

调用非public方法

和Field类似,对于非public方法,我们虽然可以通过Class.getDeclaredMethod()获取该方法实例,但直接对其调用将得到一个IllegalAccessException。为了调用非public方法,我们通过Method.setAccessible(true)允许其调用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// reflection
import java.lang.reflect.Method;

public class Main {
public static void main(String[] args) throws Exception {
Person p = new Person();
Method m = p.getClass().getDeclaredMethod("setName", String.class);
m.setAccessible(true);
m.invoke(p, "Bob");
System.out.println(p.name);
}
}

class Person {
String name;
private void setName(String name) {
this.name = name;
}
}

此外,setAccessible(true)可能会失败。如果JVM运行期存在SecurityManager,那么它会根据规则进行检查,有可能阻止setAccessible(true)。例如,某个SecurityManager可能不允许对javajavax开头的package的类调用setAccessible(true),这样可以保证JVM核心库的安全。

多态

我们来考察这样一种情况:一个Person类定义了hello()方法,并且它的子类Student也覆写了hello()方法,那么,从Person.class获取的Method,作用于Student实例时,调用的方法到底是哪个?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// reflection
import java.lang.reflect.Method;

public class Main {
public static void main(String[] args) throws Exception {
// 获取Person的hello方法:
Method h = Person.class.getMethod("hello");
// 对Student实例调用hello方法:
h.invoke(new Student());
}
}

class Person {
public void hello() {
System.out.println("Person:hello");
}
}

class Student extends Person {
public void hello() {
System.out.println("Student:hello");
}
}

运行上述代码,发现打印出的是Student:hello,因此,使用反射调用方法时,仍然遵循多态原则:即总是调用实际类型的覆写方法(如果存在)。上述的反射代码:

1
2
Method m = Person.class.getMethod("hello");
m.invoke(new Student());

实际上相当于:

1
2
Person p = new Student();
p.hello();

练习

利用反射调用方法。

下载练习

小结

Java的反射API提供的Method对象封装了方法的所有信息:

通过Class实例的方法可以获取Method实例:getMethod()getMethods()getDeclaredMethod()getDeclaredMethods()

通过Method实例可以获取方法信息:getName()getReturnType()getParameterTypes()getModifiers()

通过Method实例可以调用某个对象的方法:Object invoke(Object instance, Object... parameters)

通过设置setAccessible(true)来访问非public方法;

通过反射调用方法时,仍然遵循多态原则。

调用构造方法

我们通常使用new操作符创建新的实例:

1
Person p = new Person();

如果通过反射来创建新的实例,可以调用Class提供的newInstance()方法:

1
Person p = Person.class.newInstance();

调用Class.newInstance()的局限是,它只能调用该类的public无参数构造方法。如果构造方法带有参数,或者不是public,就无法直接通过Class.newInstance()来调用。

为了调用任意的构造方法,Java的反射API提供了Constructor对象,它包含一个构造方法的所有信息,可以创建一个实例。Constructor对象和Method非常类似,不同之处仅在于它是一个构造方法,并且,调用结果总是返回实例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import java.lang.reflect.Constructor;

public class Main {
public static void main(String[] args) throws Exception {
// 获取构造方法Integer(int):
Constructor cons1 = Integer.class.getConstructor(int.class);
// 调用构造方法:
Integer n1 = (Integer) cons1.newInstance(123);
System.out.println(n1);

// 获取构造方法Integer(String)
Constructor cons2 = Integer.class.getConstructor(String.class);
Integer n2 = (Integer) cons2.newInstance("456");
System.out.println(n2);
}
}

通过Class实例获取Constructor的方法如下:

  • getConstructor(Class...):获取某个publicConstructor
  • getDeclaredConstructor(Class...):获取某个Constructor
  • getConstructors():获取所有publicConstructor
  • getDeclaredConstructors():获取所有Constructor

注意Constructor总是当前类定义的构造方法,和父类无关,因此不存在多态的问题。

调用非publicConstructor时,必须首先通过setAccessible(true)设置允许访问。setAccessible(true)可能会失败。

小结

Constructor对象封装了构造方法的所有信息;

通过Class实例的方法可以获取Constructor实例:getConstructor()getConstructors()getDeclaredConstructor()getDeclaredConstructors()

通过Constructor实例可以创建一个实例对象:newInstance(Object... parameters); 通过设置setAccessible(true)来访问非public构造方法。



当我们获取到某个Class对象时,实际上就获取到了一个类的类型:

1
Class cls = String.class; // 获取到String的Class

还可以用实例的getClass()方法获取:

1
2
String s = "";
Class cls = s.getClass(); // s是String,因此获取到String的Class

最后一种获取Class的方法是通过Class.forName(""),传入Class的完整类名获取:

1
Class s = Class.forName("java.lang.String");

这三种方式获取的Class实例都是同一个实例,因为JVM对每个加载的Class只创建一个Class实例来表示它的类型。

获取父类的Class

有了Class实例,我们还可以获取它的父类的Class

1
2
3
4
5
6
7
8
9
10
11
// reflection
public class Main {
public static void main(String[] args) throws Exception {
Class i = Integer.class;
Class n = i.getSuperclass();
System.out.println(n);
Class o = n.getSuperclass();
System.out.println(o);
System.out.println(o.getSuperclass());
}
}

运行上述代码,可以看到,Integer的父类类型是NumberNumber的父类是ObjectObject的父类是null。除Object外,其他任何非interfaceClass都必定存在一个父类类型。

获取interface

由于一个类可能实现一个或多个接口,通过Class我们就可以查询到实现的接口类型。例如,查询Integer实现的接口:

1
2
3
4
5
6
7
8
9
10
11
12
// reflection
import java.lang.reflect.Method;

public class Main {
public static void main(String[] args) throws Exception {
Class s = Integer.class;
Class[] is = s.getInterfaces();
for (Class i : is) {
System.out.println(i);
}
}
}

运行上述代码可知,Integer实现的接口有:

  • java.lang.Comparable
  • java.lang.constant.Constable
  • java.lang.constant.ConstantDesc

要特别注意:getInterfaces()只返回当前类直接实现的接口类型,并不包括其父类实现的接口类型:

1
2
3
4
5
6
7
8
9
10
11
12
// reflection
import java.lang.reflect.Method;

public class Main {
public static void main(String[] args) throws Exception {
Class s = Integer.class.getSuperclass();
Class[] is = s.getInterfaces();
for (Class i : is) {
System.out.println(i);
}
}
}

Integer的父类是NumberNumber实现的接口是java.io.Serializable

此外,对所有interfaceClass调用getSuperclass()返回的是null,获取接口的父接口要用getInterfaces()

1
2
System.out.println(java.io.DataInputStream.class.getSuperclass()); // java.io.FilterInputStream,因为DataInputStream继承自FilterInputStream
System.out.println(java.io.Closeable.class.getSuperclass()); // null,对接口调用getSuperclass()总是返回null,获取接口的父接口要用getInterfaces()

如果一个类没有实现任何interface,那么getInterfaces()返回空数组。

继承关系

当我们判断一个实例是否是某个类型时,正常情况下,使用instanceof操作符:

1
2
3
4
5
Object n = Integer.valueOf(123);
boolean isDouble = n instanceof Double; // false
boolean isInteger = n instanceof Integer; // true
boolean isNumber = n instanceof Number; // true
boolean isSerializable = n instanceof java.io.Serializable; // true

如果是两个Class实例,要判断一个向上转型是否成立,可以调用isAssignableFrom()

1
2
3
4
5
6
7
8
// Integer i = ?
Integer.class.isAssignableFrom(Integer.class); // true,因为Integer可以赋值给Integer
// Number n = ?
Number.class.isAssignableFrom(Integer.class); // true,因为Integer可以赋值给Number
// Object o = ?
Object.class.isAssignableFrom(Integer.class); // true,因为Integer可以赋值给Object
// Integer i = ?
Integer.class.isAssignableFrom(Number.class); // false,因为Number不能赋值给Integer

小结

通过Class对象可以获取继承关系:

  • Class getSuperclass():获取父类类型;
  • Class[] getInterfaces():获取当前类实现的所有接口。

通过Class对象的isAssignableFrom()方法可以判断一个向上转型是否可以实现。

动态代理

我们来比较Java的classinterface的区别:

  • 可以实例化class(非abstract);
  • 不能实例化interface

所有interface类型的变量总是通过某个实例向上转型并赋值给接口类型变量的:

1
CharSequence cs = new StringBuilder();

有没有可能不编写实现类,直接在运行期创建某个interface的实例呢?

这是可能的,因为Java标准库提供了一种动态代理(Dynamic Proxy)的机制:可以在运行期动态创建某个interface的实例。

什么叫运行期动态创建?听起来好像很复杂。所谓动态代理,是和静态相对应的。我们来看静态代码怎么写:

定义接口:

1
2
3
public interface Hello {
void morning(String name);
}

编写实现类:

1
2
3
4
5
public class HelloWorld implements Hello {
public void morning(String name) {
System.out.println("Good morning, " + name);
}
}

创建实例,转型为接口并调用:

1
2
Hello hello = new HelloWorld();
hello.morning("Bob");

这种方式就是我们通常编写代码的方式。

还有一种方式是动态代码,我们仍然先定义了接口Hello,但是我们并不去编写实现类,而是直接通过JDK提供的一个Proxy.newProxyInstance()创建了一个Hello接口对象。这种没有实现类但是在运行期动态创建了一个接口对象的方式,我们称为动态代码。JDK提供的动态创建接口对象的方式,就叫动态代理。

一个最简单的动态代理实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;

public class Main {
public static void main(String[] args) {
InvocationHandler handler = new InvocationHandler() {
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
System.out.println(method);
if (method.getName().equals("morning")) {
System.out.println("Good morning, " + args[0]);
}
return null;
}
};
Hello hello = (Hello) Proxy.newProxyInstance(
Hello.class.getClassLoader(), // 传入ClassLoader
new Class[] { Hello.class }, // 传入要实现的接口
handler); // 传入处理调用方法的InvocationHandler
hello.morning("Bob");
}
}

interface Hello {
void morning(String name);
}

在运行期动态创建一个interface实例的方法如下:

  1. 定义一个InvocationHandler实例,它负责实现接口的方法调用;
  2. 通过Proxy.newProxyInstance()创建interface实例,它需要3个参数:
    1. 使用的ClassLoader,通常就是接口类的ClassLoader
    2. 需要实现的接口数组,至少需要传入一个接口进去;
    3. 用来处理接口方法调用的InvocationHandler实例。
  3. 将返回的Object强制转型为接口。

动态代理实际上是JVM在运行期动态创建class字节码并加载的过程,它并没有什么黑魔法,把上面的动态代理改写为静态实现类大概长这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
public class HelloDynamicProxy implements Hello {
InvocationHandler handler;
public HelloDynamicProxy(InvocationHandler handler) {
this.handler = handler;
}
public void morning(String name) {
handler.invoke(
this,
Hello.class.getMethod("morning", String.class),
new Object[] { name }
);
}
}

其实就是JVM帮我们自动编写了一个上述类(不需要源码,可以直接生成字节码),并不存在可以直接实例化接口的黑魔法。

小结

Java标准库提供了动态代理功能,允许在运行期动态创建一个接口的实例;

动态代理是通过Proxy创建代理对象,然后将接口方法“代理”给InvocationHandler完成的。



留言與分享

JAVA-异常处理

分類 编程语言, Java

在计算机程序运行的过程中,总是会出现各种各样的错误。

有一些错误是用户造成的,比如,希望用户输入一个int类型的年龄,但是用户的输入是abc

1
2
3
// 假设用户输入了abc:
String s = "abc";
int n = Integer.parseInt(s); // NumberFormatException!

程序想要读写某个文件的内容,但是用户已经把它删除了:

1
2
// 用户删除了该文件:
String t = readFile("C:\\abc.txt"); // FileNotFoundException!

还有一些错误是随机出现,并且永远不可能避免的。比如:

  • 网络突然断了,连接不到远程服务器;
  • 内存耗尽,程序崩溃了;
  • 用户点“打印”,但根本没有打印机;
  • ……

所以,一个健壮的程序必须处理各种各样的错误。

所谓错误,就是程序调用某个函数的时候,如果失败了,就表示出错。

调用方如何获知调用失败的信息?有两种方法:

方法一:约定返回错误码。

例如,处理一个文件,如果返回0,表示成功,返回其他整数,表示约定的错误码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
int code = processFile("C:\\test.txt");
if (code == 0) {
// ok:
} else {
// error:
switch (code) {
case 1:
// file not found:
case 2:
// no read permission:
default:
// unknown error:
}
}

因为使用int类型的错误码,想要处理就非常麻烦。这种方式常见于底层C函数。

方法二:在语言层面上提供一个异常处理机制。

Java内置了一套异常处理机制,总是使用异常来表示错误。

异常是一种class,因此它本身带有类型信息。异常可以在任何地方抛出,但只需要在上层捕获,这样就和方法调用分离了:

1
2
3
4
5
6
7
8
9
10
11
12
try {
String s = processFile(“C:\\test.txt”);
// ok:
} catch (FileNotFoundException e) {
// file not found:
} catch (SecurityException e) {
// no read permission:
} catch (IOException e) {
// io error:
} catch (Exception e) {
// other error:
}

因为Java的异常是class,它的继承关系如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
                     ┌───────────┐
│ Object │
└───────────┘


┌───────────┐
│ Throwable │
└───────────┘

┌─────────┴─────────┐
│ │
┌───────────┐ ┌───────────┐
│ Error │ │ Exception │
└───────────┘ └───────────┘
▲ ▲
┌───────┘ ┌────┴──────────┐
│ │ │
┌─────────────────┐ ┌─────────────────┐┌───────────┐
│OutOfMemoryError │... │RuntimeException ││IOException│...
└─────────────────┘ └─────────────────┘└───────────┘

┌───────────┴─────────────┐
│ │
┌─────────────────────┐ ┌─────────────────────────┐
│NullPointerException │ │IllegalArgumentException │...
└─────────────────────┘ └─────────────────────────┘

从继承关系可知:Throwable是异常体系的根,它继承自ObjectThrowable有两个体系:ErrorExceptionError表示严重的错误,程序对此一般无能为力,例如:

  • OutOfMemoryError:内存耗尽
  • NoClassDefFoundError:无法加载某个Class
  • StackOverflowError:栈溢出

Exception则是运行时的错误,它可以被捕获并处理。

某些异常是应用程序逻辑处理的一部分,应该捕获并处理。例如:

  • NumberFormatException:数值类型的格式错误
  • FileNotFoundException:未找到文件
  • SocketException:读取网络失败

还有一些异常是程序逻辑编写不对造成的,应该修复程序本身。例如:

  • NullPointerException:对某个null的对象调用方法或字段
  • IndexOutOfBoundsException:数组索引越界

Exception又分为两大类:

  1. RuntimeException以及它的子类;
  2. RuntimeException(包括IOExceptionReflectiveOperationException等等)

Java规定:

  • 必须捕获的异常,包括Exception及其子类,但不包括RuntimeException及其子类,这种类型的异常称为Checked Exception。
  • 不需要捕获的异常,包括Error及其子类,RuntimeException及其子类。

注意

编译器对RuntimeException及其子类不做强制捕获要求,不是指应用程序本身不应该捕获并处理RuntimeException。是否需要捕获,具体问题具体分析。

捕获异常

捕获异常使用try...catch语句,把可能发生异常的代码放到try {...}中,然后使用catch捕获对应的Exception及其子类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// try...catch
import java.io.UnsupportedEncodingException;
import java.util.Arrays;

public class Main {
public static void main(String[] args) {
byte[] bs = toGBK("中文");
System.out.println(Arrays.toString(bs));
}

static byte[] toGBK(String s) {
try {
// 用指定编码转换String为byte[]:
return s.getBytes("GBK");
} catch (UnsupportedEncodingException e) {
// 如果系统不支持GBK编码,会捕获到UnsupportedEncodingException:
System.out.println(e); // 打印异常信息
return s.getBytes(); // 尝试使用默认编码
}
}
}

如果我们不捕获UnsupportedEncodingException,会出现编译失败的问题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// try...catch
import java.io.UnsupportedEncodingException;
import java.util.Arrays;

public class Main {
public static void main(String[] args) {
byte[] bs = toGBK("中文");
System.out.println(Arrays.toString(bs));
}

static byte[] toGBK(String s) {
return s.getBytes("GBK");
}
}

编译器会报错,错误信息类似:unreported exception UnsupportedEncodingException; must be caught or declared to be thrown,并且准确地指出需要捕获的语句是return s.getBytes("GBK");。意思是说,像UnsupportedEncodingException这样的Checked Exception,必须被捕获。

这是因为String.getBytes(String)方法定义是:

1
2
3
public byte[] getBytes(String charsetName) throws UnsupportedEncodingException {
...
}

在方法定义的时候,使用throws Xxx表示该方法可能抛出的异常类型。调用方在调用的时候,必须强制捕获这些异常,否则编译器会报错。

toGBK()方法中,因为调用了String.getBytes(String)方法,就必须捕获UnsupportedEncodingException。我们也可以不捕获它,而是在方法定义处用throws表示toGBK()方法可能会抛出UnsupportedEncodingException,就可以让toGBK()方法通过编译器检查:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// try...catch
import java.io.UnsupportedEncodingException;
import java.util.Arrays;

public class Main {
public static void main(String[] args) {
byte[] bs = toGBK("中文");
System.out.println(Arrays.toString(bs));
}

static byte[] toGBK(String s) throws UnsupportedEncodingException {
return s.getBytes("GBK");
}
}

上述代码仍然会得到编译错误,但这一次,编译器提示的不是调用return s.getBytes("GBK");的问题,而是byte[] bs = toGBK("中文");。因为在main()方法中,调用toGBK(),没有捕获它声明的可能抛出的UnsupportedEncodingException

修复方法是在main()方法中捕获异常并处理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// try...catch
import java.io.UnsupportedEncodingException;
import java.util.Arrays;

public class Main {
public static void main(String[] args) {
try {
byte[] bs = toGBK("中文");
System.out.println(Arrays.toString(bs));
} catch (UnsupportedEncodingException e) {
System.out.println(e);
}
}

static byte[] toGBK(String s) throws UnsupportedEncodingException {
// 用指定编码转换String为byte[]:
return s.getBytes("GBK");
}
}

可见,只要是方法声明的Checked Exception,不在调用层捕获,也必须在更高的调用层捕获。所有未捕获的异常,最终也必须在main()方法中捕获,不会出现漏写try的情况。这是由编译器保证的。main()方法也是最后捕获Exception的机会。

如果是测试代码,上面的写法就略显麻烦。如果不想写任何try代码,可以直接把main()方法定义为throws Exception

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// try...catch
import java.io.UnsupportedEncodingException;
import java.util.Arrays;

public class Main {
public static void main(String[] args) throws Exception {
byte[] bs = toGBK("中文");
System.out.println(Arrays.toString(bs));
}

static byte[] toGBK(String s) throws UnsupportedEncodingException {
// 用指定编码转换String为byte[]:
return s.getBytes("GBK");
}
}

因为main()方法声明了可能抛出Exception,也就声明了可能抛出所有的Exception,因此在内部就无需捕获了。代价就是一旦发生异常,程序会立刻退出。

还有一些童鞋喜欢在toGBK()内部“消化”异常:

1
2
3
4
5
6
7
static byte[] toGBK(String s) {
try {
return s.getBytes("GBK");
} catch (UnsupportedEncodingException e) {
// 什么也不干
}
return null;

这种捕获后不处理的方式是非常不好的,即使真的什么也做不了,也要先把异常记录下来:

1
2
3
4
5
6
7
8
static byte[] toGBK(String s) {
try {
return s.getBytes("GBK");
} catch (UnsupportedEncodingException e) {
// 先记下来再说:
e.printStackTrace();
}
return null;

所有异常都可以调用printStackTrace()方法打印异常栈,这是一个简单有用的快速打印异常的方法。

小结

Java使用异常来表示错误,并通过try ... catch捕获异常;

Java的异常是class,并且从Throwable继承;

Error是无需捕获的严重错误,Exception是应该捕获的可处理的错误;

RuntimeException无需强制捕获,非RuntimeException(Checked Exception)需强制捕获,或者用throws声明;

不推荐捕获了异常但不进行任何处理。

在Java中,凡是可能抛出异常的语句,都可以用try ... catch捕获。把可能发生异常的语句放在try { ... }中,然后使用catch捕获对应的Exception及其子类。

多catch语句

可以使用多个catch语句,每个catch分别捕获对应的Exception及其子类。JVM在捕获到异常后,会从上到下匹配catch语句,匹配到某个catch后,执行catch代码块,然后不再继续匹配。

简单地说就是:多个catch语句只有一个能被执行。例如:

1
2
3
4
5
6
7
8
9
10
11
public static void main(String[] args) {
try {
process1();
process2();
process3();
} catch (IOException e) {
System.out.println(e);
} catch (NumberFormatException e) {
System.out.println(e);
}
}

存在多个catch的时候,catch的顺序非常重要:子类必须写在前面。例如:

1
2
3
4
5
6
7
8
9
10
11
public static void main(String[] args) {
try {
process1();
process2();
process3();
} catch (IOException e) {
System.out.println("IO error");
} catch (UnsupportedEncodingException e) { // 永远捕获不到
System.out.println("Bad encoding");
}
}

对于上面的代码,UnsupportedEncodingException异常是永远捕获不到的,因为它是IOException的子类。当抛出UnsupportedEncodingException异常时,会被catch (IOException e) { ... }捕获并执行。

因此,正确的写法是把子类放到前面:

1
2
3
4
5
6
7
8
9
10
11
public static void main(String[] args) {
try {
process1();
process2();
process3();
} catch (UnsupportedEncodingException e) {
System.out.println("Bad encoding");
} catch (IOException e) {
System.out.println("IO error");
}
}

finally语句

无论是否有异常发生,如果我们都希望执行一些语句,例如清理工作,怎么写?

可以把执行语句写若干遍:正常执行的放到try中,每个catch再写一遍。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public static void main(String[] args) {
try {
process1();
process2();
process3();
System.out.println("END");
} catch (UnsupportedEncodingException e) {
System.out.println("Bad encoding");
System.out.println("END");
} catch (IOException e) {
System.out.println("IO error");
System.out.println("END");
}
}

上述代码无论是否发生异常,都会执行System.out.println("END");这条语句。

那么如何消除这些重复的代码?Java的try ... catch机制还提供了finally语句,finally语句块保证有无错误都会执行。上述代码可以改写如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
public static void main(String[] args) {
try {
process1();
process2();
process3();
} catch (UnsupportedEncodingException e) {
System.out.println("Bad encoding");
} catch (IOException e) {
System.out.println("IO error");
} finally {
System.out.println("END");
}
}

注意finally有几个特点:

  1. finally语句不是必须的,可写可不写;
  2. finally总是最后执行。

如果没有发生异常,就正常执行try { ... }语句块,然后执行finally。如果发生了异常,就中断执行try { ... }语句块,然后跳转执行匹配的catch语句块,最后执行finally

可见,finally是用来保证一些代码必须执行的。

某些情况下,可以没有catch,只使用try ... finally结构。例如:

1
2
3
4
5
6
7
void process(String file) throws IOException {
try {
...
} finally {
System.out.println("END");
}
}

因为方法声明了可能抛出的异常,所以可以不写catch

捕获多种异常

如果某些异常的处理逻辑相同,但是异常本身不存在继承关系,那么就得编写多条catch子句:

1
2
3
4
5
6
7
8
9
10
11
12
13
public static void main(String[] args) {
try {
process1();
process2();
process3();
} catch (IOException e) {
System.out.println("Bad input");
} catch (NumberFormatException e) {
System.out.println("Bad input");
} catch (Exception e) {
System.out.println("Unknown error");
}
}

因为处理IOExceptionNumberFormatException的代码是相同的,所以我们可以把它两用|合并到一起:

1
2
3
4
5
6
7
8
9
10
11
12
public static void main(String[] args) {
try {
process1();
process2();
process3();
} catch (IOException | NumberFormatException e) {
// IOException或NumberFormatException
System.out.println("Bad input");
} catch (Exception e) {
System.out.println("Unknown error");
}
}

练习

try ... catch捕获异常并处理。

下载练习

小结

捕获异常时,多个catch语句的匹配顺序非常重要,子类必须放在前面;

finally语句保证了有无异常都会执行,它是可选的;

一个catch语句也可以匹配多个非继承关系的异常。

异常的传播

当某个方法抛出了异常时,如果当前方法没有捕获异常,异常就会被抛到上层调用方法,直到遇到某个try ... catch被捕获为止:

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

static void process1() {
process2();
}

static void process2() {
Integer.parseInt(null); // 会抛出NumberFormatException
}
}

通过printStackTrace()可以打印出方法的调用栈,类似:

1
2
3
4
5
6
java.lang.NumberFormatException: null
at java.base/java.lang.Integer.parseInt(Integer.java:614)
at java.base/java.lang.Integer.parseInt(Integer.java:770)
at Main.process2(Main.java:16)
at Main.process1(Main.java:12)
at Main.main(Main.java:5)

printStackTrace()对于调试错误非常有用,上述信息表示:NumberFormatException是在java.lang.Integer.parseInt方法中被抛出的,从下往上看,调用层次依次是:

  1. main()调用process1()
  2. process1()调用process2()
  3. process2()调用Integer.parseInt(String)
  4. Integer.parseInt(String)调用Integer.parseInt(String, int)

查看Integer.java源码可知,抛出异常的方法代码如下:

1
2
3
4
5
6
public static int parseInt(String s, int radix) throws NumberFormatException {
if (s == null) {
throw new NumberFormatException("null");
}
...
}

并且,每层调用均给出了源代码的行号,可直接定位。

抛出异常

当发生错误时,例如,用户输入了非法的字符,我们就可以抛出异常。

如何抛出异常?参考Integer.parseInt()方法,抛出异常分两步:

  1. 创建某个Exception的实例;
  2. throw语句抛出。

下面是一个例子:

1
2
3
4
5
6
void process2(String s) {
if (s==null) {
NullPointerException e = new NullPointerException();
throw e;
}
}

实际上,绝大部分抛出异常的代码都会合并写成一行:

1
2
3
4
5
void process2(String s) {
if (s==null) {
throw new NullPointerException();
}
}

如果一个方法捕获了某个异常后,又在catch子句中抛出新的异常,就相当于把抛出的异常类型“转换”了:

1
2
3
4
5
6
7
8
9
10
11
12
13
void process1(String s) {
try {
process2();
} catch (NullPointerException e) {
throw new IllegalArgumentException();
}
}

void process2(String s) {
if (s==null) {
throw new NullPointerException();
}
}

process2()抛出NullPointerException后,被process1()捕获,然后抛出IllegalArgumentException()

如果在main()中捕获IllegalArgumentException,我们看看打印的异常栈:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// exception
public class Main {
public static void main(String[] args) {
try {
process1();
} catch (Exception e) {
e.printStackTrace();
}
}

static void process1() {
try {
process2();
} catch (NullPointerException e) {
throw new IllegalArgumentException();
}
}

static void process2() {
throw new NullPointerException();
}
}

打印出的异常栈类似:

1
2
3
java.lang.IllegalArgumentException
at Main.process1(Main.java:15)
at Main.main(Main.java:5)

这说明新的异常丢失了原始异常信息,我们已经看不到原始异常NullPointerException的信息了。

为了能追踪到完整的异常栈,在构造异常的时候,把原始的Exception实例传进去,新的Exception就可以持有原始Exception信息。对上述代码改进如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// exception
public class Main {
public static void main(String[] args) {
try {
process1();
} catch (Exception e) {
e.printStackTrace();
}
}

static void process1() {
try {
process2();
} catch (NullPointerException e) {
throw new IllegalArgumentException(e);
}
}

static void process2() {
throw new NullPointerException();
}
}

运行上述代码,打印出的异常栈类似:

1
2
3
4
5
6
java.lang.IllegalArgumentException: java.lang.NullPointerException
at Main.process1(Main.java:15)
at Main.main(Main.java:5)
Caused by: java.lang.NullPointerException
at Main.process2(Main.java:20)
at Main.process1(Main.java:13)

注意到Caused by: Xxx,说明捕获的IllegalArgumentException并不是造成问题的根源,根源在于NullPointerException,是在Main.process2()方法抛出的。

在代码中获取原始异常可以使用Throwable.getCause()方法。如果返回null,说明已经是“根异常”了。

有了完整的异常栈的信息,我们才能快速定位并修复代码的问题。

最佳实践

捕获到异常并再次抛出时,一定要留住原始异常,否则很难定位第一案发现场!

如果我们在try或者catch语句块中抛出异常,finally语句是否会执行?例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
// exception
public class Main {
public static void main(String[] args) {
try {
Integer.parseInt("abc");
} catch (Exception e) {
System.out.println("catched");
throw new RuntimeException(e);
} finally {
System.out.println("finally");
}
}
}

上述代码执行结果如下:

1
2
3
4
5
6
catched
finally
Exception in thread "main" java.lang.RuntimeException: java.lang.NumberFormatException: For input string: "abc"
at Main.main(Main.java:8)
Caused by: java.lang.NumberFormatException: For input string: "abc"
at ...

第一行打印了catched,说明进入了catch语句块。第二行打印了finally,说明执行了finally语句块。

因此,在catch中抛出异常,不会影响finally的执行。JVM会先执行finally,然后抛出异常。

异常屏蔽

如果在执行finally语句时抛出异常,那么,catch语句的异常还能否继续抛出?例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// exception
public class Main {
public static void main(String[] args) {
try {
Integer.parseInt("abc");
} catch (Exception e) {
System.out.println("catched");
throw new RuntimeException(e);
} finally {
System.out.println("finally");
throw new IllegalArgumentException();
}
}
}

执行上述代码,发现异常信息如下:

1
2
3
4
catched
finally
Exception in thread "main" java.lang.IllegalArgumentException
at Main.main(Main.java:11)

这说明finally抛出异常后,原来在catch中准备抛出的异常就“消失”了,因为只能抛出一个异常。没有被抛出的异常称为“被屏蔽”的异常(Suppressed Exception)。

在极少数的情况下,我们需要获知所有的异常。如何保存所有的异常信息?方法是先用origin变量保存原始异常,然后调用Throwable.addSuppressed(),把原始异常添加进来,最后在finally抛出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// exception
public class Main {
public static void main(String[] args) throws Exception {
Exception origin = null;
try {
System.out.println(Integer.parseInt("abc"));
} catch (Exception e) {
origin = e;
throw e;
} finally {
Exception e = new IllegalArgumentException();
if (origin != null) {
e.addSuppressed(origin);
}
throw e;
}
}
}

catchfinally都抛出了异常时,虽然catch的异常被屏蔽了,但是,finally抛出的异常仍然包含了它:

1
2
3
4
5
6
7
Exception in thread "main" java.lang.IllegalArgumentException
at Main.main(Main.java:11)
Suppressed: java.lang.NumberFormatException: For input string: "abc"
at java.base/java.lang.NumberFormatException.forInputString(NumberFormatException.java:65)
at java.base/java.lang.Integer.parseInt(Integer.java:652)
at java.base/java.lang.Integer.parseInt(Integer.java:770)
at Main.main(Main.java:6)

通过Throwable.getSuppressed()可以获取所有的Suppressed Exception

绝大多数情况下,在finally中不要抛出异常。因此,我们通常不需要关心Suppressed Exception

提问时贴出异常

异常打印的详细的栈信息是找出问题的关键,许多初学者在提问时只贴代码,不贴异常,相当于只报案不给线索,福尔摩斯也无能为力。

还有的童鞋只贴部分异常信息,最关键的Caused by: xxx给省略了,这都属于不正确的提问方式,得改。

练习

如果传入的参数为负,则抛出IllegalArgumentException

下载练习

小结

调用printStackTrace()可以打印异常的传播栈,对于调试非常有用;

捕获异常并再次抛出新的异常时,应该持有原始异常信息;

通常不要在finally中抛出异常。如果在finally中抛出异常,应该原始异常加入到原有异常中。调用方可通过Throwable.getSuppressed()获取所有添加的Suppressed Exception

自定义异常

Java标准库定义的常用异常包括:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Exception
├─ RuntimeException
│ ├─ NullPointerException
│ ├─ IndexOutOfBoundsException
│ ├─ SecurityException
│ └─ IllegalArgumentException
│ └─ NumberFormatException
├─ IOException
│ ├─ UnsupportedCharsetException
│ ├─ FileNotFoundException
│ └─ SocketException
├─ ParseException
├─ GeneralSecurityException
├─ SQLException
└─ TimeoutException

当我们在代码中需要抛出异常时,尽量使用JDK已定义的异常类型。例如,参数检查不合法,应该抛出IllegalArgumentException

1
2
3
4
5
static void process1(int age) {
if (age <= 0) {
throw new IllegalArgumentException();
}
}

在一个大型项目中,可以自定义新的异常类型,但是,保持一个合理的异常继承体系是非常重要的。

一个常见的做法是自定义一个BaseException作为“根异常”,然后,派生出各种业务类型的异常。

BaseException需要从一个适合的Exception派生,通常建议从RuntimeException派生:

1
2
public class BaseException extends RuntimeException {
}

其他业务类型的异常就可以从BaseException派生:

1
2
3
4
5
6
7
public class UserNotFoundException extends BaseException {
}

public class LoginFailedException extends BaseException {
}

...

自定义的BaseException应该提供多个构造方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class BaseException extends RuntimeException {
public BaseException() {
super();
}

public BaseException(String message, Throwable cause) {
super(message, cause);
}

public BaseException(String message) {
super(message);
}

public BaseException(Throwable cause) {
super(cause);
}
}

上述构造方法实际上都是原样照抄RuntimeException。这样,抛出异常的时候,就可以选择合适的构造方法。通过IDE可以根据父类快速生成子类的构造方法。

练习

请从BaseException派生自定义异常。

下载练习

小结

抛出异常时,尽量复用JDK已定义的异常类型;

自定义异常体系时,推荐从RuntimeException派生“根异常”,再派生出业务异常;

自定义异常时,应该提供多种构造方法。



在所有的RuntimeException异常中,Java程序员最熟悉的恐怕就是NullPointerException了。

NullPointerException即空指针异常,俗称NPE。如果一个对象为null,调用其方法或访问其字段就会产生NullPointerException,这个异常通常是由JVM抛出的,例如:

1
2
3
4
5
6
7
// NullPointerException
public class Main {
public static void main(String[] args) {
String s = null;
System.out.println(s.toLowerCase());
}
}

指针这个概念实际上源自C语言,Java语言中并无指针。我们定义的变量实际上是引用,Null Pointer更确切地说是Null Reference,不过两者区别不大。

处理NullPointerException

如果遇到NullPointerException,我们应该如何处理?首先,必须明确,NullPointerException是一种代码逻辑错误,遇到NullPointerException,遵循原则是早暴露,早修复,严禁使用catch来隐藏这种编码错误:

1
2
3
4
5
// 错误示例: 捕获NullPointerException
try {
transferMoney(from, to, amount);
} catch (NullPointerException e) {
}

好的编码习惯可以极大地降低NullPointerException的产生,例如:

成员变量在定义时初始化:

1
2
3
public class Person {
private String name = "";
}

使用空字符串""而不是默认的null可避免很多NullPointerException,编写业务逻辑时,用空字符串""表示未填写比null安全得多。

返回空字符串""、空数组而不是null

1
2
3
4
5
6
7
public String[] readLinesFromFile(String file) {
if (getFileSize(file) == 0) {
// 返回空数组而不是null:
return new String[0];
}
...
}

这样可以使得调用方无需检查结果是否为null

如果调用方一定要根据null判断,比如返回null表示文件不存在,那么考虑返回Optional<T>

1
2
3
4
5
6
public Optional<String> readFromFile(String file) {
if (!fileExist(file)) {
return Optional.empty();
}
...
}

这样调用方必须通过Optional.isPresent()判断是否有结果。

定位NullPointerException

如果产生了NullPointerException,例如,调用a.b.c.x()时产生了NullPointerException,原因可能是:

  • anull
  • a.bnull
  • a.b.cnull

确定到底是哪个对象是null以前只能打印这样的日志:

1
2
3
System.out.println(a);
System.out.println(a.b);
System.out.println(a.b.c);

从Java 14开始,如果产生了NullPointerException,JVM可以给出详细的信息告诉我们null对象到底是谁。我们来看例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class Main {
public static void main(String[] args) {
Person p = new Person();
System.out.println(p.address.city.toLowerCase());
}
}

class Person {
String[] name = new String[2];
Address address = new Address();
}

class Address {
String city;
String street;
String zipcode;
}

可以在NullPointerException的详细信息中看到类似... because "<local1>.address.city" is null,意思是city字段为null,这样我们就能快速定位问题所在。

这种增强的NullPointerException详细信息是Java 14新增的功能,但默认是关闭的,我们可以给JVM添加一个-XX:+ShowCodeDetailsInExceptionMessages参数启用它:

1
java -XX:+ShowCodeDetailsInExceptionMessages Main.java

小结

NullPointerException是Java代码常见的逻辑错误,应当早暴露,早修复;

可以启用Java 14的增强异常信息来查看NullPointerException的详细错误信息。

使用断言

断言(Assertion)是一种调试程序的方式。在Java中,使用assert关键字来实现断言。

我们先看一个例子:

1
2
3
4
5
public static void main(String[] args) {
double x = Math.abs(-123.45);
assert x >= 0;
System.out.println(x);
}

语句assert x >= 0;即为断言,断言条件x >= 0预期为true。如果计算结果为false,则断言失败,抛出AssertionError

使用assert语句时,还可以添加一个可选的断言消息:

1
assert x >= 0 : "x must >= 0";

这样,断言失败的时候,AssertionError会带上消息x must >= 0,更加便于调试。

Java断言的特点是:断言失败时会抛出AssertionError,导致程序结束退出。因此,断言不能用于可恢复的程序错误,只应该用于开发和测试阶段。

对于可恢复的程序错误,不应该使用断言。例如:

1
2
3
void sort(int[] arr) {
assert arr != null;
}

应该抛出异常并在上层捕获:

1
2
3
4
5
void sort(int[] arr) {
if (arr == null) {
throw new IllegalArgumentException("array cannot be null");
}
}

当我们在程序中使用assert时,例如,一个简单的断言:

1
2
3
4
5
6
7
8
// assert
public class Main {
public static void main(String[] args) {
int x = -1;
assert x > 0;
System.out.println(x);
}
}

断言x必须大于0,实际上x-1,断言肯定失败。执行上述代码,发现程序并未抛出AssertionError,而是正常打印了x的值。

这是怎么回事?为什么assert语句不起作用?

这是因为JVM默认关闭断言指令,即遇到assert语句就自动忽略了,不执行。

要执行assert语句,必须给Java虚拟机传递-enableassertions(可简写为-ea)参数启用断言。所以,上述程序必须在命令行下运行才有效果:

1
2
3
$ java -ea Main.java
Exception in thread "main" java.lang.AssertionError
at Main.main(Main.java:5)

还可以有选择地对特定地类启用断言,命令行参数是:-ea:com.itranswarp.sample.Main,表示只对com.itranswarp.sample.Main这个类启用断言。

或者对特定地包启用断言,命令行参数是:-ea:com.itranswarp.sample...(注意结尾有3个.),表示对com.itranswarp.sample这个包启动断言。

实际开发中,很少使用断言。更好的方法是编写单元测试,后续我们会讲解JUnit的使用。

小结

断言是一种调试方式,断言失败会抛出AssertionError,只能在开发和测试阶段启用断言;

对可恢复的错误不能使用断言,而应该抛出异常;

断言很少被使用,更好的方法是编写单元测试。



使用JDK Logging

在编写程序的过程中,发现程序运行结果与预期不符,怎么办?当然是用System.out.println()打印出执行过程中的某些变量,观察每一步的结果与代码逻辑是否符合,然后有针对性地修改代码。

代码改好了怎么办?当然是删除没有用的System.out.println()语句了。

如果改代码又改出问题怎么办?再加上System.out.println()

反复这么搞几次,很快大家就发现使用System.out.println()非常麻烦。

怎么办?

解决方法是使用日志。

那什么是日志?日志就是Logging,它的目的是为了取代System.out.println()

输出日志,而不是用System.out.println(),有以下几个好处:

  1. 可以设置输出样式,避免自己每次都写"ERROR: " + var
  2. 可以设置输出级别,禁止某些级别输出。例如,只输出错误日志;
  3. 可以被重定向到文件,这样可以在程序运行结束后查看日志;
  4. 可以按包名控制日志级别,只输出某些包打的日志;
  5. 可以……

总之就是好处很多啦。

那如何使用日志?

因为Java标准库内置了日志包java.util.logging,我们可以直接用。先看一个简单的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
// logging
import java.util.logging.Level;
import java.util.logging.Logger;

public class Hello {
public static void main(String[] args) {
Logger logger = Logger.getGlobal();
logger.info("start process...");
logger.warning("memory is running out...");
logger.fine("ignored.");
logger.severe("process will be terminated...");
}
}

运行上述代码,得到类似如下的输出:

1
2
3
4
5
6
Mar 02, 2019 6:32:13 PM Hello main
INFO: start process...
Mar 02, 2019 6:32:13 PM Hello main
WARNING: memory is running out...
Mar 02, 2019 6:32:13 PM Hello main
SEVERE: process will be terminated...

对比可见,使用日志最大的好处是,它自动打印了时间、调用类、调用方法等很多有用的信息。

再仔细观察发现,4条日志,只打印了3条,logger.fine()没有打印。这是因为,日志的输出可以设定级别。JDK的Logging定义了7个日志级别,从严重到普通:

  • SEVERE
  • WARNING
  • INFO
  • CONFIG
  • FINE
  • FINER
  • FINEST

因为默认级别是INFO,因此,INFO级别以下的日志,不会被打印出来。使用日志级别的好处在于,调整级别,就可以屏蔽掉很多调试相关的日志输出。

使用Java标准库内置的Logging有以下局限:

Logging系统在JVM启动时读取配置文件并完成初始化,一旦开始运行main()方法,就无法修改配置;

配置不太方便,需要在JVM启动时传递参数-Djava.util.logging.config.file=<config-file-name>

因此,Java标准库内置的Logging使用并不是非常广泛。更方便的日志系统我们稍后介绍。

练习

使用logger.severe()打印异常:

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

public class Main {
public static void main(String[] args) {
Logger logger = Logger.getLogger(Main.class.getName());
logger.info("Start process...");
try {
"".getBytes("invalidCharsetName");
} catch (UnsupportedEncodingException e) {
// TODO: 使用logger.severe()打印异常
}
logger.info("Process end.");
}
}

下载练习

小结

日志是为了替代System.out.println(),可以定义格式,重定向到文件等;

日志可以存档,便于追踪问题;

日志记录可以按级别分类,便于打开或关闭某些级别;

可以根据配置文件调整日志,无需修改代码;

Java标准库提供了java.util.logging来实现日志功能。



和Java标准库提供的日志不同,Commons Logging是一个第三方日志库,它是由Apache创建的日志模块。

Commons Logging的特色是,它可以挂接不同的日志系统,并通过配置文件指定挂接的日志系统。默认情况下,Commons Loggin自动搜索并使用Log4j(Log4j是另一个流行的日志系统),如果没有找到Log4j,再使用JDK Logging。

使用Commons Logging只需要和两个类打交道,并且只有两步:

第一步,通过LogFactory获取Log类的实例; 第二步,使用Log实例的方法打日志。

示例代码如下:

1
2
3
4
5
6
7
8
9
10
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;

public class Main {
public static void main(String[] args) {
Log log = LogFactory.getLog(Main.class);
log.info("start...");
log.warn("end.");
}
}

运行上述代码,肯定会得到编译错误,类似error: package org.apache.commons.logging does not exist(找不到org.apache.commons.logging这个包)。因为Commons Logging是一个第三方提供的库,所以,必须先把它下载下来。下载后,解压,找到commons-logging-1.2.jar这个文件,再把Java源码Main.java放到一个目录下,例如work目录:

1
2
3
work
├─ commons-logging-1.2.jar
└─ Main.java

然后用javac编译Main.java,编译的时候要指定classpath,不然编译器找不到我们引用的org.apache.commons.logging包。编译命令如下:

1
javac -cp commons-logging-1.2.jar Main.java

如果编译成功,那么当前目录下就会多出一个Main.class文件:

1
2
3
4
work
├─ commons-logging-1.2.jar
├─ Main.java
└─ Main.class

现在可以执行这个Main.class,使用java命令,也必须指定classpath,命令如下:

1
java -cp .;commons-logging-1.2.jar Main

注意到传入的classpath有两部分:一个是.,一个是commons-logging-1.2.jar,用;分割。.表示当前目录,如果没有这个.,JVM不会在当前目录搜索Main.class,就会报错。

如果在Linux或macOS下运行,注意classpath的分隔符不是;,而是:

1
java -cp .:commons-logging-1.2.jar Main

运行结果如下:

1
2
3
4
Mar 02, 2019 7:15:31 PM Main main
INFO: start...
Mar 02, 2019 7:15:31 PM Main main
WARNING: end.

Commons Logging定义了6个日志级别:

  • FATAL
  • ERROR
  • WARNING
  • INFO
  • DEBUG
  • TRACE

默认级别是INFO

使用Commons Logging时,如果在静态方法中引用Log,通常直接定义一个静态类型变量:

1
2
3
4
5
6
7
8
// 在静态方法中引用Log:
public class Main {
static final Log log = LogFactory.getLog(Main.class);

static void foo() {
log.info("foo");
}
}

在实例方法中引用Log,通常定义一个实例变量:

1
2
3
4
5
6
7
8
// 在实例方法中引用Log:
public class Person {
protected final Log log = LogFactory.getLog(getClass());

void foo() {
log.info("foo");
}
}

注意到实例变量log的获取方式是LogFactory.getLog(getClass()),虽然也可以用LogFactory.getLog(Person.class),但是前一种方式有个非常大的好处,就是子类可以直接使用该log实例。例如:

1
2
3
4
5
6
// 在子类中使用父类实例化的log:
public class Student extends Person {
void bar() {
log.info("bar");
}
}

由于Java类的动态特性,子类获取的log字段实际上相当于LogFactory.getLog(Student.class),但却是从父类继承而来,并且无需改动代码。

此外,Commons Logging的日志方法,例如info(),除了标准的info(String)外,还提供了一个非常有用的重载方法:info(String, Throwable),这使得记录异常更加简单:

1
2
3
4
5
try {
...
} catch (Exception e) {
log.error("got exception!", e);
}

练习

使用log.error(String, Throwable)打印异常。

下载练习

小结

Commons Logging是使用最广泛的日志模块;

Commons Logging的API非常简单;

Commons Logging可以自动检测并使用其他日志模块。

前面介绍了Commons Logging,可以作为“日志接口”来使用。而真正的“日志实现”可以使用Log4j。

Log4j是一种非常流行的日志框架,最新版本是2.x。

Log4j是一个组件化设计的日志系统,它的架构大致如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
log.info("User signed in.");

│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
├──▶│ Appender │───▶│ Filter │───▶│ Layout │───▶│ Console │
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘

│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
├──▶│ Appender │───▶│ Filter │───▶│ Layout │───▶│ File │
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘

│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
└──▶│ Appender │───▶│ Filter │───▶│ Layout │───▶│ Socket │
└──────────┘ └──────────┘ └──────────┘ └──────────┘

当我们使用Log4j输出一条日志时,Log4j自动通过不同的Appender把同一条日志输出到不同的目的地。例如:

  • console:输出到屏幕;
  • file:输出到文件;
  • socket:通过网络输出到远程计算机;
  • jdbc:输出到数据库

在输出日志的过程中,通过Filter来过滤哪些log需要被输出,哪些log不需要被输出。例如,仅输出ERROR级别的日志。

最后,通过Layout来格式化日志信息,例如,自动添加日期、时间、方法名称等信息。

上述结构虽然复杂,但我们在实际使用的时候,并不需要关心Log4j的API,而是通过配置文件来配置它。

以XML配置为例,使用Log4j的时候,我们把一个log4j2.xml的文件放到classpath下就可以让Log4j读取配置文件并按照我们的配置来输出日志。下面是一个配置文件的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
<?xml version="1.0" encoding="UTF-8"?>
<Configuration>
<Properties>
<!-- 定义日志格式 -->
<Property name="log.pattern">%d{MM-dd HH:mm:ss.SSS} [%t] %-5level %logger{36}%n%msg%n%n</Property>
<!-- 定义文件名变量 -->
<Property name="file.err.filename">log/err.log</Property>
<Property name="file.err.pattern">log/err.%i.log.gz</Property>
</Properties>
<!-- 定义Appender,即目的地 -->
<Appenders>
<!-- 定义输出到屏幕 -->
<Console name="console" target="SYSTEM_OUT">
<!-- 日志格式引用上面定义的log.pattern -->
<PatternLayout pattern="${log.pattern}" />
</Console>
<!-- 定义输出到文件,文件名引用上面定义的file.err.filename -->
<RollingFile name="err" bufferedIO="true" fileName="${file.err.filename}" filePattern="${file.err.pattern}">
<PatternLayout pattern="${log.pattern}" />
<Policies>
<!-- 根据文件大小自动切割日志 -->
<SizeBasedTriggeringPolicy size="1 MB" />
</Policies>
<!-- 保留最近10份 -->
<DefaultRolloverStrategy max="10" />
</RollingFile>
</Appenders>
<Loggers>
<Root level="info">
<!-- 对info级别的日志,输出到console -->
<AppenderRef ref="console" level="info" />
<!-- 对error级别的日志,输出到err,即上面定义的RollingFile -->
<AppenderRef ref="err" level="error" />
</Root>
</Loggers>
</Configuration>

虽然配置Log4j比较繁琐,但一旦配置完成,使用起来就非常方便。对上面的配置文件,凡是INFO级别的日志,会自动输出到屏幕,而ERROR级别的日志,不但会输出到屏幕,还会同时输出到文件。并且,一旦日志文件达到指定大小(1MB),Log4j就会自动切割新的日志文件,并最多保留10份。

有了配置文件还不够,因为Log4j也是一个第三方库,我们需要从这里下载Log4j,解压后,把以下3个jar包放到classpath中:

  • log4j-api-2.x.jar
  • log4j-core-2.x.jar
  • log4j-jcl-2.x.jar

因为Commons Logging会自动发现并使用Log4j,所以,把上一节下载的commons-logging-1.2.jar也放到classpath中。

要打印日志,只需要按Commons Logging的写法写,不需要改动任何代码,就可以得到Log4j的日志输出,类似:

1
2
03-03 12:09:45.880 [main] INFO  com.itranswarp.learnjava.Main
Start process...

最佳实践

在开发阶段,始终使用Commons Logging接口来写入日志,并且开发阶段无需引入Log4j。如果需要把日志写入文件,只需要把正确的配置文件和Log4j相关的jar包放入classpath,就可以自动把日志切换成使用Log4j写入,无需修改任何代码。

练习

根据配置文件,观察Log4j写入的日志文件。

下载练习

小结

通过Commons Logging实现日志,不需要修改代码即可使用Log4j;

使用Log4j只需要把log4j2.xml和相关jar放入classpath;

如果要更换Log4j,只需要移除log4j2.xml和相关jar;

只有扩展Log4j时,才需要引用Log4j的接口(例如,将日志加密写入数据库的功能,需要自己开发)。

前面介绍了Commons Logging和Log4j这一对好基友,它们一个负责充当日志API,一个负责实现日志底层,搭配使用非常便于开发。

有的童鞋可能还听说过SLF4J和Logback。这两个东东看上去也像日志,它们又是啥?

其实SLF4J类似于Commons Logging,也是一个日志接口,而Logback类似于Log4j,是一个日志的实现。

为什么有了Commons Logging和Log4j,又会蹦出来SLF4J和Logback?这是因为Java有着非常悠久的开源历史,不但OpenJDK本身是开源的,而且我们用到的第三方库,几乎全部都是开源的。开源生态丰富的一个特定就是,同一个功能,可以找到若干种互相竞争的开源库。

因为对Commons Logging的接口不满意,有人就搞了SLF4J。因为对Log4j的性能不满意,有人就搞了Logback。

我们先来看看SLF4J对Commons Logging的接口有何改进。在Commons Logging中,我们要打印日志,有时候得这么写:

1
2
3
int score = 99;
p.setScore(score);
log.info("Set score " + score + " for Person " + p.getName() + " ok.");

拼字符串是一个非常麻烦的事情,所以SLF4J的日志接口改进成这样了:

1
2
3
int score = 99;
p.setScore(score);
logger.info("Set score {} for Person {} ok.", score, p.getName());

我们靠猜也能猜出来,SLF4J的日志接口传入的是一个带占位符的字符串,用后面的变量自动替换占位符,所以看起来更加自然。

如何使用SLF4J?它的接口实际上和Commons Logging几乎一模一样:

1
2
3
4
5
6
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

class Main {
final Logger logger = LoggerFactory.getLogger(getClass());
}

对比一下Commons Logging和SLF4J的接口:

Commons Logging SLF4J
org.apache.commons.logging.Log org.slf4j.Logger
org.apache.commons.logging.LogFactory org.slf4j.LoggerFactory

不同之处就是Log变成了LoggerLogFactory变成了LoggerFactory

使用SLF4J和Logback和前面讲到的使用Commons Logging加Log4j是类似的,先分别下载SLF4JLogback,然后把以下jar包放到classpath下:

  • slf4j-api-1.7.x.jar
  • logback-classic-1.2.x.jar
  • logback-core-1.2.x.jar

然后使用SLF4J的LoggerLoggerFactory即可。

和Log4j类似,我们仍然需要一个Logback的配置文件,把logback.xml放到classpath下,配置如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>

<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<encoder>
<pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
<charset>utf-8</charset>
</encoder>
<file>log/output.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.FixedWindowRollingPolicy">
<fileNamePattern>log/output.log.%i</fileNamePattern>
</rollingPolicy>
<triggeringPolicy class="ch.qos.logback.core.rolling.SizeBasedTriggeringPolicy">
<MaxFileSize>1MB</MaxFileSize>
</triggeringPolicy>
</appender>

<root level="INFO">
<appender-ref ref="CONSOLE" />
<appender-ref ref="FILE" />
</root>
</configuration>

运行即可获得类似如下的输出:

1
13:15:25.328 [main] INFO  com.itranswarp.learnjava.Main - Start process...

从目前的趋势来看,越来越多的开源项目从Commons Logging加Log4j转向了SLF4J加Logback。

练习

根据配置文件,观察Logback写入的日志文件。

下载练习

小结

SLF4J和Logback可以取代Commons Logging和Log4j;

始终使用SLF4J的接口写入日志,使用Logback只需要配置,不需要修改代码。

留言與分享

String

在Java中,String是一个引用类型,它本身也是一个class。但是,Java编译器对String有特殊处理,即可以直接用"..."来表示一个字符串:

1
String s1 = "Hello!";

实际上字符串在String内部是通过一个char[]数组表示的,因此,按下面的写法也是可以的:

1
String s2 = new String(new char[] {'H', 'e', 'l', 'l', 'o', '!'});

因为String太常用了,所以Java提供了"..."这种字符串字面量表示方法。

Java字符串的一个重要特点就是字符串不可变。这种不可变性是通过内部的private final char[]字段,以及没有任何修改char[]的方法实现的。

我们来看一个例子:

1
2
3
4
5
6
7
8
9
// String
public class Main {
public static void main(String[] args) {
String s = "Hello";
System.out.println(s);
s = s.toUpperCase();
System.out.println(s);
}
}

根据上面代码的输出,试解释字符串内容是否改变。

字符串比较

当我们想要比较两个字符串是否相同时,要特别注意,我们实际上是想比较字符串的内容是否相同。必须使用equals()方法而不能用==

我们看下面的例子:

1
2
3
4
5
6
7
8
9
// String
public class Main {
public static void main(String[] args) {
String s1 = "hello";
String s2 = "hello";
System.out.println(s1 == s2);
System.out.println(s1.equals(s2));
}
}

从表面上看,两个字符串用==equals()比较都为true,但实际上那只是Java编译器在编译期,会自动把所有相同的字符串当作一个对象放入常量池,自然s1s2的引用就是相同的。

所以,这种==比较返回true纯属巧合。换一种写法,==比较就会失败:

1
2
3
4
5
6
7
8
9
// String
public class Main {
public static void main(String[] args) {
String s1 = "hello";
String s2 = "HELLO".toLowerCase();
System.out.println(s1 == s2);
System.out.println(s1.equals(s2));
}
}

结论:两个字符串比较,必须总是使用equals()方法。

要忽略大小写比较,使用equalsIgnoreCase()方法。

String类还提供了多种方法来搜索子串、提取子串。常用的方法有:

1
2
// 是否包含子串:
"Hello".contains("ll"); // true

注意到contains()方法的参数是CharSequence而不是String,因为CharSequenceString实现的一个接口。

搜索子串的更多的例子:

1
2
3
4
"Hello".indexOf("l"); // 2
"Hello".lastIndexOf("l"); // 3
"Hello".startsWith("He"); // true
"Hello".endsWith("lo"); // true

提取子串的例子:

1
2
"Hello".substring(2); // "llo"
"Hello".substring(2, 4); "ll"

注意索引号是从0开始的。

去除首尾空白字符

使用trim()方法可以移除字符串首尾空白字符。空白字符包括空格,\t\r\n

1
"  \tHello\r\n ".trim(); // "Hello"

注意:trim()并没有改变字符串的内容,而是返回了一个新字符串。

另一个strip()方法也可以移除字符串首尾空白字符。它和trim()不同的是,类似中文的空格字符\u3000也会被移除:

1
2
3
"\u3000Hello\u3000".strip(); // "Hello"
" Hello ".stripLeading(); // "Hello "
" Hello ".stripTrailing(); // " Hello"

String还提供了isEmpty()isBlank()来判断字符串是否为空和空白字符串:

1
2
3
4
"".isEmpty(); // true,因为字符串长度为0
" ".isEmpty(); // false,因为字符串长度不为0
" \n".isBlank(); // true,因为只包含空白字符
" Hello ".isBlank(); // false,因为包含非空白字符

替换子串

要在字符串中替换子串,有两种方法。一种是根据字符或字符串替换:

1
2
3
String s = "hello";
s.replace('l', 'w'); // "hewwo",所有字符'l'被替换为'w'
s.replace("ll", "~~"); // "he~~o",所有子串"ll"被替换为"~~"

另一种是通过正则表达式替换:

1
2
String s = "A,,B;C ,D";
s.replaceAll("[\\,\\;\\s]+", ","); // "A,B,C,D"

上面的代码通过正则表达式,把匹配的子串统一替换为","。关于正则表达式的用法我们会在后面详细讲解。

分割字符串

要分割字符串,使用split()方法,并且传入的也是正则表达式:

1
2
String s = "A,B,C,D";
String[] ss = s.split("\\,"); // {"A", "B", "C", "D"}

拼接字符串

拼接字符串使用静态方法join(),它用指定的字符串连接字符串数组:

1
2
String[] arr = {"A", "B", "C"};
String s = String.join("***", arr); // "A***B***C"

格式化字符串

字符串提供了formatted()方法和format()静态方法,可以传入其他参数,替换占位符,然后生成新的字符串:

1
2
3
4
5
6
7
8
// String
public class Main {
public static void main(String[] args) {
String s = "Hi %s, your score is %d!";
System.out.println(s.formatted("Alice", 80));
System.out.println(String.format("Hi %s, your score is %.2f!", "Bob", 59.5));
}
}

有几个占位符,后面就传入几个参数。参数类型要和占位符一致。我们经常用这个方法来格式化信息。常用的占位符有:

  • %s:显示字符串;
  • %d:显示整数;
  • %x:显示十六进制整数;
  • %f:显示浮点数。

占位符还可以带格式,例如%.2f表示显示两位小数。如果你不确定用啥占位符,那就始终用%s,因为%s可以显示任何数据类型。要查看完整的格式化语法,请参考JDK文档

类型转换

要把任意基本类型或引用类型转换为字符串,可以使用静态方法valueOf()。这是一个重载方法,编译器会根据参数自动选择合适的方法:

1
2
3
4
String.valueOf(123); // "123"
String.valueOf(45.67); // "45.67"
String.valueOf(true); // "true"
String.valueOf(new Object()); // 类似java.lang.Object@636be97c

要把字符串转换为其他类型,就需要根据情况。例如,把字符串转换为int类型:

1
2
int n1 = Integer.parseInt("123"); // 123
int n2 = Integer.parseInt("ff", 16); // 按十六进制转换,255

把字符串转换为boolean类型:

1
2
boolean b1 = Boolean.parseBoolean("true"); // true
boolean b2 = Boolean.parseBoolean("FALSE"); // false

要特别注意,Integer有个getInteger(String)方法,它不是将字符串转换为int,而是把该字符串对应的系统变量转换为Integer

1
Integer.getInteger("java.version"); // 版本号,11

转换为char[]

Stringchar[]类型可以互相转换,方法是:

1
2
char[] cs = "Hello".toCharArray(); // String -> char[]
String s = new String(cs); // char[] -> String

如果修改了char[]数组,String并不会改变:

1
2
3
4
5
6
7
8
9
10
// String <-> char[]
public class Main {
public static void main(String[] args) {
char[] cs = "Hello".toCharArray();
String s = new String(cs);
System.out.println(s);
cs[0] = 'X';
System.out.println(s);
}
}

这是因为通过new String(char[])创建新的String实例时,它并不会直接引用传入的char[]数组,而是会复制一份,所以,修改外部的char[]数组不会影响String实例内部的char[]数组,因为这是两个不同的数组。

String的不变性设计可以看出,如果传入的对象有可能改变,我们需要复制而不是直接引用。

例如,下面的代码设计了一个Score类保存一组学生的成绩:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// int[]
import java.util.Arrays;

public class Main {
public static void main(String[] args) {
int[] scores = new int[] { 88, 77, 51, 66 };
Score s = new Score(scores);
s.printScores();
scores[2] = 99;
s.printScores();
}
}

class Score {
private int[] scores;
public Score(int[] scores) {
this.scores = scores;
}

public void printScores() {
System.out.println(Arrays.toString(scores));
}
}

观察两次输出,由于Score内部直接引用了外部传入的int[]数组,这会造成外部代码对int[]数组的修改,影响到Score类的字段。如果外部代码不可信,这就会造成安全隐患。

请修复Score的构造方法,使得外部代码对数组的修改不影响Score实例的int[]字段。

字符编码

在早期的计算机系统中,为了给字符编码,美国国家标准学会(American National Standard Institute:ANSI)制定了一套英文字母、数字和常用符号的编码,它占用一个字节,编码范围从0127,最高位始终为0,称为ASCII编码。例如,字符'A'的编码是0x41,字符'1'的编码是0x31

如果要把汉字也纳入计算机编码,很显然一个字节是不够的。GB2312标准使用两个字节表示一个汉字,其中第一个字节的最高位始终为1,以便和ASCII编码区分开。例如,汉字'中'GB2312编码是0xd6d0

类似的,日文有Shift_JIS编码,韩文有EUC-KR编码,这些编码因为标准不统一,同时使用,就会产生冲突。

为了统一全球所有语言的编码,全球统一码联盟发布了Unicode编码,它把世界上主要语言都纳入同一个编码,这样,中文、日文、韩文和其他语言就不会冲突。

Unicode编码需要两个或者更多字节表示,我们可以比较中英文字符在ASCIIGB2312Unicode的编码:

英文字符'A'ASCII编码和Unicode编码:

1
2
3
4
5
6
         ┌────┐
ASCII: │ 41 │
└────┘
┌────┬────┐
Unicode: │ 00 │ 41 │
└────┴────┘

英文字符的Unicode编码就是简单地在前面添加一个00字节。

中文字符'中'GB2312编码和Unicode编码:

1
2
3
4
5
6
         ┌────┬────┐
GB2312: │ d6 │ d0 │
└────┴────┘
┌────┬────┐
Unicode: │ 4e │ 2d │
└────┴────┘

那我们经常使用的UTF-8又是什么编码呢?因为英文字符的Unicode编码高字节总是00,包含大量英文的文本会浪费空间,所以,出现了UTF-8编码,它是一种变长编码,用来把固定长度的Unicode编码变成1~4字节的变长编码。通过UTF-8编码,英文字符'A'UTF-8编码变为0x41,正好和ASCII码一致,而中文'中'UTF-8编码为3字节0xe4b8ad

UTF-8编码的另一个好处是容错能力强。如果传输过程中某些字符出错,不会影响后续字符,因为UTF-8编码依靠高字节位来确定一个字符究竟是几个字节,它经常用来作为传输编码。

在Java中,char类型实际上就是两个字节的Unicode编码。如果我们要手动把字符串转换成其他编码,可以这样做:

1
2
3
4
byte[] b1 = "Hello".getBytes(); // 按系统默认编码转换,不推荐
byte[] b2 = "Hello".getBytes("UTF-8"); // 按UTF-8编码转换
byte[] b2 = "Hello".getBytes("GBK"); // 按GBK编码转换
byte[] b3 = "Hello".getBytes(StandardCharsets.UTF_8); // 按UTF-8编码转换

注意:转换编码后,就不再是char类型,而是byte类型表示的数组。

如果要把已知编码的byte[]转换为String,可以这样做:

1
2
3
byte[] b = ...
String s1 = new String(b, "GBK"); // 按GBK转换
String s2 = new String(b, StandardCharsets.UTF_8); // 按UTF-8转换

始终牢记:Java的Stringchar在内存中总是以Unicode编码表示。

延伸阅读

对于不同版本的JDK,String类在内存中有不同的优化方式。具体来说,早期JDK版本的String总是以char[]存储,它的定义如下:

1
2
3
4
5
public final class String {
private final char[] value;
private final int offset;
private final int count;
}

而较新的JDK版本的String则以byte[]存储:如果String仅包含ASCII字符,则每个byte存储一个字符,否则,每两个byte存储一个字符,这样做的目的是为了节省内存,因为大量的长度较短的String通常仅包含ASCII字符:

1
2
3
public final class String {
private final byte[] value;
private final byte coder; // 0 = LATIN1, 1 = UTF16

对于使用者来说,String内部的优化不影响任何已有代码,因为它的public方法签名是不变的。

小结

Java字符串String是不可变对象;

字符串操作不改变原字符串内容,而是返回新字符串;

常用的字符串操作:提取子串、查找、替换、大小写转换等;

Java使用Unicode编码表示Stringchar

转换编码就是将Stringbyte[]转换,需要指定编码;

转换为byte[]时,始终优先考虑UTF-8编码。

Java编译器对String做了特殊处理,使得我们可以直接用+拼接字符串。

考察下面的循环代码:

1
2
3
4
String s = "";
for (int i = 0; i < 1000; i++) {
s = s + "," + i;
}

虽然可以直接拼接字符串,但是,在循环中,每次循环都会创建新的字符串对象,然后扔掉旧的字符串。这样,绝大部分字符串都是临时对象,不但浪费内存,还会影响GC效率。

为了能高效拼接字符串,Java标准库提供了StringBuilder,它是一个可变对象,可以预分配缓冲区,这样,往StringBuilder中新增字符时,不会创建新的临时对象:

1
2
3
4
5
6
StringBuilder sb = new StringBuilder(1024);
for (int i = 0; i < 1000; i++) {
sb.append(',');
sb.append(i);
}
String s = sb.toString();

StringBuilder还可以进行链式操作:

1
2
3
4
5
6
7
8
9
10
11
// 链式操作
public class Main {
public static void main(String[] args) {
var sb = new StringBuilder(1024);
sb.append("Mr ")
.append("Bob")
.append("!")
.insert(0, "Hello, ");
System.out.println(sb.toString());
}
}

如果我们查看StringBuilder的源码,可以发现,进行链式操作的关键是,定义的append()方法会返回this,这样,就可以不断调用自身的其他方法。

仿照StringBuilder,我们也可以设计支持链式操作的类。例如,一个可以不断增加的计数器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// 链式操作
public class Main {
public static void main(String[] args) {
Adder adder = new Adder();
adder.add(3)
.add(5)
.inc()
.add(10);
System.out.println(adder.value());
}
}

class Adder {
private int sum = 0;

public Adder add(int n) {
sum += n;
return this;
}

public Adder inc() {
sum ++;
return this;
}

public int value() {
return sum;
}
}

注意:对于普通的字符串+操作,并不需要我们将其改写为StringBuilder,因为Java编译器在编译时就自动把多个连续的+操作编码为StringConcatFactory的操作。在运行期,StringConcatFactory会自动把字符串连接操作优化为数组复制或者StringBuilder操作。

你可能还听说过StringBuffer,这是Java早期的一个StringBuilder的线程安全版本,它通过同步来保证多个线程操作StringBuffer也是安全的,但是同步会带来执行速度的下降。

StringBuilderStringBuffer接口完全相同,现在完全没有必要使用StringBuffer

练习

请使用StringBuilder构造一个INSERT语句:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class Main {
public static void main(String[] args) {
String[] fields = { "name", "position", "salary" };
String table = "employee";
String insert = buildInsertSql(table, fields);
System.out.println(insert);
String s = "INSERT INTO employee (name, position, salary) VALUES (?, ?, ?)";
System.out.println(s.equals(insert) ? "测试成功" : "测试失败");
}

static String buildInsertSql(String table, String[] fields) {
// TODO:
return "";
}
}

下载练习

小结

StringBuilder是可变对象,用来高效拼接字符串;

StringBuilder可以支持链式操作,实现链式操作的关键是返回实例本身;

StringBufferStringBuilder的线程安全版本,现在很少使用。

StringJoiner

要高效拼接字符串,应该使用StringBuilder

很多时候,我们拼接的字符串像这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 输出: Hello Bob, Alice, Grace!
public class Main {
public static void main(String[] args) {
String[] names = {"Bob", "Alice", "Grace"};
var sb = new StringBuilder();
sb.append("Hello ");
for (String name : names) {
sb.append(name).append(", ");
}
// 注意去掉最后的", ":
sb.delete(sb.length() - 2, sb.length());
sb.append("!");
System.out.println(sb.toString());
}
}

类似用分隔符拼接数组的需求很常见,所以Java标准库还提供了一个StringJoiner来干这个事:

1
2
3
4
5
6
7
8
9
10
11
import java.util.StringJoiner;
public class Main {
public static void main(String[] args) {
String[] names = {"Bob", "Alice", "Grace"};
var sj = new StringJoiner(", ");
for (String name : names) {
sj.add(name);
}
System.out.println(sj.toString());
}
}

慢着!用StringJoiner的结果少了前面的"Hello "和结尾的"!"!遇到这种情况,需要给StringJoiner指定“开头”和“结尾”:

1
2
3
4
5
6
7
8
9
10
11
import java.util.StringJoiner;
public class Main {
public static void main(String[] args) {
String[] names = {"Bob", "Alice", "Grace"};
var sj = new StringJoiner(", ", "Hello ", "!");
for (String name : names) {
sj.add(name);
}
System.out.println(sj.toString());
}
}

String.join()

String还提供了一个静态方法join(),这个方法在内部使用了StringJoiner来拼接字符串,在不需要指定“开头”和“结尾”的时候,用String.join()更方便:

1
2
String[] names = {"Bob", "Alice", "Grace"};
var s = String.join(", ", names);

练习

请使用StringJoiner构造一个SELECT语句:

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

public class Main {
public static void main(String[] args) {
String[] fields = { "name", "position", "salary" };
String table = "employee";
String select = buildSelectSql(table, fields);
System.out.println(select);
System.out.println("SELECT name, position, salary FROM employee".equals(select) ? "测试成功" : "测试失败");
}

static String buildSelectSql(String table, String[] fields) {
// TODO:
return "";
}
}

下载练习

小结

用指定分隔符拼接字符串数组时,使用StringJoiner或者String.join()更方便;

StringJoiner拼接字符串时,还可以额外附加一个“开头”和“结尾”。



我们已经知道,Java的数据类型分两种:

  • 基本类型:byteshortintlongbooleanfloatdoublechar
  • 引用类型:所有classinterface类型。

引用类型可以赋值为null,表示空,但基本类型不能赋值为null

1
2
String s = null;
int n = null; // compile error!

那么,如何把一个基本类型视为对象(引用类型)?

比如,想要把int基本类型变成一个引用类型,我们可以定义一个Integer类,它只包含一个实例字段int,这样,Integer类就可以视为int的包装类(Wrapper Class):

1
2
3
4
5
6
7
8
9
10
11
public class Integer {
private int value;

public Integer(int value) {
this.value = value;
}

public int intValue() {
return this.value;
}
}

定义好了Integer类,我们就可以把intInteger互相转换:

1
2
3
Integer n = null;
Integer n2 = new Integer(99);
int n3 = n2.intValue();

实际上,因为包装类型非常有用,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
2
3
4
5
6
7
8
9
10
11
12
13
// Integer:
public class Main {
public static void main(String[] args) {
int i = 100;
// 通过new操作符创建Integer实例(不推荐使用,会有编译警告):
Integer n1 = new Integer(i);
// 通过静态方法valueOf(int)创建Integer实例:
Integer n2 = Integer.valueOf(i);
// 通过静态方法valueOf(String)创建Integer实例:
Integer n3 = Integer.valueOf("100");
System.out.println(n3.intValue());
}
}

Auto Boxing

因为intInteger可以互相转换:

1
2
3
int i = 100;
Integer n = Integer.valueOf(i);
int x = n.intValue();

所以,Java编译器可以帮助我们自动在intInteger之间转型:

1
2
Integer n = 100; // 编译器自动使用Integer.valueOf(int)
int x = n; // 编译器自动使用Integer.intValue()

这种直接把int变为Integer的赋值写法,称为自动装箱(Auto Boxing),反过来,把Integer变为int的赋值写法,称为自动拆箱(Auto Unboxing)。

注意

自动装箱和自动拆箱只发生在编译阶段,目的是为了少写代码。

装箱和拆箱会影响代码的执行效率,因为编译后的class代码是严格区分基本类型和引用类型的。并且,自动拆箱执行时可能会报NullPointerException

1
2
3
4
5
6
7
// NullPointerException
public class Main {
public static void main(String[] args) {
Integer n = null;
int i = n;
}
}

不变类

所有的包装类型都是不变类。我们查看Integer的源码可知,它的核心代码如下:

1
2
3
public final class Integer {
private final int value;
}

因此,一旦创建了Integer对象,该对象就是不变的。

对两个Integer实例进行比较要特别注意:绝对不能用==比较,因为Integer是引用类型,必须使用equals()比较:

1
2
3
4
5
6
7
8
9
10
11
12
13
// == or equals?
public class Main {
public static void main(String[] args) {
Integer x = 127;
Integer y = 127;
Integer m = 99999;
Integer n = 99999;
System.out.println("x == y: " + (x==y)); // true
System.out.println("m == n: " + (m==n)); // false
System.out.println("x.equals(y): " + x.equals(y)); // true
System.out.println("m.equals(n): " + m.equals(n)); // true
}
}

仔细观察结果的童鞋可以发现,==比较,较小的两个相同的Integer返回true,较大的两个相同的Integer返回false,这是因为Integer是不变类,编译器把Integer x = 127;自动变为Integer x = Integer.valueOf(127);,为了节省内存,Integer.valueOf()对于较小的数,始终返回相同的实例,因此,==比较“恰好”为true,但我们绝不能因为Java标准库的Integer内部有缓存优化就用==比较,必须用equals()方法比较两个Integer

最佳实践

按照语义编程,而不是针对特定的底层实现去“优化”。

因为Integer.valueOf()可能始终返回同一个Integer实例,因此,在我们自己创建Integer的时候,以下两种方法:

  • 方法1:Integer n = new Integer(100);
  • 方法2:Integer n = Integer.valueOf(100);

方法2更好,因为方法1总是创建新的Integer实例,方法2把内部优化留给Integer的实现者去做,即使在当前版本没有优化,也有可能在下一个版本进行优化。

我们把能创建“新”对象的静态方法称为静态工厂方法。Integer.valueOf()就是静态工厂方法,它尽可能地返回缓存的实例以节省内存。

最佳实践

创建新对象时,优先选用静态工厂方法而不是new操作符。

如果我们考察Byte.valueOf()方法的源码,可以看到,标准库返回的Byte实例全部是缓存实例,但调用者并不关心静态工厂方法以何种方式创建新实例还是直接返回缓存的实例。

进制转换

Integer类本身还提供了大量方法,例如,最常用的静态方法parseInt()可以把字符串解析成一个整数:

1
2
int x1 = Integer.parseInt("100"); // 100
int x2 = Integer.parseInt("100", 16); // 256,因为按16进制解析

Integer还可以把整数格式化为指定进制的字符串:

1
2
3
4
5
6
7
8
9
10
// Integer:
public class Main {
public static void main(String[] args) {
System.out.println(Integer.toString(100)); // "100",表示为10进制
System.out.println(Integer.toString(100, 36)); // "2s",表示为36进制
System.out.println(Integer.toHexString(100)); // "64",表示为16进制
System.out.println(Integer.toOctalString(100)); // "144",表示为8进制
System.out.println(Integer.toBinaryString(100)); // "1100100",表示为2进制
}
}

注意:上述方法的输出都是String,在计算机内存中,只用二进制表示,不存在十进制或十六进制的表示方法。int n = 100在内存中总是以4字节的二进制表示:

1
2
3
┌────────┬────────┬────────┬────────┐
│00000000│00000000│00000000│01100100│
└────────┴────────┴────────┴────────┘

我们经常使用的System.out.println(n);是依靠核心库自动把整数格式化为10进制输出并显示在屏幕上,使用Integer.toHexString(n)则通过核心库自动把整数格式化为16进制。

这里我们注意到程序设计的一个重要原则:数据的存储和显示要分离。

Java的包装类型还定义了一些有用的静态变量

1
2
3
4
5
6
7
8
9
// boolean只有两个值true/false,其包装类型只需要引用Boolean提供的静态字段:
Boolean t = Boolean.TRUE;
Boolean f = Boolean.FALSE;
// int可表示的最大/最小值:
int max = Integer.MAX_VALUE; // 2147483647
int min = Integer.MIN_VALUE; // -2147483648
// long类型占用的bit和byte数量:
int sizeOfLong = Long.SIZE; // 64 (bits)
int bytesOfLong = Long.BYTES; // 8 (bytes)

最后,所有的整数和浮点数的包装类型都继承自Number,因此,可以非常方便地直接通过包装类型获取各种基本类型:

1
2
3
4
5
6
7
8
// 向上转型为Number:
Number num = new Integer(999);
// 获取byte, int, long, float, double:
byte b = num.byteValue();
int n = num.intValue();
long ln = num.longValue();
float f = num.floatValue();
double d = num.doubleValue();

处理无符号整型

在Java中,并没有无符号整型(Unsigned)的基本数据类型。byteshortintlong都是带符号整型,最高位是符号位。而C语言则提供了CPU支持的全部数据类型,包括无符号整型。无符号整型和有符号整型的转换在Java中就需要借助包装类型的静态方法完成。

例如,byte是有符号整型,范围是-128~+127,但如果把byte看作无符号整型,它的范围就是0~255。我们把一个负的byte按无符号整型转换为int

1
2
3
4
5
6
7
8
9
// Byte
public class Main {
public static void main(String[] args) {
byte x = -1;
byte y = 127;
System.out.println(Byte.toUnsignedInt(x)); // 255
System.out.println(Byte.toUnsignedInt(y)); // 127
}
}

因为byte-1的二进制表示是11111111,以无符号整型转换后的int就是255

类似的,可以把一个short按unsigned转换为int,把一个int按unsigned转换为long

小结

Java核心库提供的包装类型可以把基本类型包装为class

自动装箱和自动拆箱都是在编译期完成的(JDK>=1.5);

装箱和拆箱会影响执行效率,且拆箱时可能发生NullPointerException

包装类型的比较必须使用equals()

整数和浮点数的包装类型都继承自Number

包装类型提供了大量实用方法。

在Java中,有很多class的定义都符合这样的规范:

  • 若干private实例字段;
  • 通过public方法来读写实例字段。

例如:

1
2
3
4
5
6
7
8
9
10
public class Person {
private String name;
private int age;

public String getName() { return this.name; }
public void setName(String name) { this.name = name; }

public int getAge() { return this.age; }
public void setAge(int age) { this.age = age; }
}

如果读写方法符合以下这种命名规范:

1
2
3
4
// 读方法:
public Type getXyz()
// 写方法:
public void setXyz(Type value)

那么这种class被称为JavaBean

java-bean

上面的字段是xyz,那么读写方法名分别以getset开头,并且后接大写字母开头的字段名Xyz,因此两个读写方法名分别是getXyz()setXyz()

boolean字段比较特殊,它的读方法一般命名为isXyz()

1
2
3
4
// 读方法:
public boolean isChild()
// 写方法:
public void setChild(boolean value)

我们通常把一组对应的读方法(getter)和写方法(setter)称为属性(property)。例如,name属性:

  • 对应的读方法是String getName()
  • 对应的写方法是setName(String)

只有getter的属性称为只读属性(read-only),例如,定义一个age只读属性:

  • 对应的读方法是int getAge()
  • 无对应的写方法setAge(int)

类似的,只有setter的属性称为只写属性(write-only)。

很明显,只读属性很常见,只写属性不常见。

属性只需要定义gettersetter方法,不一定需要对应的字段。例如,child只读属性定义如下:

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

public String getName() { return this.name; }
public void setName(String name) { this.name = name; }

public int getAge() { return this.age; }
public void setAge(int age) { this.age = age; }

public boolean isChild() {
return age <= 6;
}
}

可以看出,gettersetter也是一种数据封装的方法。

JavaBean的作用

JavaBean主要用来传递数据,即把一组数据组合成一个JavaBean便于传输。此外,JavaBean可以方便地被IDE工具分析,生成读写属性的代码,主要用在图形界面的可视化设计中。

通过IDE,可以快速生成gettersetter。例如,在Eclipse中,先输入以下代码:

1
2
3
4
public class Person {
private String name;
private int age;
}

然后,点击右键,在弹出的菜单中选择“Source”,“Generate Getters and Setters”,在弹出的对话框中选中需要生成gettersetter方法的字段,点击确定即可由IDE自动完成所有方法代码。

枚举JavaBean属性

要枚举一个JavaBean的所有属性,可以直接使用Java核心库提供的Introspector

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import java.beans.*;

public class Main {
public static void main(String[] args) throws Exception {
BeanInfo info = Introspector.getBeanInfo(Person.class);
for (PropertyDescriptor pd : info.getPropertyDescriptors()) {
System.out.println(pd.getName());
System.out.println(" " + pd.getReadMethod());
System.out.println(" " + pd.getWriteMethod());
}
}
}

class Person {
private String name;
private int age;

public String getName() {
return name;
}

public void setName(String name) {
this.name = name;
}

public int getAge() {
return age;
}

public void setAge(int age) {
this.age = age;
}
}

运行上述代码,可以列出所有的属性,以及对应的读写方法。注意class属性是从Object继承的getClass()方法带来的。

小结

JavaBean是一种符合命名规范的class,它通过gettersetter来定义属性;

属性是一种通用的叫法,并非Java语法规定;

可以利用IDE快速生成gettersetter

使用Introspector.getBeanInfo()可以获取属性列表。

在Java中,我们可以通过static final来定义常量。例如,我们希望定义周一到周日这7个常量,可以用7个不同的int表示:

1
2
3
4
5
6
7
8
9
public class Weekday {
public static final int SUN = 0;
public static final int MON = 1;
public static final int TUE = 2;
public static final int WED = 3;
public static final int THU = 4;
public static final int FRI = 5;
public static final int SAT = 6;
}

使用常量的时候,可以这么引用:

1
2
3
if (day == Weekday.SAT || day == Weekday.SUN) {
// TODO: work at home
}

也可以把常量定义为字符串类型,例如,定义3种颜色的常量:

1
2
3
4
5
public class Color {
public static final String RED = "r";
public static final String GREEN = "g";
public static final String BLUE = "b";
}

使用常量的时候,可以这么引用:

1
2
3
4
String color = ...
if (Color.RED.equals(color)) {
// TODO:
}

无论是int常量还是String常量,使用这些常量来表示一组枚举值的时候,有一个严重的问题就是,编译器无法检查每个值的合理性。例如:

1
2
3
4
5
if (weekday == 6 || weekday == 7) {
if (tasks == Weekday.MON) {
// TODO:
}
}

上述代码编译和运行均不会报错,但存在两个问题:

  • 注意到Weekday定义的常量范围是0~6,并不包含7,编译器无法检查不在枚举中的int值;
  • 定义的常量仍可与其他变量比较,但其用途并非是枚举星期值。

enum

为了让编译器能自动检查某个值在枚举的集合内,并且,不同用途的枚举需要不同的类型来标记,不能混用,我们可以使用enum来定义枚举类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// enum
public class Main {
public static void main(String[] args) {
Weekday day = Weekday.SUN;
if (day == Weekday.SAT || day == Weekday.SUN) {
System.out.println("Work at home!");
} else {
System.out.println("Work at office!");
}
}
}

enum Weekday {
SUN, MON, TUE, WED, THU, FRI, SAT;
}

注意到定义枚举类是通过关键字enum实现的,我们只需依次列出枚举的常量名。

int定义的常量相比,使用enum定义枚举有如下好处:

首先,enum常量本身带有类型信息,即Weekday.SUN类型是Weekday,编译器会自动检查出类型错误。例如,下面的语句不可能编译通过:

1
2
3
int day = 1;
if (day == Weekday.SUN) { // Compile error: bad operand types for binary operator '=='
}

其次,不可能引用到非枚举的值,因为无法通过编译。

最后,不同类型的枚举不能互相比较或者赋值,因为类型不符。例如,不能给一个Weekday枚举类型的变量赋值为Color枚举类型的值:

1
2
Weekday x = Weekday.SUN; // ok!
Weekday y = Color.RED; // Compile error: incompatible types

这就使得编译器可以在编译期自动检查出所有可能的潜在错误。

enum的比较

使用enum定义的枚举类是一种引用类型。前面我们讲到,引用类型比较,要使用equals()方法,如果使用==比较,它比较的是两个引用类型的变量是否是同一个对象。因此,引用类型比较,要始终使用equals()方法,但enum类型可以例外。

这是因为enum类型的每个常量在JVM中只有一个唯一实例,所以可以直接用==比较:

1
2
3
4
if (day == Weekday.FRI) { // ok!
}
if (day.equals(Weekday.SUN)) { // ok, but more code!
}

enum类型

通过enum定义的枚举类,和其他的class有什么区别?

答案是没有任何区别。enum定义的类型就是class,只不过它有以下几个特点:

  • 定义的enum类型总是继承自java.lang.Enum,且无法被继承;
  • 只能定义出enum的实例,而无法通过new操作符创建enum的实例;
  • 定义的每个实例都是引用类型的唯一实例;
  • 可以将enum类型用于switch语句。

例如,我们定义的Color枚举类:

1
2
3
public enum Color {
RED, GREEN, BLUE;
}

编译器编译出的class大概就像这样:

1
2
3
4
5
6
7
8
public final class Color extends Enum { // 继承自Enum,标记为final class
// 每个实例均为全局唯一:
public static final Color RED = new Color();
public static final Color GREEN = new Color();
public static final Color BLUE = new Color();
// private构造方法,确保外部无法调用new操作符:
private Color() {}
}

所以,编译后的enum类和普通class并没有任何区别。但是我们自己无法按定义普通class那样来定义enum,必须使用enum关键字,这是Java语法规定的。

因为enum是一个class,每个枚举的值都是class实例,因此,这些实例有一些方法:

name()

返回常量名,例如:

1
String s = Weekday.SUN.name(); // "SUN"

ordinal()

返回定义的常量的顺序,从0开始计数,例如:

1
int n = Weekday.MON.ordinal(); // 1

改变枚举常量定义的顺序就会导致ordinal()返回值发生变化。例如:

1
2
3
public enum Weekday {
SUN, MON, TUE, WED, THU, FRI, SAT;
}

1
2
3
public enum Weekday {
MON, TUE, WED, THU, FRI, SAT, SUN;
}

ordinal就是不同的。如果在代码中编写了类似if(x.ordinal()==1)这样的语句,就要保证enum的枚举顺序不能变。新增的常量必须放在最后。

有些童鞋会想,Weekday的枚举常量如果要和int转换,使用ordinal()不是非常方便?比如这样写:

1
2
String task = Weekday.MON.ordinal() + "/ppt";
saveToFile(task);

但是,如果不小心修改了枚举的顺序,编译器是无法检查出这种逻辑错误的。要编写健壮的代码,就不要依靠ordinal()的返回值。因为enum本身是class,所以我们可以定义private的构造方法,并且,给每个枚举常量添加字段:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// enum
public class Main {
public static void main(String[] args) {
Weekday day = Weekday.SUN;
if (day.dayValue == 6 || day.dayValue == 0) {
System.out.println("Work at home!");
} else {
System.out.println("Work at office!");
}
}
}

enum Weekday {
MON(1), TUE(2), WED(3), THU(4), FRI(5), SAT(6), SUN(0);

public final int dayValue;

private Weekday(int dayValue) {
this.dayValue = dayValue;
}
}

这样就无需担心顺序的变化,新增枚举常量时,也需要指定一个int值。

注意

枚举类的字段也可以是非final类型,即可以在运行期修改,但是不推荐这样做!

默认情况下,对枚举常量调用toString()会返回和name()一样的字符串。但是,toString()可以被覆写,而name()则不行。我们可以给Weekday添加toString()方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// enum
public class Main {
public static void main(String[] args) {
Weekday day = Weekday.SUN;
if (day.dayValue == 6 || day.dayValue == 0) {
System.out.println("Today is " + day + ". Work at home!");
} else {
System.out.println("Today is " + day + ". Work at office!");
}
}
}

enum Weekday {
MON(1, "星期一"), TUE(2, "星期二"), WED(3, "星期三"), THU(4, "星期四"), FRI(5, "星期五"), SAT(6, "星期六"), SUN(0, "星期日");

public final int dayValue;
private final String chinese;

private Weekday(int dayValue, String chinese) {
this.dayValue = dayValue;
this.chinese = chinese;
}

@Override
public String toString() {
return this.chinese;
}
}

覆写toString()的目的是在输出时更有可读性。

注意

判断枚举常量的名字,要始终使用name()方法,绝不能调用toString()!

switch

最后,枚举类可以应用在switch语句中。因为枚举类天生具有类型信息和有限个枚举常量,所以比intString类型更适合用在switch语句中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// switch
public class Main {
public static void main(String[] args) {
Weekday day = Weekday.SUN;
switch(day) {
case MON:
case TUE:
case WED:
case THU:
case FRI:
System.out.println("Today is " + day + ". Work at office!");
break;
case SAT:
case SUN:
System.out.println("Today is " + day + ". Work at home!");
break;
default:
throw new RuntimeException("cannot process " + day);
}
}
}

enum Weekday {
MON, TUE, WED, THU, FRI, SAT, SUN;
}

加上default语句,可以在漏写某个枚举常量时自动报错,从而及时发现错误。

小结

Java使用enum定义枚举类型,它被编译器编译为final class Xxx extends Enum { … }

通过name()获取常量定义的字符串,注意不要使用toString()

通过ordinal()返回常量定义的顺序(无实质意义);

可以为enum编写构造方法、字段和方法

enum的构造方法要声明为private,字段强烈建议声明为final

enum适合用在switch语句中。

使用StringInteger等类型的时候,这些类型都是不变类,一个不变类具有以下特点:

  1. 定义class时使用final,无法派生子类;
  2. 每个字段使用final,保证创建实例后无法修改任何字段。

假设我们希望定义一个Point类,有xy两个变量,同时它是一个不变类,可以这么写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public final class Point {
private final int x;
private final int y;

public Point(int x, int y) {
this.x = x;
this.y = y;
}

public int x() {
return this.x;
}

public int y() {
return this.y;
}
}

为了保证不变类的比较,还需要正确覆写equals()hashCode()方法,这样才能在集合类中正常使用。后续我们会详细讲解正确覆写equals()hashCode(),这里演示Point不变类的写法目的是,这些代码写起来都非常简单,但是很繁琐。

record

从Java 14开始,引入了新的Record类。我们定义Record类时,使用关键字record。把上述Point类改写为Record类,代码如下:

1
2
3
4
5
6
7
8
9
10
11
// Record
public class Main {
public static void main(String[] args) {
Point p = new Point(123, 456);
System.out.println(p.x());
System.out.println(p.y());
System.out.println(p);
}
}

record Point(int x, int y) {}

仔细观察Point的定义:

1
record Point(int x, int y) {}

把上述定义改写为class,相当于以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
final class Point extends Record {
private final int x;
private final int y;

public Point(int x, int y) {
this.x = x;
this.y = y;
}

public int x() {
return this.x;
}

public int y() {
return this.y;
}

public String toString() {
return String.format("Point[x=%s, y=%s]", x, y);
}

public boolean equals(Object o) {
...
}
public int hashCode() {
...
}
}

除了用final修饰class以及每个字段外,编译器还自动为我们创建了构造方法,和字段名同名的方法,以及覆写toString()equals()hashCode()方法。

换句话说,使用record关键字,可以一行写出一个不变类。

enum类似,我们自己不能直接从Record派生,只能通过record关键字由编译器实现继承。

构造方法

编译器默认按照record声明的变量顺序自动创建一个构造方法,并在方法内给字段赋值。那么问题来了,如果我们要检查参数,应该怎么办?

假设Point类的xy不允许负数,我们就得给Point的构造方法加上检查逻辑:

1
2
3
4
5
6
7
public record Point(int x, int y) {
public Point {
if (x < 0 || y < 0) {
throw new IllegalArgumentException();
}
}
}

注意到方法public Point {...}被称为Compact Constructor,它的目的是让我们编写检查逻辑,编译器最终生成的构造方法如下:

1
2
3
4
5
6
7
8
9
10
11
12
public final class Point extends Record {
public Point(int x, int y) {
// 这是我们编写的Compact Constructor:
if (x < 0 || y < 0) {
throw new IllegalArgumentException();
}
// 这是编译器继续生成的赋值代码:
this.x = x;
this.y = y;
}
...
}

作为recordPoint仍然可以添加静态方法。一种常用的静态方法是of()方法,用来创建Point

1
2
3
4
5
6
7
8
public record Point(int x, int y) {
public static Point of() {
return new Point(0, 0);
}
public static Point of(int x, int y) {
return new Point(x, y);
}
}

这样我们可以写出更简洁的代码:

1
2
var z = Point.of();
var p = Point.of(123, 456);

小结

从Java 14开始,提供新的record关键字,可以非常方便地定义Data Class:

  • 使用record定义的是不变类;
  • 可以编写Compact Constructor对参数进行验证;
  • 可以定义静态方法。

BigInteger

在Java中,由CPU原生提供的整型最大范围是64位long型整数。使用long型整数可以直接通过CPU指令进行计算,速度非常快。

如果我们使用的整数范围超过了long型怎么办?这个时候,就只能用软件来模拟一个大整数。java.math.BigInteger就是用来表示任意大小的整数。BigInteger内部用一个int[]数组来模拟一个非常大的整数:

1
2
BigInteger bi = new BigInteger("1234567890");
System.out.println(bi.pow(5)); // 2867971860299718107233761438093672048294900000

BigInteger做运算的时候,只能使用实例方法,例如,加法运算:

1
2
3
BigInteger i1 = new BigInteger("1234567890");
BigInteger i2 = new BigInteger("12345678901234567890");
BigInteger sum = i1.add(i2); // 12345678902469135780

long型整数运算比,BigInteger不会有范围限制,但缺点是速度比较慢。

也可以把BigInteger转换成long型:

1
2
3
BigInteger i = new BigInteger("123456789000");
System.out.println(i.longValue()); // 123456789000
System.out.println(i.multiply(i).longValueExact()); // java.lang.ArithmeticException: BigInteger out of long range

使用longValueExact()方法时,如果超出了long型的范围,会抛出ArithmeticException

BigIntegerIntegerLong一样,也是不可变类,并且也继承自Number类。因为Number定义了转换为基本类型的几个方法:

  • 转换为bytebyteValue()
  • 转换为shortshortValue()
  • 转换为intintValue()
  • 转换为longlongValue()
  • 转换为floatfloatValue()
  • 转换为doubledoubleValue()

因此,通过上述方法,可以把BigInteger转换成基本类型。如果BigInteger表示的范围超过了基本类型的范围,转换时将丢失高位信息,即结果不一定是准确的。如果需要准确地转换成基本类型,可以使用intValueExact()longValueExact()等方法,在转换时如果超出范围,将直接抛出ArithmeticException异常。

如果BigInteger的值甚至超过了float的最大范围(3.4x1038),那么返回的float是什么呢?

1
2
3
4
5
6
7
8
9
10
// BigInteger to float
import java.math.BigInteger;

public class Main {
public static void main(String[] args) {
BigInteger n = new BigInteger("999999").pow(99);
float f = n.floatValue();
System.out.println(f); // Infinity
}
}

小结

BigInteger用于表示任意大小的整数;

BigInteger是不变类,并且继承自Number

BigInteger转换成基本类型时可使用longValueExact()等方法保证结果准确。



BigInteger类似,BigDecimal可以表示一个任意大小且精度完全准确的浮点数。

1
2
BigDecimal bd = new BigDecimal("123.4567");
System.out.println(bd.multiply(bd)); // 15241.55677489

BigDecimalscale()表示小数位数,例如:

1
2
3
4
5
6
BigDecimal d1 = new BigDecimal("123.45");
BigDecimal d2 = new BigDecimal("123.4500");
BigDecimal d3 = new BigDecimal("1234500");
System.out.println(d1.scale()); // 2,两位小数
System.out.println(d2.scale()); // 4
System.out.println(d3.scale()); // 0

通过BigDecimalstripTrailingZeros()方法,可以将一个BigDecimal格式化为一个相等的,但去掉了末尾0的BigDecimal

1
2
3
4
5
6
7
8
9
BigDecimal d1 = new BigDecimal("123.4500");
BigDecimal d2 = d1.stripTrailingZeros();
System.out.println(d1.scale()); // 4
System.out.println(d2.scale()); // 2,因为去掉了00

BigDecimal d3 = new BigDecimal("1234500");
BigDecimal d4 = d3.stripTrailingZeros();
System.out.println(d3.scale()); // 0
System.out.println(d4.scale()); // -2

如果一个BigDecimalscale()返回负数,例如,-2,表示这个数是个整数,并且末尾有2个0。

可以对一个BigDecimal设置它的scale,如果精度比原始值低,那么按照指定的方法进行四舍五入或者直接截断:

1
2
3
4
5
6
7
8
9
10
11
12
import java.math.BigDecimal;
import java.math.RoundingMode;
----
public class Main {
public static void main(String[] args) {
BigDecimal d1 = new BigDecimal("123.456789");
BigDecimal d2 = d1.setScale(4, RoundingMode.HALF_UP); // 四舍五入,123.4568
BigDecimal d3 = d1.setScale(4, RoundingMode.DOWN); // 直接截断,123.4567
System.out.println(d2);
System.out.println(d3);
}
}

BigDecimal做加、减、乘时,精度不会丢失,但是做除法时,存在无法除尽的情况,这时,就必须指定精度以及如何进行截断:

1
2
3
4
BigDecimal d1 = new BigDecimal("123.456");
BigDecimal d2 = new BigDecimal("23.456789");
BigDecimal d3 = d1.divide(d2, 10, RoundingMode.HALF_UP); // 保留10位小数并四舍五入
BigDecimal d4 = d1.divide(d2); // 报错:ArithmeticException,因为除不尽

还可以对BigDecimal做除法的同时求余数:

1
2
3
4
5
6
7
8
9
10
11
import java.math.BigDecimal;
----
public class Main {
public static void main(String[] args) {
BigDecimal n = new BigDecimal("12.345");
BigDecimal m = new BigDecimal("0.12");
BigDecimal[] dr = n.divideAndRemainder(m);
System.out.println(dr[0]); // 102
System.out.println(dr[1]); // 0.105
}
}

调用divideAndRemainder()方法时,返回的数组包含两个BigDecimal,分别是商和余数,其中商总是整数,余数不会大于除数。我们可以利用这个方法判断两个BigDecimal是否是整数倍数:

1
2
3
4
5
6
BigDecimal n = new BigDecimal("12.75");
BigDecimal m = new BigDecimal("0.15");
BigDecimal[] dr = n.divideAndRemainder(m);
if (dr[1].signum() == 0) {
// n是m的整数倍
}

比较BigDecimal

在比较两个BigDecimal的值是否相等时,要特别注意,使用equals()方法不但要求两个BigDecimal的值相等,还要求它们的scale()相等:

1
2
3
4
5
BigDecimal d1 = new BigDecimal("123.456");
BigDecimal d2 = new BigDecimal("123.45600");
System.out.println(d1.equals(d2)); // false,因为scale不同
System.out.println(d1.equals(d2.stripTrailingZeros())); // true,因为d2去除尾部0后scale变为3
System.out.println(d1.compareTo(d2)); // 0 = 相等, -1 = d1 < d2, 1 = d1 > d2

必须使用compareTo()方法来比较,它根据两个值的大小分别返回负数、正数和0,分别表示小于、大于和等于。

注意

总是使用compareTo()比较两个BigDecimal的值,不要使用equals()!

如果查看BigDecimal的源码,可以发现,实际上一个BigDecimal是通过一个BigInteger和一个scale来表示的,即BigInteger表示一个完整的整数,而scale表示小数位数:

1
2
3
4
public class BigDecimal extends Number implements Comparable<BigDecimal> {
private final BigInteger intVal;
private final int scale;
}

BigDecimal也是从Number继承的,也是不可变对象。

小结

BigDecimal用于表示精确的小数,常用于财务计算;

比较BigDecimal的值是否相等,必须使用compareTo()而不能使用equals()

Java的核心库提供了大量的现成的类供我们使用。本节我们介绍几个常用的工具类。

Math

顾名思义,Math类就是用来进行数学计算的,它提供了大量的静态方法来便于我们实现数学计算:

求绝对值:

1
2
Math.abs(-100); // 100
Math.abs(-7.8); // 7.8

取最大或最小值:

1
2
Math.max(100, 99); // 100
Math.min(1.2, 2.3); // 1.2

计算xy次方:

1
Math.pow(2, 10); // 2的10次方=1024

计算 x\sqrt 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
2
3
4
5
Math.sin(3.14); // 0.00159...
Math.cos(3.14); // -0.9999...
Math.tan(3.14); // -0.0015...
Math.asin(1.0); // 1.57079...
Math.acos(1.0); // 0.0

Math还提供了几个数学常量:

1
2
3
double pi = Math.PI; // 3.14159...
double e = Math.E; // 2.7182818...
Math.sin(Math.PI / 6); // sin(π/6) = 0.5

生成一个随机数x,x的范围是0 <= x < 1

1
Math.random(); // 0.53907... 每次都不一样

如果我们要生成一个区间在[MIN, MAX)的随机数,可以借助Math.random()实现,计算如下:

1
2
3
4
5
6
7
8
9
10
11
12
// 区间在[MIN, MAX)的随机数
public class Main {
public static void main(String[] args) {
double x = Math.random(); // x的范围是[0,1)
double min = 10;
double max = 50;
double y = x * (max - min) + min; // y的范围是[10,50)
long n = (long) y; // n的范围是[10,50)的整数
System.out.println(y);
System.out.println(n);
}
}

有些同学可能注意到Java标准库还提供了一个StrictMath,它提供了和Math几乎一模一样的方法。这两个类的区别在于,由于浮点数计算存在误差,不同的平台(例如x86和ARM)计算的结果可能不一致(指误差不同),因此,StrictMath保证所有平台计算结果都是完全相同的,而Math会尽量针对平台优化计算速度,所以,绝大多数情况下,使用Math就足够了。

HexFormat

在处理byte[]数组时,我们经常需要与十六进制字符串转换,自己写起来比较麻烦,用Java标准库提供的HexFormat则可以方便地帮我们转换。

要将byte[]数组转换为十六进制字符串,可以用formatHex()方法:

1
2
3
4
5
6
7
8
9
import java.util.HexFormat;

public class Main {
public static void main(String[] args) throws InterruptedException {
byte[] data = "Hello".getBytes();
HexFormat hf = HexFormat.of();
String hexData = hf.formatHex(data); // 48656c6c6f
}
}

如果要定制转换格式,则使用定制的HexFormat实例:

1
2
3
// 分隔符为空格,添加前缀0x,大写字母:
HexFormat hf = HexFormat.ofDelimiter(" ").withPrefix("0x").withUpperCase();
hf.formatHex("Hello".getBytes())); // 0x48 0x65 0x6C 0x6C 0x6F

从十六进制字符串到byte[]数组转换,使用parseHex()方法:

1
byte[] bs = HexFormat.of().parseHex("48656c6c6f");

Random

Random用来创建伪随机数。所谓伪随机数,是指只要给定一个初始的种子,产生的随机数序列是完全一样的。

要生成一个随机数,可以使用nextInt()nextLong()nextFloat()nextDouble()

1
2
3
4
5
6
Random r = new Random();
r.nextInt(); // 2071575453,每次都不一样
r.nextInt(10); // 5,生成一个[0,10)之间的int
r.nextLong(); // 8811649292570369305,每次都不一样
r.nextFloat(); // 0.54335...生成一个[0,1)之间的float
r.nextDouble(); // 0.3716...生成一个[0,1)之间的double

有童鞋问,每次运行程序,生成的随机数都是不同的,没看出伪随机数的特性来。

这是因为我们创建Random实例时,如果不给定种子,就使用系统当前时间戳作为种子,因此每次运行时,种子不同,得到的伪随机数序列就不同。

如果我们在创建Random实例时指定一个种子,就会得到完全确定的随机数序列:

1
2
3
4
5
6
7
8
9
10
11
import java.util.Random;

public class Main {
public static void main(String[] args) {
Random r = new Random(12345);
for (int i = 0; i < 10; i++) {
System.out.println(r.nextInt(100));
}
// 51, 80, 41, 28, 55...
}
}

前面我们使用的Math.random()实际上内部调用了Random类,所以它也是伪随机数,只是我们无法指定种子。

SecureRandom

有伪随机数,就有真随机数。实际上真正的真随机数只能通过量子力学原理来获取,而我们想要的是一个不可预测的安全的随机数,SecureRandom就是用来创建安全的随机数的:

1
2
SecureRandom sr = new SecureRandom();
System.out.println(sr.nextInt(100));

SecureRandom无法指定种子,它使用RNG(random number generator)算法。JDK的SecureRandom实际上有多种不同的底层实现,有的使用安全随机种子加上伪随机数算法来产生安全的随机数,有的使用真正的随机数生成器。实际使用的时候,可以优先获取高强度的安全随机数生成器,如果没有提供,再使用普通等级的安全随机数生成器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import java.util.Arrays;
import java.security.SecureRandom;
import java.security.NoSuchAlgorithmException;

public class Main {
public static void main(String[] args) {
SecureRandom sr = null;
try {
sr = SecureRandom.getInstanceStrong(); // 获取高强度安全随机数生成器
} catch (NoSuchAlgorithmException e) {
sr = new SecureRandom(); // 获取普通的安全随机数生成器
}
byte[] buffer = new byte[16];
sr.nextBytes(buffer); // 用安全随机数填充buffer
System.out.println(Arrays.toString(buffer));
}
}

SecureRandom的安全性是通过操作系统提供的安全的随机种子来生成随机数。这个种子是通过CPU的热噪声、读写磁盘的字节、网络流量等各种随机事件产生的“熵”。

在密码学中,安全的随机数非常重要。如果使用不安全的伪随机数,所有加密体系都将被攻破。因此,时刻牢记必须使用SecureRandom来产生安全的随机数。

注意

需要使用安全随机数的时候,必须使用SecureRandom,绝不能使用Random!

小结

Java提供的常用工具类有:

  • Math:数学计算
  • HexFormat:格式化十六进制数
  • Random:生成伪随机数
  • SecureRandom:生成安全的随机数

留言與分享

JAVA-面向对象编程-基础

分類 编程语言, Java

Java是一种面向对象的编程语言。面向对象编程,英文是Object-Oriented Programming,简称OOP。

那什么是面向对象编程?

和面向对象编程不同的,是面向过程编程。面向过程编程,是把模型分解成一步一步的过程。比如,老板告诉你,要编写一个TODO任务,必须按照以下步骤一步一步来:

  1. 读取文件;
  2. 编写TODO;
  3. 保存文件。

而面向对象编程,顾名思义,你得首先有个对象:

有了对象后,就可以和对象进行互动:

1
2
3
GirlFriend gf = new GirlFriend();
gf.name = "Alice";
gf.send("flowers");

因此,面向对象编程,是一种通过对象的方式,把现实世界映射到计算机模型的一种编程方法。

在本章中,我们将讨论:

面向对象的基本概念,包括:

  • 实例
  • 方法

面向对象的实现方式,包括:

  • 继承
  • 多态

Java语言本身提供的机制,包括:

  • package
  • classpath
  • jar

以及Java标准库提供的核心类,包括:

  • 字符串
  • 包装类型
  • JavaBean
  • 枚举
  • 常用工具类

通过本章的学习,完全可以理解并掌握面向对象的基本思想,但不保证能找到对象。


面向对象编程,是一种通过对象的方式,把现实世界映射到计算机模型的一种编程方法。

现实世界中,我们定义了“人”这种抽象概念,而具体的人则是“小明”、“小红”、“小军”等一个个具体的人。所以,“人”可以定义为一个类(class),而具体的人则是实例(instance):

现实世界 计算机模型 Java代码
类 / class class Person { }
小明 实例 / ming Person ming = new Person()
小红 实例 / hong Person hong = new Person()
小军 实例 / jun Person jun = new Person()

同样的,“书”也是一种抽象的概念,所以它是类,而《Java核心技术》、《Java编程思想》、《Java学习笔记》则是实例:

现实世界 计算机模型 Java代码
类 / class class Book { }
Java核心技术 实例 / book1 Book book1 = new Book()
Java编程思想 实例 / book2 Book book2 = new Book()
Java学习笔记 实例 / book3 Book book3 = new Book()

class和instance

所以,只要理解了class和instance的概念,基本上就明白了什么是面向对象编程。

class是一种对象模版,它定义了如何创建实例,因此,class本身就是一种数据类型:

class

而instance是对象实例,instance是根据class创建的实例,可以创建多个instance,每个instance类型相同,但各自属性可能不相同:

instances

定义class

在Java中,创建一个类,例如,给这个类命名为Person,就是定义一个class

1
2
3
4
class Person {
public String name;
public int age;
}

一个class可以包含多个字段(field),字段用来描述一个类的特征。上面的Person类,我们定义了两个字段,一个是String类型的字段,命名为name,一个是int类型的字段,命名为age。因此,通过class,把一组数据汇集到一个对象上,实现了数据封装。

public是用来修饰字段的,它表示这个字段可以被外部访问。

我们再看另一个Book类的定义:

1
2
3
4
5
6
class Book {
public String name;
public String author;
public String isbn;
public double price;
}

请指出Book类的各个字段。

创建实例

定义了class,只是定义了对象模版,而要根据对象模版创建出真正的对象实例,必须用new操作符。

new操作符可以创建一个实例,然后,我们需要定义一个引用类型的变量来指向这个实例:

1
Person ming = new Person();

上述代码创建了一个Person类型的实例,并通过变量ming指向它。

注意区分Person ming是定义Person类型的变量ming,而new Person()是创建Person实例。

有了指向这个实例的变量,我们就可以通过这个变量来操作实例。访问实例变量可以用变量.字段,例如:

1
2
3
4
5
6
7
ming.name = "Xiao Ming"; // 对字段name赋值
ming.age = 12; // 对字段age赋值
System.out.println(ming.name); // 访问字段name

Person hong = new Person();
hong.name = "Xiao Hong";
hong.age = 15;

上述两个变量分别指向两个不同的实例,它们在内存中的结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
            ┌──────────────────┐
ming ──────▶│Person instance │
├──────────────────┤
│name = "Xiao Ming"│
│age = 12 │
└──────────────────┘
┌──────────────────┐
hong ──────▶│Person instance │
├──────────────────┤
│name = "Xiao Hong"│
│age = 15 │
└──────────────────┘

两个instance拥有class定义的nameage字段,且各自都有一份独立的数据,互不干扰。

注意

一个Java源文件可以包含多个类的定义,但只能定义一个public类,且public类名必须与文件名一致。如果要定义多个public类,必须拆到多个Java源文件中。

练习

请定义一个City类,该class具有如下字段:

  • name: 名称,String类型
  • latitude: 纬度,double类型
  • longitude: 经度,double类型

实例化几个City并赋值,然后打印。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// City
public class Main {
public static void main(String[] args) {
City bj = new City();
bj.name = "Beijing";
bj.latitude = 39.903;
bj.longitude = 116.401;
System.out.println(bj.name);
System.out.println("location: " + bj.latitude + ", " + bj.longitude);
}
}

class City {
???
}

小结

在OOP中,classinstance是“模版”和“实例”的关系;

定义class就是定义了一种数据类型,对应的instance是这种数据类型的实例;

class定义的field,在每个instance都会拥有各自的field,且互不干扰;

通过new操作符创建新的instance,然后用变量指向它,即可通过变量来引用这个instance

访问实例字段的方法是变量名.字段名

指向instance的变量都是引用变量。

一个class可以包含多个field,例如,我们给Person类就定义了两个field

1
2
3
4
class Person {
public String name;
public int age;
}

但是,直接把fieldpublic暴露给外部可能会破坏封装性。比如,代码可以这样写:

1
2
3
Person ming = new Person();
ming.name = "Xiao Ming";
ming.age = -99; // age设置为负数

显然,直接操作field,容易造成逻辑混乱。为了避免外部代码直接去访问field,我们可以用private修饰field,拒绝外部访问:

1
2
3
4
class Person {
private String name;
private int age;
}

试试private修饰的field有什么效果:

1
2
3
4
5
6
7
8
9
10
11
12
13
// private field
public class Main {
public static void main(String[] args) {
Person ming = new Person();
ming.name = "Xiao Ming"; // 对字段name赋值
ming.age = 12; // 对字段age赋值
}
}

class Person {
private String name;
private int age;
}

是不是编译报错?把访问field的赋值语句去了就可以正常编译了。

fieldpublic改成private,外部代码不能访问这些field,那我们定义这些field有什么用?怎么才能给它赋值?怎么才能读取它的值?

所以我们需要使用方法(method)来让外部代码可以间接修改field

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// private field
public class Main {
public static void main(String[] args) {
Person ming = new Person();
ming.setName("Xiao Ming"); // 设置name
ming.setAge(12); // 设置age
System.out.println(ming.getName() + ", " + ming.getAge());
}
}

class Person {
private String name;
private int age;

public String getName() {
return this.name;
}

public void setName(String name) {
this.name = name;
}

public int getAge() {
return this.age;
}

public void setAge(int age) {
if (age < 0 || age > 100) {
throw new IllegalArgumentException("invalid age value");
}
this.age = age;
}
}

虽然外部代码不能直接修改private字段,但是,外部代码可以调用方法setName()setAge()来间接修改private字段。在方法内部,我们就有机会检查参数对不对。比如,setAge()就会检查传入的参数,参数超出了范围,直接报错。这样,外部代码就没有任何机会把age设置成不合理的值。

setName()方法同样可以做检查,例如,不允许传入null和空字符串:

1
2
3
4
5
6
public void setName(String name) {
if (name == null || name.isBlank()) {
throw new IllegalArgumentException("invalid name");
}
this.name = name.strip(); // 去掉首尾空格
}

同样,外部代码不能直接读取private字段,但可以通过getName()getAge()间接获取private字段的值。

所以,一个类通过定义方法,就可以给外部代码暴露一些操作的接口,同时,内部自己保证逻辑一致性。

调用方法的语法是实例变量.方法名(参数);。一个方法调用就是一个语句,所以不要忘了在末尾加;。例如:ming.setName("Xiao Ming");

定义方法

从上面的代码可以看出,定义方法的语法是:

1
2
3
4
修饰符 方法返回类型 方法名(方法参数列表) {
若干方法语句;
return 方法返回值;
}

方法返回值通过return语句实现,如果没有返回值,返回类型设置为void,可以省略return

private方法

public方法,自然就有private方法。和private字段一样,private方法不允许外部调用,那我们定义private方法有什么用?

定义private方法的理由是内部方法是可以调用private方法的。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// private method
public class Main {
public static void main(String[] args) {
Person ming = new Person();
ming.setBirth(2008);
System.out.println(ming.getAge());
}
}

class Person {
private String name;
private int birth;

public void setBirth(int birth) {
this.birth = birth;
}

public int getAge() {
return calcAge(2019); // 调用private方法
}

// private方法:
private int calcAge(int currentYear) {
return currentYear - this.birth;
}
}

观察上述代码,calcAge()是一个private方法,外部代码无法调用,但是,内部方法getAge()可以调用它。

此外,我们还注意到,这个Person类只定义了birth字段,没有定义age字段,获取age时,通过方法getAge()返回的是一个实时计算的值,并非存储在某个字段的值。这说明方法可以封装一个类的对外接口,调用方不需要知道也不关心Person实例在内部到底有没有age字段。

this变量

在方法内部,可以使用一个隐含的变量this,它始终指向当前实例。因此,通过this.field就可以访问当前实例的字段。

如果没有命名冲突,可以省略this。例如:

1
2
3
4
5
6
7
class Person {
private String name;

public String getName() {
return name; // 相当于this.name
}
}

但是,如果有局部变量和字段重名,那么局部变量优先级更高,就必须加上this

1
2
3
4
5
6
7
class Person {
private String name;

public void setName(String name) {
this.name = name; // 前面的this不可少,少了就变成局部变量name了
}
}

方法参数

方法可以包含0个或任意个参数。方法参数用于接收传递给方法的变量值。调用方法时,必须严格按照参数的定义一一传递。例如:

1
2
3
4
5
6
class Person {
...
public void setNameAndAge(String name, int age) {
...
}
}

调用这个setNameAndAge()方法时,必须有两个参数,且第一个参数必须为String,第二个参数必须为int

1
2
3
Person ming = new Person();
ming.setNameAndAge("Xiao Ming"); // 编译错误:参数个数不对
ming.setNameAndAge(12, "Xiao Ming"); // 编译错误:参数类型不对

可变参数

可变参数用类型...定义,可变参数相当于数组类型:

1
2
3
4
5
6
7
class Group {
private String[] names;

public void setNames(String... names) {
this.names = names;
}
}

上面的setNames()就定义了一个可变参数。调用时,可以这么写:

1
2
3
4
5
Group g = new Group();
g.setNames("Xiao Ming", "Xiao Hong", "Xiao Jun"); // 传入3个String
g.setNames("Xiao Ming", "Xiao Hong"); // 传入2个String
g.setNames("Xiao Ming"); // 传入1个String
g.setNames(); // 传入0个String

完全可以把可变参数改写为String[]类型:

1
2
3
4
5
6
7
class Group {
private String[] names;

public void setNames(String[] names) {
this.names = names;
}
}

但是,调用方需要自己先构造String[],比较麻烦。例如:

1
2
Group g = new Group();
g.setNames(new String[] {"Xiao Ming", "Xiao Hong", "Xiao Jun"}); // 传入1个String[]

另一个问题是,调用方可以传入null

1
2
Group g = new Group();
g.setNames(null);

而可变参数可以保证无法传入null,因为传入0个参数时,接收到的实际值是一个空数组而不是null

参数绑定

调用方把参数传递给实例方法时,调用时传递的值会按参数位置一一绑定。

那什么是参数绑定?

我们先观察一个基本类型参数的传递:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 基本类型参数绑定
public class Main {
public static void main(String[] args) {
Person p = new Person();
int n = 15; // n的值为15
p.setAge(n); // 传入n的值
System.out.println(p.getAge()); // 15
n = 20; // n的值改为20
System.out.println(p.getAge()); // 15还是20?
}
}

class Person {
private int age;

public int getAge() {
return this.age;
}

public void setAge(int age) {
this.age = age;
}
}

运行代码,从结果可知,修改外部的局部变量n,不影响实例page字段,原因是setAge()方法获得的参数,复制了n的值,因此,p.age和局部变量n互不影响。

结论:基本类型参数的传递,是调用方值的复制。双方各自的后续修改,互不影响。

我们再看一个传递引用参数的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 引用类型参数绑定
public class Main {
public static void main(String[] args) {
Person p = new Person();
String[] fullname = new String[] { "Homer", "Simpson" };
p.setName(fullname); // 传入fullname数组
System.out.println(p.getName()); // "Homer Simpson"
fullname[0] = "Bart"; // fullname数组的第一个元素修改为"Bart"
System.out.println(p.getName()); // "Homer Simpson"还是"Bart Simpson"?
}
}

class Person {
private String[] name;

public String getName() {
return this.name[0] + " " + this.name[1];
}

public void setName(String[] name) {
this.name = name;
}
}

注意到setName()的参数现在是一个数组。一开始,把fullname数组传进去,然后,修改fullname数组的内容,结果发现,实例p的字段p.name也被修改了!

结论

引用类型参数的传递,调用方的变量,和接收方的参数变量,指向的是同一个对象。双方任意一方对这个对象的修改,都会影响对方(因为指向同一个对象嘛)。

有了上面的结论,我们再看一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 引用类型参数绑定
public class Main {
public static void main(String[] args) {
Person p = new Person();
String bob = "Bob";
p.setName(bob); // 传入bob变量
System.out.println(p.getName()); // "Bob"
bob = "Alice"; // bob改名为Alice
System.out.println(p.getName()); // "Bob"还是"Alice"?
}
}

class Person {
private String name;

public String getName() {
return this.name;
}

public void setName(String name) {
this.name = name;
}
}

不要怀疑引用参数绑定的机制,试解释为什么上面的代码两次输出都是"Bob"

练习

Person类增加getAge/setAge方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class Main {
public static void main(String[] args) {
Person ming = new Person();
ming.setName("小明");
ming.setAge(12);
System.out.println(ming.getAge());
}
}

class Person {
private String name;

public String getName() {
return name;
}

public void setName(String name) {
this.name = name;
}
}

下载练习

小结

  • 方法可以让外部代码安全地访问实例字段;

  • 方法是一组执行语句,并且可以执行任意逻辑;

  • 方法内部遇到return时返回,void表示不返回任何值(注意和返回null不同);

  • 外部代码通过public方法操作实例,内部代码可以调用private方法;

  • 理解方法的参数绑定。

方法重载

在一个类中,我们可以定义多个方法。如果有一系列方法,它们的功能都是类似的,只有参数有所不同,那么,可以把这一组方法名做成同名方法。例如,在Hello类中,定义多个hello()方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Hello {
public void hello() {
System.out.println("Hello, world!");
}

public void hello(String name) {
System.out.println("Hello, " + name + "!");
}

public void hello(String name, int age) {
if (age < 18) {
System.out.println("Hi, " + name + "!");
} else {
System.out.println("Hello, " + name + "!");
}
}
}

这种方法名相同,但各自的参数不同,称为方法重载(Overload)。

注意:方法重载的返回值类型通常都是相同的。

方法重载的目的是,功能类似的方法使用同一名字,更容易记住,因此,调用起来更简单。

举个例子,String类提供了多个重载方法indexOf(),可以查找子串:

  • int indexOf(int ch):根据字符的Unicode码查找;
  • int indexOf(String str):根据字符串查找;
  • int indexOf(int ch, int fromIndex):根据字符查找,但指定起始位置;
  • int indexOf(String str, int fromIndex)根据字符串查找,但指定起始位置。

试一试:

1
2
3
4
5
6
7
8
9
10
11
12
// String.indexOf()
public class Main {
public static void main(String[] args) {
String s = "Test string";
int n1 = s.indexOf('t');
int n2 = s.indexOf("st");
int n3 = s.indexOf("st", 4);
System.out.println(n1);
System.out.println(n2);
System.out.println(n3);
}
}

练习

Person增加重载方法setName(String, String)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class Main {
public static void main(String[] args) {
Person ming = new Person();
Person hong = new Person();
ming.setName("Xiao Ming");
// TODO: 给Person增加重载方法setName(String, String):
hong.setName("Xiao", "Hong");
System.out.println(ming.getName());
System.out.println(hong.getName());
}
}

class Person {
private String name;

public String getName() {
return name;
}

public void setName(String name) {
this.name = name;
}
}

下载练习

小结

方法重载是指多个方法的方法名相同,但各自的参数不同;

重载方法应该完成类似的功能,参考StringindexOf()

重载方法返回值类型应该相同。



在前面的章节中,我们已经定义了Person类:

1
2
3
4
5
6
7
8
9
class Person {
private String name;
private int age;

public String getName() {...}
public void setName(String name) {...}
public int getAge() {...}
public void setAge(int age) {...}
}

现在,假设需要定义一个Student类,字段如下:

1
2
3
4
5
6
7
8
9
10
11
12
class Student {
private String name;
private int age;
private int score;

public String getName() {...}
public void setName(String name) {...}
public int getAge() {...}
public void setAge(int age) {...}
public int getScore() { … }
public void setScore(int score) { … }
}

仔细观察,发现Student类包含了Person类已有的字段和方法,只是多出了一个score字段和相应的getScore()setScore()方法。

能不能在Student中不要写重复的代码?

这个时候,继承就派上用场了。

继承是面向对象编程中非常强大的一种机制,它首先可以复用代码。当我们让StudentPerson继承时,Student就获得了Person的所有功能,我们只需要为Student编写新增的功能。

Java使用extends关键字来实现继承:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Person {
private String name;
private int age;

public String getName() {...}
public void setName(String name) {...}
public int getAge() {...}
public void setAge(int age) {...}
}

class Student extends Person {
// 不要重复name和age字段/方法,
// 只需要定义新增score字段/方法:
private int score;

public int getScore() { … }
public void setScore(int score) { … }
}

可见,通过继承,Student只需要编写额外的功能,不再需要重复代码。

注意

子类自动获得了父类的所有字段,严禁定义与父类重名的字段!

在OOP的术语中,我们把Person称为超类(super class),父类(parent class),基类(base class),把Student称为子类(subclass),扩展类(extended class)。

继承树

注意到我们在定义Person的时候,没有写extends。在Java中,没有明确写extends的类,编译器会自动加上extends Object。所以,任何类,除了Object,都会继承自某个类。下图是PersonStudent的继承树:

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


┌───────────┐
│ Person │
└───────────┘


┌───────────┐
│ Student │
└───────────┘

Java只允许一个class继承自一个类,因此,一个类有且仅有一个父类。只有Object特殊,它没有父类。

类似的,如果我们定义一个继承自PersonTeacher,它们的继承树关系如下:

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


┌───────────┐
│ Person │
└───────────┘
▲ ▲
│ │
│ │
┌───────────┐ ┌───────────┐
│ Student │ │ Teacher │
└───────────┘ └───────────┘

protected

继承有个特点,就是子类无法访问父类的private字段或者private方法。例如,Student类就无法访问Person类的nameage字段:

1
2
3
4
5
6
7
8
9
10
class Person {
private String name;
private int age;
}

class Student extends Person {
public String hello() {
return "Hello, " + name; // 编译错误:无法访问name字段
}
}

这使得继承的作用被削弱了。为了让子类可以访问父类的字段,我们需要把private改为protected。用protected修饰的字段可以被子类访问:

1
2
3
4
5
6
7
8
9
10
class Person {
protected String name;
protected int age;
}

class Student extends Person {
public String hello() {
return "Hello, " + name; // OK!
}
}

因此,protected关键字可以把字段和方法的访问权限控制在继承树内部,一个protected字段和方法可以被其子类,以及子类的子类所访问,后面我们还会详细讲解。

super

super关键字表示父类(超类)。子类引用父类的字段时,可以用super.fieldName。例如:

1
2
3
4
5
class Student extends Person {
public String hello() {
return "Hello, " + super.name;
}
}

实际上,这里使用super.name,或者this.name,或者name,效果都是一样的。编译器会自动定位到父类的name字段。

但是,在某些时候,就必须使用super。我们来看一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// super
public class Main {
public static void main(String[] args) {
Student s = new Student("Xiao Ming", 12, 89);
}
}

class Person {
protected String name;
protected int age;

public Person(String name, int age) {
this.name = name;
this.age = age;
}
}

class Student extends Person {
protected int score;

public Student(String name, int age, int score) {
this.score = score;
}
}

运行上面的代码,会得到一个编译错误,大意是在Student的构造方法中,无法调用Person的构造方法。

这是因为在Java中,任何class的构造方法,第一行语句必须是调用父类的构造方法。如果没有明确地调用父类的构造方法,编译器会帮我们自动加一句super();,所以,Student类的构造方法实际上是这样:

1
2
3
4
5
6
7
8
class Student extends Person {
protected int score;

public Student(String name, int age, int score) {
super(); // 自动调用父类的构造方法
this.score = score;
}
}

但是,Person类并没有无参数的构造方法,因此,编译失败。

解决方法是调用Person类存在的某个构造方法。例如:

1
2
3
4
5
6
7
8
class Student extends Person {
protected int score;

public Student(String name, int age, int score) {
super(name, age); // 调用父类的构造方法Person(String, int)
this.score = score;
}
}

这样就可以正常编译了!

因此我们得出结论:如果父类没有默认的构造方法,子类就必须显式调用super()并给出参数以便让编译器定位到父类的一个合适的构造方法。

这里还顺带引出了另一个问题:即子类不会继承任何父类的构造方法。子类默认的构造方法是编译器自动生成的,不是继承的。

阻止继承

正常情况下,只要某个class没有final修饰符,那么任何类都可以从该class继承。

从Java 15开始,允许使用sealed修饰class,并通过permits明确写出能够从该class继承的子类名称。

例如,定义一个Shape类:

1
2
3
public sealed class Shape permits Rect, Circle, Triangle {
...
}

上述Shape类就是一个sealed类,它只允许指定的3个类继承它。如果写:

1
public final class Rect extends Shape {...}

是没问题的,因为Rect出现在Shapepermits列表中。但是,如果定义一个Ellipse就会报错:

1
2
public final class Ellipse extends Shape {...}
// Compile error: class is not allowed to extend sealed class: Shape

原因是Ellipse并未出现在Shapepermits列表中。这种sealed类主要用于一些框架,防止继承被滥用。

sealed类在Java 15中目前是预览状态,要启用它,必须使用参数--enable-preview--source 15

向上转型

如果一个引用变量的类型是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
2
3
4
Student s = new Student();
Person p = s; // upcasting, ok
Object o1 = p; // upcasting, ok
Object o2 = s; // upcasting, ok

注意到继承树是Student > Person > Object,所以,可以把Student类型转型为Person,或者更高层次的Object

向下转型

和向上转型相反,如果把一个父类类型强制转型为子类类型,就是向下转型(downcasting)。例如:

1
2
3
4
Person p1 = new Student(); // upcasting, ok
Person p2 = new Person();
Student s1 = (Student) p1; // ok
Student s2 = (Student) p2; // runtime error! ClassCastException!

如果测试上面的代码,可以发现:

Person类型p1实际指向Student实例,Person类型变量p2实际指向Person实例。在向下转型的时候,把p1转型为Student会成功,因为p1确实指向Student实例,把p2转型为Student会失败,因为p2的实际类型是Person,不能把父类变为子类,因为子类功能比父类多,多的功能无法凭空变出来。

因此,向下转型很可能会失败。失败的时候,Java虚拟机会报ClassCastException

为了避免向下转型出错,Java提供了instanceof操作符,可以先判断一个实例究竟是不是某种类型:

1
2
3
4
5
6
7
8
9
10
Person p = new Person();
System.out.println(p instanceof Person); // true
System.out.println(p instanceof Student); // false

Student s = new Student();
System.out.println(s instanceof Person); // true
System.out.println(s instanceof Student); // true

Student n = null;
System.out.println(n instanceof Student); // false

instanceof实际上判断一个变量所指向的实例是否是指定类型,或者这个类型的子类。如果一个引用变量为null,那么对任何instanceof的判断都为false

利用instanceof,在向下转型前可以先判断:

1
2
3
4
5
Person p = new Student();
if (p instanceof Student) {
// 只有判断成功才会向下转型:
Student s = (Student) p; // 一定会成功
}

从Java 14开始,判断instanceof后,可以直接转型为指定变量,避免再次强制转型。例如,对于以下代码:

1
2
3
4
5
Object obj = "hello";
if (obj instanceof String) {
String s = (String) obj;
System.out.println(s.toUpperCase());
}

可以改写如下:

1
2
3
4
5
6
7
8
9
10
// instanceof variable:
public class Main {
public static void main(String[] args) {
Object obj = "hello";
if (obj instanceof String s) {
// 可以直接使用变量s:
System.out.println(s.toUpperCase());
}
}
}

这种使用instanceof的写法更加简洁。

区分继承和组合

在使用继承时,我们要注意逻辑一致性。

考察下面的Book类:

1
2
3
4
5
class Book {
protected String name;
public String getName() {...}
public void setName(String name) {...}
}

这个Book类也有name字段,那么,我们能不能让Student继承自Book呢?

1
2
3
class Student extends Book {
protected int score;
}

显然,从逻辑上讲,这是不合理的,Student不应该从Book继承,而应该从Person继承。

究其原因,是因为StudentPerson的一种,它们是is关系,而Student并不是Book。实际上StudentBook的关系是has关系。

具有has关系不应该使用继承,而是使用组合,即Student可以持有一个Book实例:

1
2
3
4
class Student extends Person {
protected Book book;
protected int score;
}

因此,继承是is关系,组合是has关系。

练习

定义PrimaryStudent,从Student继承,并新增一个grade字段:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
public class Main {
public static void main(String[] args) {
Person p = new Person("小明", 12);
Student s = new Student("小红", 20, 99);
// TODO: 定义PrimaryStudent,从Student继承,新增grade字段:
Student ps = new PrimaryStudent("小军", 9, 100, 5);
System.out.println(ps.getScore());
}
}

class Person {
protected String name;
protected int age;

public Person(String name, int age) {
this.name = name;
this.age = age;
}

public String getName() { return name; }
public void setName(String name) { this.name = name; }

public int getAge() { return age; }
public void setAge(int age) { this.age = age; }
}

class Student extends Person {
protected int score;

public Student(String name, int age, int score) {
super(name, age);
this.score = score;
}

public int getScore() { return score; }
}

class PrimaryStudent {
// TODO
}

下载练习

小结

继承是面向对象编程的一种强大的代码复用方式;

Java只允许单继承,所有类最终的根类是Object

protected允许子类访问父类的字段和方法;

子类的构造方法可以通过super()调用父类的构造方法;

可以安全地向上转型为更抽象的类型;

可以强制向下转型,最好借助instanceof判断;

子类和父类的关系是is,has关系不能用继承。

在继承关系中,子类如果定义了一个与父类方法签名完全相同的方法,被称为覆写(Override)。

例如,在Person类中,我们定义了run()方法:

1
2
3
4
5
class Person {
public void run() {
System.out.println("Person.run");
}
}

在子类Student中,覆写这个run()方法:

1
2
3
4
5
6
class Student extends Person {
@Override
public void run() {
System.out.println("Student.run");
}
}

Override和Overload不同的是,如果方法签名不同,就是Overload,Overload方法是一个新方法;如果方法签名相同,并且返回值也相同,就是Override

注意

方法名相同,方法参数相同,但方法返回值不同,也是不同的方法。在Java程序中,出现这种情况,编译器会报错。

1
2
3
4
5
6
7
8
9
10
class Person {
public void run() { … }
}

class Student extends Person {
// 不是Override,因为参数不同:
public void run(String s) { … }
// 不是Override,因为返回值不同:
public int run() { … }
}

加上@Override可以让编译器帮助检查是否进行了正确的覆写。希望进行覆写,但是不小心写错了方法签名,编译器会报错。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// override
public class Main {
public static void main(String[] args) {
}
}

class Person {
public void run() {}
}

public class Student extends Person {
@Override // Compile error!
public void run(String s) {}
}

但是@Override不是必需的。

在上一节中,我们已经知道,引用变量的声明类型可能与其实际类型不符,例如:

1
Person p = new Student();

现在,我们考虑一种情况,如果子类覆写了父类的方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// override
public class Main {
public static void main(String[] args) {
Person p = new Student();
p.run(); // 应该打印Person.run还是Student.run?
}
}

class Person {
public void run() {
System.out.println("Person.run");
}
}

class Student extends Person {
@Override
public void run() {
System.out.println("Student.run");
}
}

那么,一个实际类型为Student,引用类型为Person的变量,调用其run()方法,调用的是Person还是Studentrun()方法?

运行一下上面的代码就可以知道,实际上调用的方法是Studentrun()方法。因此可得出结论:

Java的实例方法调用是基于运行时的实际类型的动态调用,而非变量的声明类型。

这个非常重要的特性在面向对象编程中称之为多态。它的英文拼写非常复杂:Polymorphic。

多态

多态是指,针对某个类型的方法调用,其真正执行的方法取决于运行时期实际类型的方法。例如:

1
2
Person p = new Student();
p.run(); // 无法确定运行时究竟调用哪个run()方法

有同学会说,从上面的代码一看就明白,肯定调用的是Studentrun()方法啊。

但是,假设我们编写这样一个方法:

1
2
3
4
public void runTwice(Person p) {
p.run();
p.run();
}

它传入的参数类型是Person,我们是无法知道传入的参数实际类型究竟是Person,还是Student,还是Person的其他子类例如Teacher,因此,也无法确定调用的是不是Person类定义的run()方法。

所以,多态的特性就是,运行期才能动态决定调用的子类方法。对某个类型调用某个方法,执行的实际方法可能是某个子类的覆写方法。这种不确定性的方法调用,究竟有什么作用?

我们还是来举例子。

假设我们定义一种收入,需要给它报税,那么先定义一个Income类:

1
2
3
4
5
6
class Income {
protected double income;
public double getTax() {
return income * 0.1; // 税率10%
}
}

对于工资收入,可以减去一个基数,那么我们可以从Income派生出SalaryIncome,并覆写getTax()

1
2
3
4
5
6
7
8
9
class Salary extends Income {
@Override
public double getTax() {
if (income <= 5000) {
return 0;
}
return (income - 5000) * 0.2;
}
}

如果你享受国务院特殊津贴,那么按照规定,可以全部免税:

1
2
3
4
5
6
class StateCouncilSpecialAllowance extends Income {
@Override
public double getTax() {
return 0;
}
}

现在,我们要编写一个报税的财务软件,对于一个人的所有收入进行报税,可以这么写:

1
2
3
4
5
6
7
public double totalTax(Income... incomes) {
double total = 0;
for (Income income: incomes) {
total = total + income.getTax();
}
return total;
}

来试一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
// Polymorphic
public class Main {
public static void main(String[] args) {
// 给一个有普通收入、工资收入和享受国务院特殊津贴的小伙伴算税:
Income[] incomes = new Income[] {
new Income(3000),
new Salary(7500),
new StateCouncilSpecialAllowance(15000)
};
System.out.println(totalTax(incomes));
}

public static double totalTax(Income... incomes) {
double total = 0;
for (Income income: incomes) {
total = total + income.getTax();
}
return total;
}
}

class Income {
protected double income;

public Income(double income) {
this.income = income;
}

public double getTax() {
return income * 0.1; // 税率10%
}
}

class Salary extends Income {
public Salary(double income) {
super(income);
}

@Override
public double getTax() {
if (income <= 5000) {
return 0;
}
return (income - 5000) * 0.2;
}
}

class StateCouncilSpecialAllowance extends Income {
public StateCouncilSpecialAllowance(double income) {
super(income);
}

@Override
public double getTax() {
return 0;
}
}

观察totalTax()方法:利用多态,totalTax()方法只需要和Income打交道,它完全不需要知道SalaryStateCouncilSpecialAllowance的存在,就可以正确计算出总的税。如果我们要新增一种稿费收入,只需要从Income派生,然后正确覆写getTax()方法就可以。把新的类型传入totalTax(),不需要修改任何代码。

可见,多态具有一个非常强大的功能,就是允许添加更多类型的子类实现功能扩展,却不需要修改基于父类的代码。

覆写Object方法

因为所有的class最终都继承自Object,而Object定义了几个重要的方法:

  • toString():把instance输出为String
  • equals():判断两个instance是否逻辑相等;
  • hashCode():计算一个instance的哈希值。

在必要的情况下,我们可以覆写Object的这几个方法。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class Person {
...
// 显示更有意义的字符串:
@Override
public String toString() {
return "Person:name=" + name;
}

// 比较是否相等:
@Override
public boolean equals(Object o) {
// 当且仅当o为Person类型:
if (o instanceof Person) {
Person p = (Person) o;
// 并且name字段相同时,返回true:
return this.name.equals(p.name);
}
return false;
}

// 计算hash:
@Override
public int hashCode() {
return this.name.hashCode();
}
}

调用super

在子类的覆写方法中,如果要调用父类的被覆写的方法,可以通过super来调用。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Person {
protected String name;
public String hello() {
return "Hello, " + name;
}
}

class Student extends Person {
@Override
public String hello() {
// 调用父类的hello()方法:
return super.hello() + "!";
}
}

final

继承可以允许子类覆写父类的方法。如果一个父类不允许子类对它的某个方法进行覆写,可以把该方法标记为final。用final修饰的方法不能被Override

1
2
3
4
5
6
7
8
9
10
11
12
13
class Person {
protected String name;
public final String hello() {
return "Hello, " + name;
}
}

class Student extends Person {
// compile error: 不允许覆写
@Override
public String hello() {
}
}

如果一个类不希望任何其他类继承自它,那么可以把这个类本身标记为final。用final修饰的类不能被继承:

1
2
3
4
5
6
7
final class Person {
protected String name;
}

// compile error: 不允许继承自Person
class Student extends Person {
}

对于一个类的实例字段,同样可以用final修饰。用final修饰的字段在初始化后不能被修改。例如:

1
2
3
class Person {
public final String name = "Unamed";
}

final字段重新赋值会报错:

1
2
Person p = new Person();
p.name = "New Name"; // compile error!

可以在构造方法中初始化final字段:

1
2
3
4
5
6
class Person {
public final String name;
public Person(String name) {
this.name = name;
}
}

这种方法更为常用,因为可以保证实例一旦创建,其final字段就不可修改。

练习

给一个有工资收入和稿费收入的小伙伴算税。

下载练习

小结

子类可以覆写父类的方法(Override),覆写在子类中改变了父类方法的行为;

Java的方法调用总是作用于运行期对象的实际类型,这种行为称为多态;

final修饰符有多种作用:

  • final修饰的方法可以阻止被覆写;
  • final修饰的class可以阻止被继承;
  • final修饰的field必须在创建对象时初始化,随后不可修改。

由于多态的存在,每个子类都可以覆写父类的方法,例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
class Person {
public void run() { … }
}

class Student extends Person {
@Override
public void run() { … }
}

class Teacher extends Person {
@Override
public void run() { … }
}

Person类派生的StudentTeacher都可以覆写run()方法。

如果父类Personrun()方法没有实际意义,能否去掉方法的执行语句?

1
2
3
class Person {
public void run(); // Compile Error!
}

答案是不行,会导致编译错误,因为定义方法的时候,必须实现方法的语句。

能不能去掉父类的run()方法?

答案还是不行,因为去掉父类的run()方法,就失去了多态的特性。例如,runTwice()就无法编译:

1
2
3
4
public void runTwice(Person p) {
p.run(); // Person没有run()方法,会导致编译错误
p.run();
}

如果父类的方法本身不需要实现任何功能,仅仅是为了定义方法签名,目的是让子类去覆写它,那么,可以把父类的方法声明为抽象方法:

1
2
3
class Person {
public abstract void run();
}

把一个方法声明为abstract,表示它是一个抽象方法,本身没有实现任何方法语句。因为这个抽象方法本身是无法执行的,所以,Person类也无法被实例化。编译器会告诉我们,无法编译Person类,因为它包含抽象方法。

必须把Person类本身也声明为abstract,才能正确编译它:

1
2
3
abstract class Person {
public abstract void run();
}

抽象类

如果一个class定义了方法,但没有具体执行代码,这个方法就是抽象方法,抽象方法用abstract修饰。

因为无法执行抽象方法,因此这个类也必须申明为抽象类(abstract class)。

使用abstract修饰的类就是抽象类。我们无法实例化一个抽象类:

1
Person p = new Person(); // 编译错误

无法实例化的抽象类有什么用?

因为抽象类本身被设计成只能用于被继承,因此,抽象类可以强迫子类实现其定义的抽象方法,否则编译会报错。因此,抽象方法实际上相当于定义了“规范”。

例如,Person类定义了抽象方法run(),那么,在实现子类Student的时候,就必须覆写run()方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// abstract class
public class Main {
public static void main(String[] args) {
Person p = new Student();
p.run();
}
}

abstract class Person {
public abstract void run();
}

class Student extends Person {
@Override
public void run() {
System.out.println("Student.run");
}
}

面向抽象编程

当我们定义了抽象类Person,以及具体的StudentTeacher子类的时候,我们可以通过抽象类Person类型去引用具体的子类的实例:

1
2
Person s = new Student();
Person t = new Teacher();

这种引用抽象类的好处在于,我们对其进行方法调用,并不关心Person类型变量的具体子类型:

1
2
3
// 不关心Person变量的具体子类型:
s.run();
t.run();

同样的代码,如果引用的是一个新的子类,我们仍然不关心具体类型:

1
2
3
// 同样不关心新的子类是如何实现run()方法的:
Person e = new Employee();
e.run();

这种尽量引用高层类型,避免引用实际子类型的方式,称之为面向抽象编程。

面向抽象编程的本质就是:

  • 上层代码只定义规范(例如:abstract class Person);
  • 不需要子类就可以实现业务逻辑(正常编译);
  • 具体的业务逻辑由不同的子类实现,调用者并不关心。

练习

用抽象类给一个有工资收入和稿费收入的小伙伴算税。

下载练习

小结

通过abstract定义的方法是抽象方法,它只有定义,没有实现。抽象方法定义了子类必须实现的接口规范;

定义了抽象方法的class必须被定义为抽象类,从抽象类继承的子类必须实现抽象方法;

如果不实现抽象方法,则该子类仍是一个抽象类;

面向抽象编程使得调用者只关心抽象方法的定义,不关心子类的具体实现。

在抽象类中,抽象方法本质上是定义接口规范:即规定高层类的接口,从而保证所有子类都有相同的接口实现,这样,多态就能发挥出威力。

如果一个抽象类没有字段,所有方法全部都是抽象方法:

1
2
3
4
abstract class Person {
public abstract void run();
public abstract String getName();
}

就可以把该抽象类改写为接口:interface

在Java中,使用interface可以声明一个接口:

1
2
3
4
interface Person {
void run();
String getName();
}

所谓interface,就是比抽象类还要抽象的纯抽象接口,因为它连字段都不能有。因为接口定义的所有方法默认都是public abstract的,所以这两个修饰符不需要写出来(写不写效果都一样)。

当一个具体的class去实现一个interface时,需要使用implements关键字。举个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Student implements Person {
private String name;

public Student(String name) {
this.name = name;
}

@Override
public void run() {
System.out.println(this.name + " run");
}

@Override
public String getName() {
return this.name;
}
}

我们知道,在Java中,一个类只能继承自另一个类,不能从多个类继承。但是,一个类可以实现多个interface,例如:

1
2
3
class Student implements Person, Hello { // 实现了两个interface
...
}

术语

注意区分术语:

Java的接口特指interface的定义,表示一个接口类型和一组方法签名,而编程接口泛指接口规范,如方法签名,数据格式,网络协议等。

抽象类和接口的对比如下:

abstract class interface
继承 只能extends一个class 可以implements多个interface
字段 可以定义实例字段 不能定义实例字段
抽象方法 可以定义抽象方法 可以定义抽象方法
非抽象方法 可以定义非抽象方法 可以定义default方法

接口继承

一个interface可以继承自另一个interfaceinterface继承自interface使用extends,它相当于扩展了接口的方法。例如:

1
2
3
4
5
6
7
8
interface Hello {
void hello();
}

interface Person extends Hello {
void run();
String getName();
}

此时,Person接口继承自Hello接口,因此,Person接口现在实际上有3个抽象方法签名,其中一个来自继承的Hello接口。

继承关系

合理设计interfaceabstract class的继承关系,可以充分复用代码。一般来说,公共逻辑适合放在abstract class中,具体逻辑放到各个子类,而接口层次代表抽象程度。可以参考Java的集合类定义的一组接口、抽象类以及具体子类的继承关系:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
┌───────────────┐
│ Iterable │
└───────────────┘
▲ ┌───────────────────┐
│ │ Object │
┌───────────────┐ └───────────────────┘
│ Collection │ ▲
└───────────────┘ │
▲ ▲ ┌───────────────────┐
│ └──────────│AbstractCollection │
┌───────────────┐ └───────────────────┘
│ List │ ▲
└───────────────┘ │
▲ ┌───────────────────┐
└──────────│ AbstractList │
└───────────────────┘
▲ ▲
│ │
│ │
┌────────────┐ ┌────────────┐
│ ArrayList │ │ LinkedList │
└────────────┘ └────────────┘

在使用的时候,实例化的对象永远只能是某个具体的子类,但总是通过接口去引用它,因为接口比抽象类更抽象:

1
2
3
List list = new ArrayList(); // 用List接口引用具体子类的实例
Collection coll = list; // 向上转型为Collection接口
Iterable it = coll; // 向上转型为Iterable接口

default方法

在接口中,可以定义default方法。例如,把Person接口的run()方法改为default方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// interface
public class Main {
public static void main(String[] args) {
Person p = new Student("Xiao Ming");
p.run();
}
}

interface Person {
String getName();
default void run() {
System.out.println(getName() + " run");
}
}

class Student implements Person {
private String name;

public Student(String name) {
this.name = name;
}

public String getName() {
return this.name;
}
}

实现类可以不必覆写default方法。default方法的目的是,当我们需要给接口新增一个方法时,会涉及到修改全部子类。如果新增的是default方法,那么子类就不必全部修改,只需要在需要覆写的地方去覆写新增方法。

default方法和抽象类的普通方法是有所不同的。因为interface没有字段,default方法无法访问字段,而抽象类的普通方法可以访问实例字段。

练习

用接口给一个有工资收入和稿费收入的小伙伴算税。

下载练习

小结

Java的接口(interface)定义了纯抽象规范,一个类可以实现多个接口;

接口也是数据类型,适用于向上转型和向下转型;

接口的所有方法都是抽象方法,接口不能定义实例字段;

接口可以定义default方法(JDK>=1.8)。

在一个class中定义的字段,我们称之为实例字段。实例字段的特点是,每个实例都有独立的字段,各个实例的同名字段互不影响。

还有一种字段,是用static修饰的字段,称为静态字段:static field

实例字段在每个实例中都有自己的一个独立“空间”,但是静态字段只有一个共享“空间”,所有实例都会共享该字段。举个例子:

1
2
3
4
5
6
class Person {
public String name;
public int age;
// 定义静态字段number:
public static int number;
}

我们来看看下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// static field
public class Main {
public static void main(String[] args) {
Person ming = new Person("Xiao Ming", 12);
Person hong = new Person("Xiao Hong", 15);
ming.number = 88;
System.out.println(hong.number);
hong.number = 99;
System.out.println(ming.number);
}
}

class Person {
public String name;
public int age;

public static int number;

public Person(String name, int age) {
this.name = name;
this.age = age;
}
}

对于静态字段,无论修改哪个实例的静态字段,效果都是一样的:所有实例的静态字段都被修改了,原因是静态字段并不属于实例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
        ┌──────────────────┐
ming ──▶│Person instance │
├──────────────────┤
│name = "Xiao Ming"│
│age = 12 │
│number ───────────┼──┐ ┌─────────────┐
└──────────────────┘ │ │Person class │
│ ├─────────────┤
├───▶│number = 99 │
┌──────────────────┐ │ └─────────────┘
hong ──▶│Person instance │ │
├──────────────────┤ │
│name = "Xiao Hong"│ │
│age = 15 │ │
│number ───────────┼──┘
└──────────────────┘

虽然实例可以访问静态字段,但是它们指向的其实都是Person class的静态字段。所以,所有实例共享一个静态字段。

因此,不推荐用实例变量.静态字段去访问静态字段,因为在Java程序中,实例对象并没有静态字段。在代码中,实例对象能访问静态字段只是因为编译器可以根据实例类型自动转换为类名.静态字段来访问静态对象。

推荐用类名来访问静态字段。可以把静态字段理解为描述class本身的字段。对于上面的代码,更好的写法是:

1
2
Person.number = 99;
System.out.println(Person.number);

静态方法

有静态字段,就有静态方法。用static修饰的方法称为静态方法。

调用实例方法必须通过一个实例变量,而调用静态方法则不需要实例变量,通过类名就可以调用。静态方法类似其它编程语言的函数。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// static method
public class Main {
public static void main(String[] args) {
Person.setNumber(99);
System.out.println(Person.number);
}
}

class Person {
public static int number;

public static void setNumber(int value) {
number = value;
}
}

因为静态方法属于class而不属于实例,因此,静态方法内部,无法访问this变量,也无法访问实例字段,它只能访问静态字段。

通过实例变量也可以调用静态方法,但这只是编译器自动帮我们把实例改写成类名而已。

通常情况下,通过实例变量访问静态字段和静态方法,会得到一个编译警告。

静态方法经常用于工具类。例如:

  • Arrays.sort()
  • Math.random()

静态方法也经常用于辅助方法。注意到Java程序的入口main()也是静态方法。

接口的静态字段

因为interface是一个纯抽象类,所以它不能定义实例字段。但是,interface是可以有静态字段的,并且静态字段必须为final类型:

1
2
3
4
public interface Person {
public static final int MALE = 1;
public static final int FEMALE = 2;
}

实际上,因为interface的字段只能是public static final类型,所以我们可以把这些修饰符都去掉,上述代码可以简写为:

1
2
3
4
5
public interface Person {
// 编译器会自动加上public static final:
int MALE = 1;
int FEMALE = 2;
}

编译器会自动把该字段变为public static final类型。

练习

Person类增加一个静态字段count和静态方法getCount(),统计实例创建的个数。

下载练习

小结

静态字段属于所有实例“共享”的字段,实际上是属于class的字段;

调用静态方法不需要实例,无法访问this,但可以访问静态字段和其他静态方法;

静态方法常用于工具类和辅助方法。

在前面的代码中,我们把类和接口命名为PersonStudentHello等简单名字。

在现实中,如果小明写了一个Person类,小红也写了一个Person类,现在,小白既想用小明的Person,也想用小红的Person,怎么办?

如果小军写了一个Arrays类,恰好JDK也自带了一个Arrays类,如何解决类名冲突?

在Java中,我们使用package来解决名字冲突。

Java定义了一种名字空间,称之为包:package。一个类总是属于某个包,类名(比如Person)只是一个简写,真正的完整类名是包名.类名

例如:

小明的Person类存放在包ming下面,因此,完整类名是ming.Person

小红的Person类存放在包hong下面,因此,完整类名是hong.Person

小军的Arrays类存放在包mr.jun下面,因此,完整类名是mr.jun.Arrays

JDK的Arrays类存放在包java.util下面,因此,完整类名是java.util.Arrays

在定义class的时候,我们需要在第一行声明这个class属于哪个包。

小明的Person.java文件:

1
2
3
4
package ming; // 申明包名ming

public class Person {
}

小军的Arrays.java文件:

1
2
3
4
package mr.jun; // 申明包名mr.jun

public class Arrays {
}

在Java虚拟机执行的时候,JVM只看完整类名,因此,只要包名不同,类就不同。

包可以是多层结构,用.隔开。例如:java.util

特别注意

包没有父子关系。java.util和java.util.zip是不同的包,两者没有任何继承关系。

没有定义包名的class,它使用的是默认包,非常容易引起名字冲突,因此,不推荐不写包名的做法。

我们还需要按照包结构把上面的Java文件组织起来。假设以package_sample作为根目录,src作为源码目录,那么所有文件结构就是:

1
2
3
4
5
6
7
8
9
package_sample
└─ src
├─ hong
│ └─ Person.java
│ ming
│ └─ Person.java
└─ mr
└─ jun
└─ Arrays.java

即所有Java文件对应的目录层次要和包的层次一致。

编译后的.class文件也需要按照包结构存放。如果使用IDE,把编译后的.class文件放到bin目录下,那么,编译的文件结构就是:

1
2
3
4
5
6
7
8
9
package_sample
└─ bin
├─ hong
│ └─ Person.class
│ ming
│ └─ Person.class
└─ mr
└─ jun
└─ Arrays.class

包作用域

位于同一个包的类,可以访问包作用域的字段和方法。不用publicprotectedprivate修饰的字段和方法就是包作用域。例如,Person类定义在hello包下面:

1
2
3
4
5
6
7
8
package hello;

public class Person {
// 包作用域:
void hello() {
System.out.println("Hello!");
}
}

Main类也定义在hello包下面:

1
2
3
4
5
6
7
8
package hello;

public class Main {
public static void main(String[] args) {
Person p = new Person();
p.hello(); // 可以调用,因为Main和Person在同一个包
}
}

import

在一个class中,我们总会引用其他的class。例如,小明的ming.Person类,如果要引用小军的mr.jun.Arrays类,他有三种写法:

第一种,直接写出完整类名,例如:

1
2
3
4
5
6
7
8
9
// Person.java
package ming;

public class Person {
public void run() {
// 写完整类名: mr.jun.Arrays
mr.jun.Arrays arrays = new mr.jun.Arrays();
}
}

很显然,每次写完整类名比较痛苦。

因此,第二种写法是用import语句,导入小军的Arrays,然后写简单类名:

1
2
3
4
5
6
7
8
9
10
11
12
// Person.java
package ming;

// 导入完整类名:
import mr.jun.Arrays;

public class Person {
public void run() {
// 写简单类名: Arrays
Arrays arrays = new Arrays();
}
}

在写import的时候,可以使用*,表示把这个包下面的所有class都导入进来(但不包括子包的class):

1
2
3
4
5
6
7
8
9
10
11
// Person.java
package ming;

// 导入mr.jun包的所有class:
import mr.jun.*;

public class Person {
public void run() {
Arrays arrays = new Arrays();
}
}

我们一般不推荐这种写法,因为在导入了多个包后,很难看出Arrays类属于哪个包。

还有一种import static的语法,它可以导入一个类的静态字段和静态方法:

1
2
3
4
5
6
7
8
9
10
11
package main;

// 导入System类的所有静态字段和静态方法:
import static java.lang.System.*;

public class Main {
public static void main(String[] args) {
// 相当于调用System.out.println(…)
out.println("Hello, world!");
}
}

import static很少使用。

Java编译器最终编译出的.class文件只使用完整类名,因此,在代码中,当编译器遇到一个class名称时:

  • 如果是完整类名,就直接根据完整类名查找这个class
  • 如果是简单类名,按下面的顺序依次查找:
    • 查找当前package是否存在这个class
    • 查找import的包是否包含这个class
    • 查找java.lang包是否包含这个class

如果按照上面的规则还无法确定类名,则编译报错。

我们来看一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Main.java
package test;

import java.text.Format;

public class Main {
public static void main(String[] args) {
java.util.List list; // ok,使用完整类名 -> java.util.List
Format format = null; // ok,使用import的类 -> java.text.Format
String s = "hi"; // ok,使用java.lang包的String -> java.lang.String
System.out.println(s); // ok,使用java.lang包的System -> java.lang.System
MessageFormat mf = null; // 编译错误:无法找到MessageFormat: MessageFormat cannot be resolved to a type
}
}

因此,编写class的时候,编译器会自动帮我们做两个import动作:

  • 默认自动import当前package的其他class
  • 默认自动import java.lang.*

注意

自动导入的是java.lang包,但类似java.lang.reflect这些包仍需要手动导入。

如果有两个class名称相同,例如,mr.jun.Arraysjava.util.Arrays,那么只能import其中一个,另一个必须写完整类名。

最佳实践

为了避免名字冲突,我们需要确定唯一的包名。推荐的做法是使用倒置的域名来确保唯一性。例如:

  • org.apache
  • org.apache.commons.log
  • com.liaoxuefeng.sample

子包就可以根据功能自行命名。

要注意不要和java.lang包的类重名,即自己的类不要使用这些名字:

  • String
  • System
  • Runtime

要注意也不要和JDK常用类重名:

  • java.util.List
  • java.text.Format
  • java.math.BigInteger

编译和运行

假设我们创建了如下的目录结构:

1
2
3
4
5
6
7
8
9
work
├── bin
└── src
└── com
└── itranswarp
├── sample
│   └── Main.java
└── world
└── Person.java

其中,bin目录用于存放编译后的class文件,src目录按包结构存放Java源码,我们怎么一次性编译这些Java源码呢?

首先,确保当前目录是work目录,即存放srcbin的父目录:

1
2
$ ls
bin src

然后,编译src目录下的所有Java文件:

1
$ javac -d ./bin src/**/*.java

命令行-d指定输出的class文件存放bin目录,后面的参数src/**/*.java表示src目录下的所有.java文件,包括任意深度的子目录。

注意:Windows不支持**这种搜索全部子目录的做法,所以在Windows下编译必须依次列出所有.java文件:

1
C:\work> javac -d bin src\com\itranswarp\sample\Main.java src\com\itranswarp\world\Persion.java

使用Windows的PowerShell可以利用Get-ChildItem来列出指定目录下的所有.java文件:

1
2
3
PS C:\work> (Get-ChildItem -Path .\src -Recurse -Filter *.java).FullName
C:\work\src\com\itranswarp\sample\Main.java
C:\work\src\com\itranswarp\world\Person.java

因此,编译命令可写为:

1
PS C:\work> javac -d .\bin (Get-ChildItem -Path .\src -Recurse -Filter *.java).FullName

如果编译无误,则javac命令没有任何输出。可以在bin目录下看到如下class文件:

1
2
3
4
5
6
7
bin
└── com
└── itranswarp
├── sample
│   └── Main.class
└── world
└── Person.class

现在,我们就可以直接运行class文件了。根据当前目录的位置确定classpath,例如,当前目录仍为work,则classpath为bin或者./bin

1
2
$ java -cp bin com.itranswarp.sample.Main 
Hello, world!

练习

请按如下包结构创建工程项目:

1
2
3
4
5
6
7
8
oop-package
└── src
└── com
└── itranswarp
├── sample
│   └── Main.java
└── world
└── Person.java

下载练习

小结

Java内建的package机制是为了避免class命名冲突;

JDK的核心类使用java.lang包,编译器会自动导入;

JDK的其它常用类定义在java.util.*java.math.*java.text.*,……;

包名推荐使用倒置的域名,例如org.apache

在Java中,我们经常看到publicprotectedprivate这些修饰符。在Java中,这些修饰符可以用来限定访问作用域。

public

定义为publicclassinterface可以被其他任何类访问:

1
2
3
4
5
6
package abc;

public class Hello {
public void hi() {
}
}

上面的Hellopublic,因此,可以被其他包的类访问:

1
2
3
4
5
6
7
8
package xyz;

class Main {
void foo() {
// Main可以访问Hello
Hello h = new Hello();
}
}

定义为publicfieldmethod可以被其他类访问,前提是首先有访问class的权限:

1
2
3
4
5
6
package abc;

public class Hello {
public void hi() {
}
}

上面的hi()方法是public,可以被其他类调用,前提是首先要能访问Hello类:

1
2
3
4
5
6
7
8
package xyz;

class Main {
void foo() {
Hello h = new Hello();
h.hi();
}
}

private

定义为privatefieldmethod无法被其他类访问:

1
2
3
4
5
6
7
8
9
10
11
package abc;

public class Hello {
// 不能被其他类调用:
private void hi() {
}

public void hello() {
this.hi();
}
}

实际上,确切地说,private访问权限被限定在class的内部,而且与方法声明顺序无关。推荐把private方法放到后面,因为public方法定义了类对外提供的功能,阅读代码的时候,应该先关注public方法:

1
2
3
4
5
6
7
8
9
10
package abc;

public class Hello {
public void hello() {
this.hi();
}

private void hi() {
}
}

由于Java支持嵌套类,如果一个类内部还定义了嵌套类,那么,嵌套类拥有访问private的权限:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// private
public class Main {
public static void main(String[] args) {
Inner i = new Inner();
i.hi();
}

// private方法:
private static void hello() {
System.out.println("private hello!");
}

// 静态内部类:
static class Inner {
public void hi() {
Main.hello();
}
}
}

定义在一个class内部的class称为嵌套类(nested class),Java支持好几种嵌套类。

protected

protected作用于继承关系。定义为protected的字段和方法可以被子类访问,以及子类的子类:

1
2
3
4
5
6
7
package abc;

public class Hello {
// protected方法:
protected void hi() {
}
}

上面的protected方法可以被继承的类访问:

1
2
3
4
5
6
7
8
package xyz;

class Main extends Hello {
void foo() {
// 可以访问protected方法:
hi();
}
}

package

最后,包作用域是指一个类允许访问同一个package的没有publicprivate修饰的class,以及没有publicprotectedprivate修饰的字段和方法。

1
2
3
4
5
6
7
package abc;
// package权限的类:
class Hello {
// package权限的方法:
void hi() {
}
}

只要在同一个包,就可以访问package权限的classfieldmethod

1
2
3
4
5
6
7
8
9
10
package abc;

class Main {
void foo() {
// 可以访问package权限的类:
Hello h = new Hello();
// 可以调用package权限的方法:
h.hi();
}
}

注意,包名必须完全一致,包没有父子关系,com.apachecom.apache.abc是不同的包。

局部变量

在方法内部定义的变量称为局部变量,局部变量作用域从变量声明处开始到对应的块结束。方法参数也是局部变量。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
package abc;

public class Hello {
void hi(String name) { // 1
String s = name.toLowerCase(); // 2
int len = s.length(); // 3
if (len < 10) { // 4
int p = 10 - len; // 5
for (int i=0; i<10; i++) { // 6
System.out.println(); // 7
} // 8
} // 9
} // 10
}

我们观察上面的hi()方法代码:

  • 方法参数name是局部变量,它的作用域是整个方法,即1 ~ 10;
  • 变量s的作用域是定义处到方法结束,即2 ~ 10;
  • 变量len的作用域是定义处到方法结束,即3 ~ 10;
  • 变量p的作用域是定义处到if块结束,即5 ~ 9;
  • 变量i的作用域是for循环,即6 ~ 8。

使用局部变量时,应该尽可能把局部变量的作用域缩小,尽可能延后声明局部变量。

final

Java还提供了一个final修饰符。final与访问权限不冲突,它有很多作用。

final修饰class可以阻止被继承:

1
2
3
4
5
6
7
8
9
package abc;

// 无法被继承:
public final class Hello {
private int n = 0;
protected void hi(int t) {
long i = t;
}
}

final修饰method可以阻止被子类覆写:

1
2
3
4
5
6
7
package abc;

public class Hello {
// 无法被覆写:
protected final void hi() {
}
}

final修饰field可以阻止被重新赋值:

1
2
3
4
5
6
7
8
package abc;

public class Hello {
private final int n = 0;
protected void hi() {
this.n = 1; // error!
}
}

final修饰局部变量可以阻止被重新赋值:

1
2
3
4
5
6
7
package abc;

public class Hello {
protected void hi(final int t) {
t = 1; // error!
}
}

最佳实践

如果不确定是否需要public,就不声明为public,即尽可能少地暴露对外的字段和方法。

把方法定义为package权限有助于测试,因为测试类和被测试类只要位于同一个package,测试代码就可以访问被测试类的package权限方法。

一个.java文件只能包含一个public类,但可以包含多个非public类。如果有public类,文件名必须和public类的名字相同。

小结

Java内建的访问权限包括publicprotectedprivatepackage权限;

Java在方法内部定义的变量是局部变量,局部变量的作用域从变量声明开始,到一个块结束;

final修饰符不是访问权限,它可以修饰classfieldmethod

一个.java文件只能包含一个public类,但可以包含多个非public类。

在Java程序中,通常情况下,我们把不同的类组织在不同的包下面,对于一个包下面的类来说,它们是在同一层次,没有父子关系:

1
2
3
4
5
java.lang
├── Math
├── Runnable
├── String
└── ...

还有一种类,它被定义在另一个类的内部,所以称为内部类(Nested Class)。Java的内部类分为好几种,通常情况用得不多,但也需要了解它们是如何使用的。

Inner Class

如果一个类定义在另一个类的内部,这个类就是Inner Class:

1
2
3
4
5
class Outer {
class Inner {
// 定义了一个Inner Class
}
}

上述定义的Outer是一个普通类,而Inner是一个Inner Class,它与普通类有个最大的不同,就是Inner Class的实例不能单独存在,必须依附于一个Outer Class的实例。示例代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// inner class
public class Main {
public static void main(String[] args) {
Outer outer = new Outer("Nested"); // 实例化一个Outer
Outer.Inner inner = outer.new Inner(); // 实例化一个Inner
inner.hello();
}
}

class Outer {
private String name;

Outer(String name) {
this.name = name;
}

class Inner {
void hello() {
System.out.println("Hello, " + Outer.this.name);
}
}
}

观察上述代码,要实例化一个Inner,我们必须首先创建一个Outer的实例,然后,调用Outer实例的new来创建Inner实例:

1
Outer.Inner inner = outer.new Inner();

这是因为Inner Class除了有一个this指向它自己,还隐含地持有一个Outer Class实例,可以用Outer.this访问这个实例。所以,实例化一个Inner Class不能脱离Outer实例。

Inner Class和普通Class相比,除了能引用Outer实例外,还有一个额外的“特权”,就是可以修改Outer Class的private字段,因为Inner Class的作用域在Outer Class内部,所以能访问Outer Class的private字段和方法。

观察Java编译器编译后的.class文件可以发现,Outer类被编译为Outer.class,而Inner类被编译为Outer$Inner.class

Anonymous Class

还有一种定义Inner Class的方法,它不需要在Outer Class中明确地定义这个Class,而是在方法内部,通过匿名类(Anonymous Class)来定义。示例代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// Anonymous Class
public class Main {
public static void main(String[] args) {
Outer outer = new Outer("Nested");
outer.asyncHello();
}
}

class Outer {
private String name;

Outer(String name) {
this.name = name;
}

void asyncHello() {
Runnable r = new Runnable() {
@Override
public void run() {
System.out.println("Hello, " + Outer.this.name);
}
};
new Thread(r).start();
}
}

观察asyncHello()方法,我们在方法内部实例化了一个RunnableRunnable本身是接口,接口是不能实例化的,所以这里实际上是定义了一个实现了Runnable接口的匿名类,并且通过new实例化该匿名类,然后转型为Runnable。在定义匿名类的时候就必须实例化它,定义匿名类的写法如下:

1
2
3
Runnable r = new Runnable() {
// 实现必要的抽象方法...
};

匿名类和Inner Class一样,可以访问Outer Class的private字段和方法。之所以我们要定义匿名类,是因为在这里我们通常不关心类名,比直接定义Inner Class可以少写很多代码。

观察Java编译器编译后的.class文件可以发现,Outer类被编译为Outer.class,而匿名类被编译为Outer$1.class。如果有多个匿名类,Java编译器会将每个匿名类依次命名为Outer$1Outer$2Outer$3……

除了接口外,匿名类也完全可以继承自普通类。观察以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// Anonymous Class
import java.util.HashMap;

public class Main {
public static void main(String[] args) {
HashMap<String, String> map1 = new HashMap<>();
HashMap<String, String> map2 = new HashMap<>() {}; // 匿名类!
HashMap<String, String> map3 = new HashMap<>() {
{
put("A", "1");
put("B", "2");
}
};
System.out.println(map3.get("A"));
}
}

map1是一个普通的HashMap实例,但map2是一个匿名类实例,只是该匿名类继承自HashMapmap3也是一个继承自HashMap的匿名类实例,并且添加了static代码块来初始化数据。观察编译输出可发现Main$1.classMain$2.class两个匿名类文件。

Static Nested Class

最后一种内部类和Inner Class类似,但是使用static修饰,称为静态内部类(Static Nested Class):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// Static Nested Class
public class Main {
public static void main(String[] args) {
Outer.StaticNested sn = new Outer.StaticNested();
sn.hello();
}
}

class Outer {
private static String NAME = "OUTER";

private String name;

Outer(String name) {
this.name = name;
}

static class StaticNested {
void hello() {
System.out.println("Hello, " + Outer.NAME);
}
}
}

static修饰的内部类和Inner Class有很大的不同,它不再依附于Outer的实例,而是一个完全独立的类,因此无法引用Outer.this,但它可以访问Outerprivate静态字段和静态方法。如果把StaticNested移到Outer之外,就失去了访问private的权限。

小结

Java的内部类可分为Inner Class、Anonymous Class和Static Nested Class三种;

Inner Class和Anonymous Class本质上是相同的,都必须依附于Outer Class的实例,即隐含地持有Outer.this实例,并拥有Outer Class的private访问权限;

Static Nested Class是独立类,但拥有Outer Class的private访问权限。

在Java中,我们经常听到classpath这个东西。网上有很多关于“如何设置classpath”的文章,但大部分设置都不靠谱。

到底什么是classpath

classpath是JVM用到的一个环境变量,它用来指示JVM如何搜索class

因为Java是编译型语言,源码文件是.java,而编译后的.class文件才是真正可以被JVM执行的字节码。因此,JVM需要知道,如果要加载一个abc.xyz.Hello的类,应该去哪搜索对应的Hello.class文件。

所以,classpath就是一组目录的集合,它设置的搜索路径与操作系统相关。例如,在Windows系统上,用;分隔,带空格的目录用""括起来,可能长这样:

1
C:\work\project1\bin;C:\shared;"D:\My Documents\project1\bin"

在Linux系统上,用:分隔,可能长这样:

1
/usr/shared:/usr/local/bin:/home/liaoxuefeng/bin

现在我们假设classpath.;C:\work\project1\bin;C:\shared,当JVM在加载abc.xyz.Hello这个类时,会依次查找:

  • <当前目录>\abc\xyz\Hello.class
  • C:\work\project1\bin\abc\xyz\Hello.class
  • C:\shared\abc\xyz\Hello.class

注意到.代表当前目录。如果JVM在某个路径下找到了对应的class文件,就不再往后继续搜索。如果所有路径下都没有找到,就报错。

classpath的设定方法有两种:

在系统环境变量中设置classpath环境变量,不推荐;

在启动JVM时设置classpath变量,推荐。

我们强烈不推荐在系统环境变量中设置classpath,那样会污染整个系统环境。在启动JVM时设置classpath才是推荐的做法。实际上就是给java命令传入-classpath参数:

1
java -classpath .;C:\work\project1\bin;C:\shared abc.xyz.Hello

或者使用-cp的简写:

1
java -cp .;C:\work\project1\bin;C:\shared abc.xyz.Hello

没有设置系统环境变量,也没有传入-cp参数,那么JVM默认的classpath.,即当前目录:

1
java abc.xyz.Hello

上述命令告诉JVM只在当前目录搜索Hello.class

在IDE中运行Java程序,IDE自动传入的-cp参数是当前工程的bin目录和引入的jar包。

通常,我们在自己编写的class中,会引用Java核心库的class,例如,StringArrayList等。这些class应该上哪去找?

有很多“如何设置classpath”的文章会告诉你把JVM自带的rt.jar放入classpath,但事实上,根本不需要告诉JVM如何去Java核心库查找class,JVM怎么可能笨到连自己的核心库在哪都不知道!

注意

不要把任何Java核心库添加到classpath中!JVM根本不依赖classpath加载核心库!

更好的做法是,不要设置classpath!默认的当前目录.对于绝大多数情况都够用了。

假设我们有一个编译后的Hello.class,它的包名是com.example,当前目录是C:\work,那么,目录结构必须如下:

1
2
3
4
C:\work
└─ com
└─ example
└─ Hello.class

运行这个Hello.class必须在当前目录下使用如下命令:

1
C:\work> java -cp . com.example.Hello

JVM根据classpath设置的.在当前目录下查找com.example.Hello,即实际搜索文件必须位于com/example/Hello.class。如果指定的.class文件不存在,或者目录结构和包名对不上,均会报错。

jar包

如果有很多.class文件,散落在各层目录中,肯定不便于管理。如果能把目录打一个包,变成一个文件,就方便多了。

jar包就是用来干这个事的,它可以把package组织的目录层级,以及各个目录下的所有文件(包括.class文件和其他文件)都打成一个jar文件,这样一来,无论是备份,还是发给客户,就简单多了。

jar包实际上就是一个zip格式的压缩文件,而jar包相当于目录。如果我们要执行一个jar包的class,就可以把jar包放到classpath中:

1
java -cp ./hello.jar abc.xyz.Hello

这样JVM会自动在hello.jar文件里去搜索某个类。

那么问题来了:如何创建jar包?

因为jar包就是zip包,所以,直接在资源管理器中,找到正确的目录,点击右键,在弹出的快捷菜单中选择“发送到”,“压缩(zipped)文件夹”,就制作了一个zip文件。然后,把后缀从.zip改为.jar,一个jar包就创建成功。

假设编译输出的目录结构是这样:

1
2
3
4
5
6
7
8
9
package_sample
└─ bin
├─ hong
│ └─ Person.class
│ ming
│ └─ Person.class
└─ mr
└─ jun
└─ Arrays.class

这里需要特别注意的是,jar包里的第一层目录,不能是bin,而应该是hongmingmr。如果在Windows的资源管理器中看,应该长这样:

hello.zip.ok

如果长这样:

hello.zip.invalid

上面的hello.zip包含有bin目录,说明打包打得有问题,JVM仍然无法从jar包中查找正确的class,原因是hong.Person必须按hong/Person.class存放,而不是bin/hong/Person.class

jar包还可以包含一个特殊的/META-INF/MANIFEST.MF文件,MANIFEST.MF是纯文本,可以指定Main-Class和其它信息。JVM会自动读取这个MANIFEST.MF文件,如果存在Main-Class,我们就不必在命令行指定启动的类名,而是用更方便的命令:

1
java -jar hello.jar

在大型项目中,不可能手动编写MANIFEST.MF文件,再手动创建jar包。Java社区提供了大量的开源构建工具,例如Maven,可以非常方便地创建jar包。

小结

JVM通过环境变量classpath决定搜索class的路径和顺序;

强烈建议不要设置系统环境变量classpath,建议始终通过-cp命令传入;

jar包本质上是zip格式,相当于目录,可以包含很多.class文件,方便下载和使用;

MANIFEST.MF文件可以提供jar包的信息,如Main-Class,这样可以直接运行jar包。

在Java开发中,许多童鞋经常被各种版本的JDK搞得晕头转向,本节我们就来详细讲解Java程序编译后的class文件版本问题。

我们通常说的Java 8,Java 11,Java 17,是指JDK的版本,也就是JVM的版本,更确切地说,就是java.exe这个程序的版本:

1
2
$ java -version
java version "17" 2021-09-14 LTS

而每个版本的JVM,它能执行的class文件版本也不同。例如,Java 11对应的class文件版本是55,而Java 17对应的class文件版本是61。

如果用Java 11编译一个Java程序,输出的class文件版本默认就是55,这个class既可以在Java 11上运行,也可以在Java 17上运行,因为Java 17支持的class文件版本是61,表示“最多支持到版本61”。

如果用Java 17编译一个Java程序,输出的class文件版本默认就是61,它可以在Java 17、Java 18上运行,但不可能在Java 11上运行,因为Java 11支持的class版本最多到55。如果使用低于Java 17的JVM运行,会得到一个UnsupportedClassVersionError,错误信息类似:

1
java.lang.UnsupportedClassVersionError: Xxx has been compiled by a more recent version of the Java Runtime...

只要看到UnsupportedClassVersionError就表示当前要加载的class文件版本超过了JVM的能力,必须使用更高版本的JVM才能运行。

打个比方,用Word 2013保存一个Word文件,这个文件也可以在Word 2016上打开。但反过来,用Word 2016保存一个Word文件,就无法使用Word 2013打开。

但是,且慢,用Word 2016也可以保存一个格式为Word 2013的文件,这样保存的Word文件就可以用低版本的Word 2013打开,但前提是保存时必须明确指定文件格式兼容Word 2013。

类似的,对应到JVM的class文件,我们也可以用Java 17编译一个Java程序,指定输出的class版本要兼容Java 11(即class版本55),这样编译生成的class文件就可以在Java >=11的环境中运行。

指定编译输出有两种方式,一种是在javac命令行中用参数--release设置:

1
$ javac --release 11 Main.java

参数--release 11表示源码兼容Java 11,编译的class输出版本为Java 11兼容,即class版本55。

第二种方式是用参数--source指定源码版本,用参数--target指定输出class版本:

1
$ javac --source 9 --target 11 Main.java

上述命令如果使用Java 17的JDK编译,它会把源码视为Java 9兼容版本,并输出class为Java 11兼容版本。注意--release参数和--source --target参数只能二选一,不能同时设置。

然而,指定版本如果低于当前的JDK版本,会有一些潜在的问题。例如,我们用Java 17编译Hello.java,参数设置--source 9--target 11

1
2
3
4
5
public class Hello {
public static void hello(String name) {
System.out.println("hello".indent(4));
}
}

用低于Java 11的JVM运行Hello会得到一个LinkageError,因为无法加载Hello.class文件,而用Java 11运行Hello会得到一个NoSuchMethodError,因为String.indent()方法是从Java 12才添加进来的,Java 11的String版本根本没有indent()方法。

注意

如果使用–release 11则会在编译时检查该方法是否在Java 11中存在。

因此,如果运行时的JVM版本是Java 11,则编译时也最好使用Java 11,而不是用高版本的JDK编译输出低版本的class。

如果使用javac编译时不指定任何版本参数,那么相当于使用--release 当前版本编译,即源码版本和输出版本均为当前版本。

在开发阶段,多个版本的JDK可以同时安装,当前使用的JDK版本可由JAVA_HOME环境变量切换。

源码版本

在编写源代码的时候,我们通常会预设一个源码的版本。在编译的时候,如果用--source--release指定源码版本,则使用指定的源码版本检查语法。

例如,使用了lambda表达式的源码版本至少要为8才能编译,使用了var关键字的源码版本至少要为10才能编译,使用switch表达式的源码版本至少要为12才能编译,且12和13版本需要启用--enable-preview参数。

小结

高版本的JDK可编译输出低版本兼容的class文件,但需注意,低版本的JDK可能不存在高版本JDK添加的类和方法,导致运行时报错。

运行时使用哪个JDK版本,编译时就尽量使用同一版本的JDK编译源码。

从Java 9开始,JDK又引入了模块(Module)。

什么是模块?这要从Java 9之前的版本说起。

我们知道,.class文件是JVM看到的最小可执行文件,而一个大型程序需要编写很多Class,并生成一堆.class文件,很不便于管理,所以,jar文件就是class文件的容器。

在Java 9之前,一个大型Java程序会生成自己的jar文件,同时引用依赖的第三方jar文件,而JVM自带的Java标准库,实际上也是以jar文件形式存放的,这个文件叫rt.jar,一共有60多M。

如果是自己开发的程序,除了一个自己的app.jar以外,还需要一堆第三方的jar包,运行一个Java程序,一般来说,命令行写这样:

1
java -cp app.jar:a.jar:b.jar:c.jar com.liaoxuefeng.sample.Main

注意

JVM自带的标准库rt.jar不要写到classpath中,写了反而会干扰JVM的正常运行。

如果漏写了某个运行时需要用到的jar,那么在运行期极有可能抛出ClassNotFoundException

所以,jar只是用于存放class的容器,它并不关心class之间的依赖。

从Java 9开始引入的模块,主要是为了解决“依赖”这个问题。如果a.jar必须依赖另一个b.jar才能运行,那我们应该给a.jar加点说明啥的,让程序在编译和运行的时候能自动定位到b.jar,这种自带“依赖关系”的class容器就是模块。

为了表明Java模块化的决心,从Java 9开始,原有的Java标准库已经由一个单一巨大的rt.jar分拆成了几十个模块,这些模块以.jmod扩展名标识,可以在$JAVA_HOME/jmods目录下找到它们:

  • java.base.jmod
  • java.compiler.jmod
  • java.datatransfer.jmod
  • java.desktop.jmod

这些.jmod文件每一个都是一个模块,模块名就是文件名。例如:模块java.base对应的文件就是java.base.jmod。模块之间的依赖关系已经被写入到模块内的module-info.class文件了。所有的模块都直接或间接地依赖java.base模块,只有java.base模块不依赖任何模块,它可以被看作是“根模块”,好比所有的类都是从Object直接或间接继承而来。

把一堆class封装为jar仅仅是一个打包的过程,而把一堆class封装为模块则不但需要打包,还需要写入依赖关系,并且还可以包含二进制代码(通常是JNI扩展)。此外,模块支持多版本,即在同一个模块中可以为不同的JVM提供不同的版本。

编写模块

那么,我们应该如何编写模块呢?还是以具体的例子来说。首先,创建模块和原有的创建Java项目是完全一样的,以oop-module工程为例,它的目录结构如下:

1
2
3
4
5
6
7
8
9
10
oop-module
├── bin
├── build.sh
└── src
├── com
│   └── itranswarp
│   └── sample
│   ├── Greeting.java
│   └── Main.java
└── module-info.java

其中,bin目录存放编译后的class文件,src目录存放源码,按包名的目录结构存放,仅仅在src目录下多了一个module-info.java这个文件,这就是模块的描述文件。在这个模块中,它长这样:

1
2
3
4
module hello.world {
requires java.base; // 可不写,任何模块都会自动引入java.base
requires java.xml;
}

其中,module是关键字,后面的hello.world是模块的名称,它的命名规范与包一致。花括号的requires xxx;表示这个模块需要引用的其他模块名。除了java.base可以被自动引入外,这里我们引入了一个java.xml的模块。

当我们使用模块声明了依赖关系后,才能使用引入的模块。例如,Main.java代码如下:

1
2
3
4
5
6
7
8
9
10
11
package com.itranswarp.sample;

// 必须引入java.xml模块后才能使用其中的类:
import javax.xml.XMLConstants;

public class Main {
public static void main(String[] args) {
Greeting g = new Greeting();
System.out.println(g.hello(XMLConstants.XML_NS_PREFIX));
}
}

如果把requires java.xml;module-info.java中去掉,编译将报错。可见,模块的重要作用就是声明依赖关系。

下面,我们用JDK提供的命令行工具来编译并创建模块。

首先,我们把工作目录切换到oop-module,在当前目录下编译所有的.java文件,并存放到bin目录下,命令如下:

1
$ javac -d bin src/module-info.java src/com/itranswarp/sample/*.java

如果编译成功,现在项目结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
oop-module
├── bin
│   ├── com
│   │   └── itranswarp
│   │   └── sample
│   │   ├── Greeting.class
│   │   └── Main.class
│   └── module-info.class
└── src
├── com
│   └── itranswarp
│   └── sample
│   ├── Greeting.java
│   └── Main.java
└── module-info.java

注意到src目录下的module-info.java被编译到bin目录下的module-info.class

下一步,我们需要把bin目录下的所有class文件先打包成jar,在打包的时候,注意传入--main-class参数,让这个jar包能自己定位main方法所在的类:

1
$ jar --create --file hello.jar --main-class com.itranswarp.sample.Main -C bin .

现在我们就在当前目录下得到了hello.jar这个jar包,它和普通jar包并无区别,可以直接使用命令java -jar hello.jar来运行它。但是我们的目标是创建模块,所以,继续使用JDK自带的jmod命令把一个jar包转换成模块:

1
$ jmod create --class-path hello.jar hello.jmod

于是,在当前目录下我们又得到了hello.jmod这个模块文件,这就是最后打包出来的传说中的模块!

运行模块

要运行一个jar,我们使用java -jar xxx.jar命令。要运行一个模块,我们只需要指定模块名。试试:

1
$ java --module-path hello.jmod --module hello.world

结果是一个错误:

1
2
Error occurred during initialization of boot layer
java.lang.module.FindException: JMOD format not supported at execution time: hello.jmod

原因是.jmod不能被放入--module-path中。换成.jar就没问题了:

1
2
$ java --module-path hello.jar --module hello.world
Hello, xml!

那我们辛辛苦苦创建的hello.jmod有什么用?答案是我们可以用它来打包JRE。

打包JRE

前面讲了,为了支持模块化,Java 9首先带头把自己的一个巨大无比的rt.jar拆成了几十个.jmod模块,原因就是,运行Java程序的时候,实际上我们用到的JDK模块,并没有那么多。不需要的模块,完全可以删除。

过去发布一个Java应用程序,要运行它,必须下载一个完整的JRE,再运行jar包。而完整的JRE块头很大,有100多M。怎么给JRE瘦身呢?

现在,JRE自身的标准库已经分拆成了模块,只需要带上程序用到的模块,其他的模块就可以被裁剪掉。怎么裁剪JRE呢?并不是说把系统安装的JRE给删掉部分模块,而是“复制”一份JRE,但只带上用到的模块。为此,JDK提供了jlink命令来干这件事。命令如下:

1
$ jlink --module-path hello.jmod --add-modules java.base,java.xml,hello.world --output jre/

我们在--module-path参数指定了我们自己的模块hello.jmod,然后,在--add-modules参数中指定了我们用到的3个模块java.basejava.xmlhello.world,用,分隔。最后,在--output参数指定输出目录。

现在,在当前目录下,我们可以找到jre目录,这是一个完整的并且带有我们自己hello.jmod模块的JRE。试试直接运行这个JRE:

1
2
$ jre/bin/java --module hello.world
Hello, xml!

要分发我们自己的Java应用程序,只需要把这个jre目录打个包给对方发过去,对方直接运行上述命令即可,既不用下载安装JDK,也不用知道如何配置我们自己的模块,极大地方便了分发和部署。

访问权限

前面我们讲过,Java的class访问权限分为public、protected、private和默认的包访问权限。引入模块后,这些访问权限的规则就要稍微做些调整。

确切地说,class的这些访问权限只在一个模块内有效,模块和模块之间,例如,a模块要访问b模块的某个class,必要条件是b模块明确地导出了可以访问的包。

举个例子:我们编写的模块hello.world用到了模块java.xml的一个类javax.xml.XMLConstants,我们之所以能直接使用这个类,是因为模块java.xmlmodule-info.java中声明了若干导出:

1
2
3
4
5
6
module java.xml {
exports java.xml;
exports javax.xml.catalog;
exports javax.xml.datatype;
...
}

只有它声明的导出的包,外部代码才被允许访问。换句话说,如果外部代码想要访问我们的hello.world模块中的com.itranswarp.sample.Greeting类,我们必须将其导出:

1
2
3
4
5
6
module hello.world {
exports com.itranswarp.sample;

requires java.base;
requires java.xml;
}

因此,模块进一步隔离了代码的访问权限。

练习

请下载并练习如何打包模块和JRE。

下载练习

小结

Java 9引入的模块目的是为了管理依赖;

使用模块可以按需打包JRE;

使用模块对类的访问权限有了进一步限制。

留言與分享

Java变量类型

分類 编程语言, Java

变量类型


在Java语言中,所有的变量在使用前必须声明。声明变量的基本格式如下:

1
type identifier [ = value][, identifier [= value] ...] ;

格式说明:type为Java数据类型。identifier是变量名。可以使用逗号隔开来声明多个同类型变量。
以下列出了一些变量的声明实例。注意有些包含了初始化过程。

1
2
3
4
5
6
int a, b, c;         // 声明三个int型整数:a、 b、c
int d = 3, e = 4, f = 5; // 声明三个整数并赋予初值
byte z = 22; // 声明并初始化 z
String s = "runoob"; // 声明并初始化字符串 s
double pi = 3.14159; // 声明了双精度浮点型变量 pi
char x = 'x'; // 声明变量 x 的值是字符 'x'。

Java语言支持的变量类型有:

  • 类变量:独立于方法之外的变量,用 static 修饰。
  • 实例变量:独立于方法之外的变量,不过没有 static 修饰。
  • 局部变量:类的方法中的变量。

Java 局部变量

  • 局部变量声明在方法、构造方法或者语句块中;
  • 局部变量在方法、构造方法、或者语句块被执行的时候创建,当它们执行完成后,变量将会被销毁;
  • ==访问修饰符不能用于局部变量;==
  • 局部变量只在声明它的方法、构造方法或者语句块中可见;
  • 局部变量是在栈上分配的。
  • 局部变量没有默认值,所以局部变量被声明后,必须经过初始化,才可以使用。

Java 实例变量

  • 实例变量声明在一个类中,但在方法、构造方法和语句块之外;
  • 当一个对象被实例化之后,每个实例变量的值就跟着确定;
  • 实例变量在对象创建的时候创建,在对象被销毁的时候销毁;
  • 实例变量的值应该至少被一个方法、构造方法或者语句块引用,使得外部能够通过这些方式获取实例变量信息;
  • 实例变量可以声明在使用前或者使用后;
  • ==访问修饰符可以修饰实例变量;==
  • 实例变量对于类中的方法、构造方法或者语句块是可见的。一般情况下应该把实例变量设为私有。通过使用访问修饰符可以使实例变量对子类可见;
  • 实例变量具有默认值。数值型变量的默认值是0,布尔型变量的默认值是false,引用类型变量的默认值是null。变量的值可以在声明时指定,也可以在构造方法中指定;
  • 实例变量可以直接通过变量名访问。但在静态方法以及其他类中,就应该使用完全限定名:ObejectReference.VariableName。

类变量(静态变量)

  • 类变量也称为静态变量,在类中以static关键字声明,但必须在方法构造方法和语句块之外。
  • 无论一个类创建了多少个对象,类只拥有类变量的一份拷贝。
  • 静态变量除了被声明为常量外很少使用。常量是指声明为public/private,final和static类型的变量。常量初始化后不可改变。
  • ==静态变量储存在静态存储区。==经常被声明为常量,很少单独使用static声明变量。
  • 静态变量在第一次被访问时创建,在程序结束时销毁。
  • 与实例变量具有相似的可见性。但为了对类的使用者可见,大多数静态变量声明为public类型。
  • 默认值和实例变量相似。数值型变量默认值是0,布尔型默认值是false,引用类型默认值是null。变量的值可以在声明的时候指定,也可以在构造方法中指定。此外,静态变量还可以在静态语句块中初始化。
  • 静态变量可以通过:ClassName.VariableName的方式访问。
  • 类变量被声明为public static final类型时,类变量名称一般建议使用大写字母。如果静态变量不是public和final类型,其命名方式与实例变量以及局部变量的命名方式一致。

留言與分享

JAVA快速入门教程

分類 编程语言, Java

Java最早是由SUN公司(已被Oracle收购)的詹姆斯·高斯林(高司令,人称Java之父)在上个世纪90年代初开发的一种编程语言,最初被命名为Oak,目标是针对小型家电设备的嵌入式应用,结果市场没啥反响。谁料到互联网的崛起,让Oak重新焕发了生机,于是SUN公司改造了Oak,在1995年以Java的名称正式发布,原因是Oak已经被人注册了,因此SUN注册了Java这个商标。随着互联网的高速发展,Java逐渐成为最重要的网络编程语言。

Java介于编译型语言和解释型语言之间。编译型语言如C、C++,代码是直接编译成机器码执行,但是不同的平台(x86、ARM等)CPU的指令集不同,因此,需要编译出每一种平台的对应机器码。解释型语言如Python、Ruby没有这个问题,可以由解释器直接加载源码然后运行,代价是运行效率太低。而Java是将代码编译成一种“字节码”,它类似于抽象的CPU指令,然后,针对不同平台编写虚拟机,不同平台的虚拟机负责加载字节码并执行,这样就实现了“一次编写,到处运行”的效果。当然,这是针对Java开发者而言。对于虚拟机,需要为每个平台分别开发。为了保证不同平台、不同公司开发的虚拟机都能正确执行Java字节码,SUN公司制定了一系列的Java虚拟机规范。从实践的角度看,JVM的兼容性做得非常好,低版本的Java字节码完全可以正常运行在高版本的JVM上。

随着Java的发展,SUN给Java又分出了三个不同版本:

  • Java SE:Standard Edition
  • Java EE:Enterprise Edition
  • Java ME:Micro Edition

这三者之间有啥关系呢?

1
2
3
4
5
6
7
8
9
┌───────────────────────────┐
│Java EE │
│ ┌────────────────────┐ │
│ │Java SE │ │
│ │ ┌─────────────┐ │ │
│ │ │ Java ME │ │ │
│ │ └─────────────┘ │ │
│ └────────────────────┘ │
└───────────────────────────┘

简单来说,Java SE就是标准版,包含标准的JVM和标准库,而Java EE是企业版,它只是在Java SE的基础上加上了大量的API和库,以便方便开发Web应用、数据库、消息服务等,Java EE的应用使用的虚拟机和Java SE完全相同。

Java ME就和Java SE不同,它是一个针对嵌入式设备的“瘦身版”,Java SE的标准库无法在Java ME上使用,Java ME的虚拟机也是“瘦身版”。

毫无疑问,Java SE是整个Java平台的核心,而Java EE是进一步学习Web应用所必须的。我们熟悉的Spring等框架都是Java EE开源生态系统的一部分。不幸的是,Java ME从来没有真正流行起来,反而是Android开发成为了移动平台的标准之一,因此,没有特殊需求,不建议学习Java ME。

因此我们推荐的Java学习路线图如下:

  1. 首先要学习Java SE,掌握Java语言本身、Java核心开发技术以及Java标准库的使用;
  2. 如果继续学习Java EE,那么Spring框架、数据库开发、分布式架构就是需要学习的;
  3. 如果要学习大数据开发,那么Hadoop、Spark、Flink这些大数据平台就是需要学习的,他们都基于Java或Scala开发;
  4. 如果想要学习移动开发,那么就深入Android平台,掌握Android App开发。

无论怎么选择,Java SE的核心技术是基础,这个教程的目的就是让你完全精通Java SE并掌握Java EE!

Java版本

从1996年发布1.0版本开始,到目前为止,最新的Java版本是Java 21:

时间 版本
1996 1.0
1997 1.1
1998 1.2
2000 1.3
2002 1.4
2004 1.5 / 5.0
2005 1.6 / 6.0
2011 1.7 / 7.0
2014 1.8 / 8.0
2017/9 1.9 / 9.0
2018/3 10
2018/9 11
2019/3 12
2019/9 13
2020/3 14
2020/9 15
2021/3 16
2021/9 17
2022/3 18
2022/9 19
2023/3 20
2023/9 21
2024/3 22
2024/9 23

本教程使用的Java版本是最新版的Java 23

名词解释

初学者学Java,经常听到JDK、JRE这些名词,它们到底是啥?

  • JDK:Java Development Kit
  • JRE:Java Runtime Environment

简单地说,JRE就是运行Java字节码的虚拟机。但是,如果只有Java源码,要编译成Java字节码,就需要JDK,因为JDK除了包含JRE,还提供了编译器、调试器等开发工具。

二者关系如下:

1
2
3
4
5
6
7
8
9
10
11
 ┌─    ┌──────────────────────────────────┐
│ │ Compiler, debugger, etc. │
│ └──────────────────────────────────┘
JDK ┌─ ┌──────────────────────────────────┐
│ │ │ │
│ JRE │ JVM + Runtime Library │
│ │ │ │
└─ └─ └──────────────────────────────────┘
┌───────┐┌───────┐┌───────┐┌───────┐
│Windows││ Linux ││ macOS ││others │
└───────┘└───────┘└───────┘└───────┘

要学习Java开发,当然需要安装JDK了。

那JSR、JCP……又是啥?

  • JSR规范:Java Specification Request
  • JCP组织:Java Community Process

为了保证Java语言的规范性,SUN公司搞了一个JSR规范,凡是想给Java平台加一个功能,比如说访问数据库的功能,大家要先创建一个JSR规范,定义好接口,这样,各个数据库厂商都按照规范写出Java驱动程序,开发者就不用担心自己写的数据库代码在MySQL上能跑,却不能跑在PostgreSQL上。

所以JSR是一系列的规范,从JVM的内存模型到Web程序接口,全部都标准化了。而负责审核JSR的组织就是JCP。

一个JSR规范发布时,为了让大家有个参考,还要同时发布一个“参考实现”,以及一个“兼容性测试套件”:

  • RI:Reference Implementation
  • TCK:Technology Compatibility Kit

比如有人提议要搞一个基于Java开发的消息服务器,这个提议很好啊,但是光有提议还不行,得贴出真正能跑的代码,这就是RI。如果有其他人也想开发这样一个消息服务器,如何保证这些消息服务器对开发者来说接口、功能都是相同的?所以还得提供TCK。

通常来说,RI只是一个“能跑”的正确的代码,它不追求速度,所以,如果真正要选择一个Java的消息服务器,一般是没人用RI的,大家都会选择一个有竞争力的商用或开源产品。

参考:Java消息服务JMS的JSR:https://jcp.org/en/jsr/detail?id=914

要开始学习Java编程,我们需要首先搭建开发环境。

本节我们介绍如何安装JDK,在命令行编译Java程序,以及如何使用IDE来开发Java。

安装JDK

因为Java程序必须运行在JVM之上,所以,我们第一件事情就是安装JDK。

搜索JDK 23,确保从Oracle的官网下载最新的稳定版JDK:

1
2
3
4
5
6
7
8
Java SE Development Kit 23 downloads

Linux macOS Windows
-------

x64 Compressed Archive Download
x64 Installer Download
x64 MSI Installer Download

选择合适的操作系统与安装包,找到Java SE 23的下载链接Download,下载安装即可。Windows优先选x64 MSI Installer,Linux和macOS要根据自己电脑的CPU是ARM还是x86来选择合适的安装包。

设置环境变量

安装完JDK后,需要设置一个JAVA_HOME的环境变量,它指向JDK的安装目录。在Windows下,它是安装目录,类似:

1
C:\Program Files\Java\jdk-23

在macOS下,它在~/.bash_profile~/.zprofile里,它是:

1
export JAVA_HOME=`/usr/libexec/java_home -v 23`

然后,把JAVA_HOMEbin目录附加到系统环境变量PATH上。在Windows下,它长这样:

1
Path=%JAVA_HOME%\bin;<现有的其他路径>

在macOS下,它在~/.bash_profile~/.zprofile里,长这样:

1
export PATH=$JAVA_HOME/bin:$PATH

JAVA_HOMEbin目录添加到PATH中是为了在任意文件夹下都可以运行java。打开PowerShell窗口,输入命令java -version,如果一切正常,你会看到如下输出:

1
2
3
4
5
6
7
8
9
10
11
12
┌─────────────────────────────────────────────────────────┐
│Windows PowerShell - □ x │
├─────────────────────────────────────────────────────────┤
│Windows PowerShell │
│Copyright (C) Microsoft Corporation. All rights reserved.│
│ │
│PS C:\Users\liaoxuefeng> java -version │
│java version "23" ... │
│Java(TM) SE Runtime Environment │
│Java HotSpot(TM) 64-Bit Server VM │
│ │
└─────────────────────────────────────────────────────────┘

如果你看到的版本号不是23,而是151.8之类,说明系统存在多个JDK,且默认JDK不是JDK 23,需要把JDK 23提到PATH前面。

如果你得到一个错误输出:“无法将“java”项识别为 cmdlet、函数、脚本文件或可运行程序的名称。”:

1
2
3
4
5
6
7
8
9
10
11
12
┌─────────────────────────────────────────────────────────┐
│Windows PowerShell - □ x │
├─────────────────────────────────────────────────────────┤
│Windows PowerShell │
│Copyright (C) Microsoft Corporation. All rights reserved.│
│ │
│PS C:\Users\liaoxuefeng> java -version │
│java : The term 'java' is not recognized as ... │
│... │
│ + FullyQualifiedErrorId : CommandNotFoundException │
│ │
└─────────────────────────────────────────────────────────┘

这是因为系统无法找到Java虚拟机的程序java.exe,需要检查JAVA_HOMEPATH的配置。

可以参考如何设置或更改PATH系统变量

JDK

细心的童鞋还可以在JAVA_HOMEbin目录下找到很多可执行文件:

  • java:这个可执行程序其实就是JVM,运行Java程序,就是启动JVM,然后让JVM执行指定的编译后的代码;
  • javac:这是Java的编译器,它用于把Java源码文件(以.java后缀结尾)编译为Java字节码文件(以.class后缀结尾);
  • jar:用于把一组.class文件打包成一个.jar文件,便于发布;
  • javadoc:用于从Java源码中自动提取注释并生成文档;
  • jdb:Java调试器,用于开发阶段的运行调试。


我们来编写第一个Java程序。

打开文本编辑器,输入以下代码:

1
2
3
4
5
public class Hello {
public static void main(String[] args) {
System.out.println("Hello, world!");
}
}

在一个Java程序中,你总能找到一个类似:

1
2
3
public class Hello {
...
}

的定义,这个定义被称为class(类),这里的类名是Hello,大小写敏感,class用来定义一个类,public表示这个类是公开的,publicclass都是Java的关键字,必须小写,Hello是类的名字,按照习惯,首字母H要大写。而花括号{}中间则是类的定义。

注意到类的定义中,我们定义了一个名为main的方法:

1
2
3
public static void main(String[] args) {
...
}

方法是可执行的代码块,一个方法除了方法名main,还有用()括起来的方法参数,这里的main方法有一个参数,参数类型是String[],参数名是argspublicstatic用来修饰方法,这里表示它是一个公开的静态方法,void是方法的返回类型,而花括号{}中间的就是方法的代码。

方法的代码每一行用;结束,这里只有一行代码,就是:

1
System.out.println("Hello, world!");

它用来打印一个字符串到屏幕上。

Java规定,某个类定义的public static void main(String[] args)是Java程序的固定入口方法,因此,Java程序总是从main方法开始执行。

注意到Java源码的缩进不是必须的,但是用缩进后,格式好看,很容易看出代码块的开始和结束,缩进一般是4个空格或者一个tab。

最后,当我们把代码保存为文件时,文件名必须是Hello.java,而且文件名也要注意大小写,因为要和我们定义的类名Hello完全保持一致。

如何运行Java程序

Java源码本质上是一个文本文件,我们需要先用javacHello.java编译成字节码文件Hello.class,然后,用java命令执行这个字节码文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
┌──────────────────┐
│ Hello.java │◀── source code
└──────────────────┘
│ compile

┌──────────────────┐
│ Hello.class │◀── byte code
└──────────────────┘
│ execute

┌──────────────────┐
│ Run on JVM │
└──────────────────┘

因此,可执行文件javac是编译器,而可执行文件java就是虚拟机。

第一步,在保存Hello.java的目录下执行命令javac Hello.java

1
$ javac Hello.java

如果源代码无误,上述命令不会有任何输出,而当前目录下会产生一个Hello.class文件:

1
2
$ ls
Hello.class Hello.java

第二步,执行Hello.class,使用命令java Hello(注意不是java Hello.class):

1
2
$ java Hello
Hello, world!

注意:给虚拟机传递的参数Hello是我们定义的类名,虚拟机自动查找对应的class文件并执行。

如果执行java Hello报错:

1
2
3
$ java Hello
Error: Could not find or load main class Hello
Caused by: java.lang.ClassNotFoundException: Hello

出现ClassNotFoundException信息,说明在当前目录下并没有Hello.class这个文件,请切换到Hello.class的目录,然后执行java Hello

有一些童鞋可能知道,直接运行java Hello.java也是可以的:

1
2
$ java Hello.java 
Hello, world!

这是从Java 11开始新增的一个功能,它可以直接运行一个单文件源码!

需要注意的是,在实际项目中,单个不依赖第三方库的Java源码是非常罕见的,所以,绝大多数情况下,我们无法直接运行一个Java源码文件,原因是它需要依赖其他的库。

小结

一个Java源码只能定义一个public类型的class,并且class名称和文件名要完全一致;

使用javac可以将.java源码编译成.class字节码;

使用java可以运行一个已编译的Java程序,参数是类名。

IDE是集成开发环境:Integrated Development Environment的缩写。

使用IDE的好处在于,可以把编写代码、组织项目、编译、运行、调试等放到一个环境中运行,能极大地提高开发效率。

IDE提升开发效率主要靠以下几点:

  • 编辑器的自动提示,可以大大提高敲代码的速度;
  • 代码修改后可以自动重新编译,并直接运行;
  • 可以方便地进行断点调试。

目前,流行的用于Java开发的IDE有:

Eclipse

Eclipse是由IBM开发并捐赠给开源社区的一个IDE,也是目前应用最广泛的IDE。Eclipse的特点是它本身是Java开发的,并且基于插件结构,即使是对Java开发的支持也是通过插件JDT实现的。

除了用于Java开发,Eclipse配合插件也可以作为C/C++开发环境、PHP开发环境、Rust开发环境等。

IntelliJ Idea

IntelliJ Idea是由JetBrains公司开发的一个功能强大的IDE,分为免费版和商用付费版。JetBrains公司的IDE平台也是基于IDE平台+语言插件的模式,支持Python开发环境、Ruby开发环境、PHP开发环境等,这些开发环境也分为免费版和付费版。

NetBeans

NetBeans是最早由SUN开发的开源IDE,由于使用人数较少,目前已不再流行。

使用Eclipse

你可以使用任何IDE进行Java学习和开发。我们不讨论任何关于IDE的优劣,本教程使用Eclipse作为开发演示环境,原因在于:

  • 完全免费使用;
  • 所有功能完全满足Java开发需求。

安装Eclipse

Eclipse的发行版提供了预打包的开发环境,包括Java、JavaEE、C++、PHP、Rust等。从这里下载:

我们需要下载的版本是Eclipse IDE for Java Developers:

eclipse-jdt

根据操作系统是Windows、Mac还是Linux,从右边选择对应的下载链接。

注意

教程从头到尾并不需要用到Enterprise Java的功能,所以不需要下载Eclipse IDE for Enterprise Java Developers

设置Eclipse

下载并安装完成后,我们启动Eclipse,对IDE环境做一个基本设置:

选择菜单“Eclipse/Window”-“Preferences”,打开配置对话框:

eclipse-pref

我们需要调整以下设置项:

General > Editors > Text Editors

钩上“Show line numbers”,这样编辑器会显示行号;

General > Workspace

钩上“Refresh using native hooks or polling”,这样Eclipse会自动刷新文件夹的改动;

对于“Text file encoding”,如果Default不是UTF-8,一定要改为“Other:UTF-8”,所有文本文件均使用UTF-8编码;

对于“New text file line delimiter”,建议使用Unix,即换行符使用\n而不是Windows的\r\n

workspace-utf-8

Java > Compiler

将“Compiler compliance level”设置为20,本教程的所有代码均使用Java 20的语法,并且编译到Java 20的版本。

去掉“Use default compliance settings”并钩上“Enable preview features for Java 20”,这样我们就可以使用Java 20的预览功能。

注意

如果Compiler compliance level没有22这个选项,请更新到最新版Eclipse。如果更新后还是没有22,打开Help - Eclipse Marketplace,搜索Java 22 Support安装后重启即可。

Java > Installed JREs

在Installed JREs中应该看到Java SE 20,如果还有其他的JRE,可以删除,以确保Java SE 20是默认的JRE。

Eclipse IDE结构

打开Eclipse后,整个IDE由若干个区域组成:

eclipse-workspace

  • 中间可编辑的文本区(见1)是编辑器,用于编辑源码;
  • 分布在左右和下方的是视图:
    • Package Exploroer(见2)是Java项目的视图
    • Console(见3)是命令行输出视图
    • Outline(见4)是当前正在编辑的Java源码的结构视图
  • 视图可以任意组合,然后把一组视图定义成一个Perspective(见5),Eclipse预定义了Java、Debug等几个Perspective,用于快速切换。

新建Java项目

在Eclipse菜单选择“File”-“New”-“Java Project”,填入HelloWorld,JRE选择Java SE 22

new-java-project

暂时不要勾选“Create module-info.java file”,因为模块化机制我们后面才会讲到:

new-java-project-2

点击“Finish”就成功创建了一个名为HelloWorld的Java工程。

新建Java文件并运行

展开HelloWorld工程,选中源码目录src,点击右键,在弹出菜单中选择“New”-“Class”:

new-java-class

在弹出的对话框中,Name一栏填入Hello

new-hello-class

点击”Finish“,就自动在src目录下创建了一个名为Hello.java的源文件。我们双击打开这个源文件,填上代码:

hello-java-source

保存,然后选中文件Hello.java,点击右键,在弹出的菜单中选中“Run As…”-“Java Application”:

run-as-java-application

Console窗口中就可以看到运行结果:

console

如果没有在主界面中看到Console窗口,请选中菜单“Window”-“Show View”-“Console”,即可显示。

我们先剖析一个完整的Java程序,它的基本结构是什么:

1
2
3
4
5
6
7
8
9
10
11
12
/**
* 可以用来自动创建文档的注释
*/
public class Hello {
public static void main(String[] args) {
// 向屏幕输出文本:
System.out.println("Hello, world!");
/* 多行注释开始
注释内容
注释结束 */
}
} // class定义结束

因为Java是面向对象的语言,一个程序的基本单位就是classclass是关键字,这里定义的class名字就是Hello

1
2
3
public class Hello { // 类名是Hello
// ...
} // class定义结束

类名要求:

  • 类名必须以英文字母开头,后接字母,数字和下划线的组合
  • 习惯以大写字母开头

要注意遵守命名习惯,好的类命名:

  • Hello
  • NoteBook
  • VRPlayer

不好的类命名:

  • hello
  • Good123
  • Note_Book
  • _World

注意到public是访问修饰符,表示该class是公开的。

不写public,也能正确编译,但是这个类将无法从命令行执行。

class内部,可以定义若干方法(method):

1
2
3
4
5
public class Hello {
public static void main(String[] args) { // 方法名是main
// 方法代码...
} // 方法定义结束
}

方法定义了一组执行语句,方法内部的代码将会被依次顺序执行。

这里的方法名是main,返回值是void,表示没有任何返回值。

我们注意到public除了可以修饰class外,也可以修饰方法。而关键字static是另一个修饰符,它表示静态方法,后面我们会讲解方法的类型,目前,我们只需要知道,Java入口程序规定的方法必须是静态方法,方法名必须为main,括号内的参数必须是String数组。

方法名也有命名规则,命名和class一样,但是首字母小写:

好的方法命名:

  • main
  • goodMorning
  • playVR

不好的方法命名:

  • Main
  • good123
  • good_morning
  • _playVR

在方法内部,语句才是真正的执行代码。Java的每一行语句必须以分号结束:

1
2
3
4
5
public class Hello {
public static void main(String[] args) {
System.out.println("Hello, world!"); // 语句
}
}

在Java程序中,注释是一种给人阅读的文本,不是程序的一部分,所以编译器会自动忽略注释。

Java有3种注释,第一种是单行注释,以双斜线开头,直到这一行的结尾结束:

1
// 这是注释...

而多行注释以/*星号开头,以*/结束,可以有多行:

1
2
3
4
5
/*
这是注释
blablabla...
这也是注释
*/

还有一种特殊的多行注释,以/**开头,以*/结束,如果有多行,每行通常以星号开头:

1
2
3
4
5
6
7
8
9
10
/**
* 可以用来自动创建文档的注释
*
* @auther liaoxuefeng
*/
public class Hello {
public static void main(String[] args) {
System.out.println("Hello, world!");
}
}

这种特殊的多行注释需要写在类和方法的定义处,可以用于自动创建文档。

Java程序对格式没有明确的要求,多几个空格或者回车不影响程序的正确性,但是我们要养成良好的编程习惯,注意遵守Java社区约定的编码格式。

那约定的编码格式有哪些要求呢?其实我们在前面介绍的Eclipse IDE提供了快捷键Ctrl+Shift+F(macOS是⌘+⇧+F)帮助我们快速格式化代码的功能,Eclipse就是按照约定的编码格式对代码进行格式化的,所以只需要看看格式化后的代码长啥样就行了。具体的代码格式要求可以在Eclipse的设置中Java-Code Style查看。

变量

什么是变量?

变量就是初中数学的代数的概念,例如一个简单的方程,x,y都是变量:

在Java中,变量分为两种:基本类型的变量和引用类型的变量。

我们先讨论基本类型的变量。

在Java中,变量必须先定义后使用,在定义变量的时候,可以给它一个初始值。例如:

1
int x = 1;

上述语句定义了一个整型int类型的变量,名称为x,初始值为1

不写初始值,就相当于给它指定了默认值。默认值总是0

来看一个完整的定义变量,然后打印变量值的例子:

1
2
3
4
5
6
7
// 定义并打印变量
public class Main {
public static void main(String[] args) {
int x = 100; // 定义int类型变量x,并赋予初始值100
System.out.println(x); // 打印该变量的值
}
}

变量的一个重要特点是可以重新赋值。例如,对变量x,先赋值100,再赋值200,观察两次打印的结果:

1
2
3
4
5
6
7
8
9
// 重新赋值变量
public class Main {
public static void main(String[] args) {
int x = 100; // 定义int类型变量x,并赋予初始值100
System.out.println(x); // 打印该变量的值,观察是否为100
x = 200; // 重新赋值为200
System.out.println(x); // 打印该变量的值,观察是否为200
}
}

注意到第一次定义变量x的时候,需要指定变量类型int,因此使用语句int x = 100;。而第二次重新赋值的时候,变量x已经存在了,不能再重复定义,因此不能指定变量类型int,必须使用语句x = 200;

变量不但可以重新赋值,还可以赋值给其他变量。让我们来看一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 变量之间的赋值
public class Main {
public static void main(String[] args) {
int n = 100; // 定义变量n,同时赋值为100
System.out.println("n = " + n); // 打印n的值

n = 200; // 变量n赋值为200
System.out.println("n = " + n); // 打印n的值

int x = n; // 变量x赋值为n(n的值为200,因此赋值后x的值也是200)
System.out.println("x = " + x); // 打印x的值

x = x + 100; // 变量x赋值为x+100(x的值为200,因此赋值后x的值是200+100=300)
System.out.println("x = " + x); // 打印x的值
System.out.println("n = " + n); // 再次打印n的值,n应该是200还是300?
}
}

我们一行一行地分析代码执行流程:

执行int n = 100;,该语句定义了变量n,同时赋值为100,因此,JVM在内存中为变量n分配一个“存储单元”,填入值100

1
2
3
4
5
6
      n


┌───┬───┬───┬───┬───┬───┬───┐
│ │100│ │ │ │ │ │
└───┴───┴───┴───┴───┴───┴───┘

执行n = 200;时,JVM把200写入变量n的存储单元,因此,原有的值被覆盖,现在n的值为200

1
2
3
4
5
6
      n


┌───┬───┬───┬───┬───┬───┬───┐
│ │200│ │ │ │ │ │
└───┴───┴───┴───┴───┴───┴───┘

执行int x = n;时,定义了一个新的变量x,同时对x赋值,因此,JVM需要新分配一个存储单元给变量x,并写入和变量n一样的值,结果是变量x的值也变为200

1
2
3
4
5
6
      n           x
│ │
▼ ▼
┌───┬───┬───┬───┬───┬───┬───┐
│ │200│ │ │200│ │ │
└───┴───┴───┴───┴───┴───┴───┘

执行x = x + 100;时,JVM首先计算等式右边的值x + 100,结果为300(因为此刻x的值为200),然后,将结果300写入x的存储单元,因此,变量x最终的值变为300

1
2
3
4
5
6
      n           x
│ │
▼ ▼
┌───┬───┬───┬───┬───┬───┬───┐
│ │200│ │ │300│ │ │
└───┴───┴───┴───┴───┴───┴───┘

可见,变量可以反复赋值。注意,等号=是赋值语句,不是数学意义上的相等,否则无法解释x = x + 100

基本数据类型

基本数据类型是CPU可以直接进行运算的类型。Java定义了以下几种基本数据类型:

  • 整数类型:byte,short,int,long
  • 浮点数类型:float,double
  • 字符类型:char
  • 布尔类型:boolean

Java定义的这些基本数据类型有什么区别呢?要了解这些区别,我们就必须简单了解一下计算机内存的基本结构。

计算机内存的最小存储单元是字节(byte),一个字节就是一个8位二进制数,即8个bit。它的二进制表示范围从00000000~11111111,换算成十进制是0~255,换算成十六进制是00~ff

内存单元从0开始编号,称为内存地址。每个内存单元可以看作一间房间,内存地址就是门牌号。

1
2
3
4
  0   1   2   3   4   5   6  ...
┌───┬───┬───┬───┬───┬───┬───┐
│ │ │ │ │ │ │ │...
└───┴───┴───┴───┴───┴───┴───┘

一个字节是1byte,1024字节是1K,1024K是1M,1024M是1G,1024G是1T。一个拥有4T内存的计算机的字节数量就是:

1
2
3
4
5
4T = 4 x 1024G
= 4 x 1024 x 1024M
= 4 x 1024 x 1024 x 1024K
= 4 x 1024 x 1024 x 1024 x 1024
= 4398046511104

不同的数据类型占用的字节数不一样。我们看一下Java基本数据类型占用的字节数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
       ┌───┐
byte │ │
└───┘
┌───┬───┐
short │ │ │
└───┴───┘
┌───┬───┬───┬───┐
int │ │ │ │ │
└───┴───┴───┴───┘
┌───┬───┬───┬───┬───┬───┬───┬───┐
long │ │ │ │ │ │ │ │ │
└───┴───┴───┴───┴───┴───┴───┴───┘
┌───┬───┬───┬───┐
float │ │ │ │ │
└───┴───┴───┴───┘
┌───┬───┬───┬───┬───┬───┬───┬───┐
double │ │ │ │ │ │ │ │ │
└───┴───┴───┴───┴───┴───┴───┴───┘
┌───┬───┐
char │ │ │
└───┴───┘

byte恰好就是一个字节,而longdouble需要8个字节。

整型

对于整型类型,Java只定义了带符号的整型,因此,最高位的bit表示符号位(0表示正数,1表示负数)。各种整型能表示的最大范围如下:

  • byte:-128 ~ 127
  • short: -32768 ~ 32767
  • int: -2147483648 ~ 2147483647
  • long: -9223372036854775808 ~ 9223372036854775807

我们来看定义整型的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 定义整型
public class Main {
public static void main(String[] args) {
int i = 2147483647;
int i2 = -2147483648;
int i3 = 2_000_000_000; // 加下划线更容易识别
int i4 = 0xff0000; // 十六进制表示的16711680
int i5 = 0b1000000000; // 二进制表示的512

long n1 = 9000000000000000000L; // long型的结尾需要加L
long n2 = 900; // 没有加L,此处900为int,但int类型可以赋值给long
int i6 = 900L; // 错误:不能把long型赋值给int
}
}

特别注意:同一个数的不同进制的表示是完全相同的,例如15=0xf0b1111

浮点型

浮点类型的数就是小数,因为小数用科学计数法表示的时候,小数点是可以“浮动”的,如1234.5可以表示成12.345x102,也可以表示成1.2345x103,所以称为浮点数。

下面是定义浮点数的例子:

1
2
3
4
5
6
7
float f1 = 3.14f;
float f2 = 3.14e38f; // 科学计数法表示的3.14x10^38
float f3 = 1.0; // 错误:不带f结尾的是double类型,不能赋值给float

double d = 1.79e308;
double d2 = -1.79e308;
double d3 = 4.9e-324; // 科学计数法表示的4.9x10^-324

对于float类型,需要加上f后缀。

浮点数可表示的范围非常大,float类型可最大表示3.4x1038,而double类型可最大表示1.79x10308。

布尔类型

布尔类型boolean只有truefalse两个值,布尔类型总是关系运算的计算结果:

1
2
3
4
5
boolean b1 = true;
boolean b2 = false;
boolean isGreater = 5 > 3; // 计算结果为true
int age = 12;
boolean isAdult = age >= 18; // 计算结果为false

Java语言对布尔类型的存储并没有做规定,因为理论上存储布尔类型只需要1 bit,但是通常JVM内部会把boolean表示为4字节整数。

字符类型

字符类型char表示一个字符。Java的char类型除了可表示标准的ASCII外,还可以表示一个Unicode字符:

1
2
3
4
5
6
7
8
9
// 字符类型
public class Main {
public static void main(String[] args) {
char a = 'A';
char zh = '中';
System.out.println(a);
System.out.println(zh);
}
}

注意char类型使用单引号',且仅有一个字符,要和双引号"的字符串类型区分开。

引用类型

除了上述基本类型的变量,剩下的都是引用类型。例如,引用类型最常用的就是String字符串:

1
String s = "hello";

引用类型的变量类似于C语言的指针,它内部存储一个“地址”,指向某个对象在内存的位置,后续我们介绍类的概念时会详细讨论。

常量

定义变量的时候,如果加上final修饰符,这个变量就变成了常量:

1
2
3
4
final double PI = 3.14; // PI是一个常量
double r = 5.0;
double area = PI * r * r;
PI = 300; // compile error!

常量在定义时进行初始化后就不可再次赋值,再次赋值会导致编译错误。

常量的作用是用有意义的变量名来避免魔术数字(Magic number),例如,不要在代码中到处写3.14,而是定义一个常量。如果将来需要提高计算精度,我们只需要在常量的定义处修改,例如,改成3.1416,而不必在所有地方替换3.14

为了和变量区分开来,根据习惯,常量名通常全部大写

var关键字

有些时候,类型的名字太长,写起来比较麻烦。例如:

1
StringBuilder sb = new StringBuilder();

这个时候,如果想省略变量类型,可以使用var关键字:

1
var sb = new StringBuilder();

编译器会根据赋值语句自动推断出变量sb的类型是StringBuilder。对编译器来说,语句:

1
var sb = new StringBuilder();

实际上会自动变成:

1
StringBuilder sb = new StringBuilder();

因此,使用var定义变量,仅仅是少写了变量类型而已。

变量的作用范围

在Java中,多行语句用{ ... }括起来。很多控制语句,例如条件判断和循环,都以{ ... }作为它们自身的范围,例如:

1
2
3
4
5
6
7
8
9
10
11
if (...) { // if开始
...
while (...) { // while 开始
...
if (...) { // if开始
...
} // if结束
...
} // while结束
...
} // if结束

只要正确地嵌套这些{ ... },编译器就能识别出语句块的开始和结束。而在语句块中定义的变量,它有一个作用域,就是从定义处开始,到语句块结束。超出了作用域引用这些变量,编译器会报错。举个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
{
...
int i = 0; // 变量i从这里开始定义
...
{
...
int x = 1; // 变量x从这里开始定义
...
{
...
String s = "hello"; // 变量s从这里开始定义
...
} // 变量s作用域到此结束
...
// 注意,这是一个新的变量s,它和上面的变量同名,
// 但是因为作用域不同,它们是两个不同的变量:
String s = "hi";
...
} // 变量x和s作用域到此结束
...
} // 变量i作用域到此结束

定义变量时,要遵循作用域最小化原则,尽量将变量定义在尽可能小的作用域,并且,不要重复使用变量名。

小结

Java提供了两种变量类型:基本类型和引用类型

基本类型包括整型,浮点型,布尔型,字符型。

变量可重新赋值,等号是赋值语句,不是数学意义的等号。

常量在初始化后不可重新赋值,使用常量便于理解程序意图。

Java的整数运算遵循四则运算规则,可以使用任意嵌套的小括号。四则运算规则和初等数学一致。例如:

1
2
3
4
5
6
7
8
9
// 四则运算
public class Main {
public static void main(String[] args) {
int i = (100 + 200) * (99 - 88); // 3300
int n = 7 * (5 + (i - 9)); // 23072
System.out.println(i);
System.out.println(n);
}
}

整数的数值表示不但是精确的,而且整数运算永远是精确的,即使是除法也是精确的,因为两个整数相除只能得到结果的整数部分:

1
int x = 12345 / 67; // 184

求余运算使用%

1
int y = 12345 % 67; // 12345÷67的余数是17

特别注意:整数的除法对于除数为0时运行时将报错,但编译不会报错。

溢出

要特别注意,整数由于存在范围限制,如果计算结果超出了范围,就会产生溢出,而溢出不会出错,却会得到一个奇怪的结果:

1
2
3
4
5
6
7
8
9
// 运算溢出
public class Main {
public static void main(String[] args) {
int x = 2147483640;
int y = 15;
int sum = x + y;
System.out.println(sum); // -2147483641
}
}

要解释上述结果,我们把整数214748364015换成二进制做加法:

1
2
3
4
  0111 1111 1111 1111 1111 1111 1111 1000
+ 0000 0000 0000 0000 0000 0000 0000 1111
-----------------------------------------
1000 0000 0000 0000 0000 0000 0000 0111

由于最高位计算结果为1,因此,加法结果变成了一个负数。

要解决上面的问题,可以把int换成long类型,由于long可表示的整型范围更大,所以结果就不会溢出:

1
2
3
4
long x = 2147483640;
long y = 15;
long sum = x + y;
System.out.println(sum); // 2147483655

还有一种简写的运算符,即+=-=*=/=,它们的使用方法如下:

1
2
n += 100; // 3409, 相当于 n = n + 100;
n -= 100; // 3309, 相当于 n = n - 100;

自增/自减

Java还提供了++运算和--运算,它们可以对一个整数进行加1和减1的操作:

1
2
3
4
5
6
7
8
9
10
// 自增/自减运算
public class Main {
public static void main(String[] args) {
int n = 3300;
n++; // 3301, 相当于 n = n + 1;
n--; // 3300, 相当于 n = n - 1;
int y = 100 + (++n); // 不要这么写
System.out.println(y);
}
}

注意++写在前面和后面计算结果是不同的,++n表示先加1再引用n,n++表示先引用n再加1。不建议把++运算混入到常规运算中,容易自己把自己搞懵了。

移位运算

在计算机中,整数总是以二进制的形式表示。例如,int类型的整数7使用4字节表示的二进制如下:

1
00000000 0000000 0000000 00000111

可以对整数进行移位运算。对整数7左移1位将得到整数14,左移两位将得到整数28

1
2
3
4
5
int n = 7;       // 00000000 00000000 00000000 00000111 = 7
int a = n << 1; // 00000000 00000000 00000000 00001110 = 14
int b = n << 2; // 00000000 00000000 00000000 00011100 = 28
int c = n << 28; // 01110000 00000000 00000000 00000000 = 1879048192
int d = n << 29; // 11100000 00000000 00000000 00000000 = -536870912

左移29位时,由于最高位变成1,因此结果变成了负数。

类似的,对整数28进行右移,结果如下:

1
2
3
4
int n = 7;       // 00000000 00000000 00000000 00000111 = 7
int a = n >> 1; // 00000000 00000000 00000000 00000011 = 3
int b = n >> 2; // 00000000 00000000 00000000 00000001 = 1
int c = n >> 3; // 00000000 00000000 00000000 00000000 = 0

如果对一个负数进行右移,最高位的1不动,结果仍然是一个负数:

1
2
3
4
5
int n = -536870912;
int a = n >> 1; // 11110000 00000000 00000000 00000000 = -268435456
int b = n >> 2; // 11111000 00000000 00000000 00000000 = -134217728
int c = n >> 28; // 11111111 11111111 11111111 11111110 = -2
int d = n >> 29; // 11111111 11111111 11111111 11111111 = -1

还有一种无符号的右移运算,使用>>>,它的特点是不管符号位,右移后高位总是补0,因此,对一个负数进行>>>右移,它会变成正数,原因是最高位的1变成了0

1
2
3
4
5
int n = -536870912;
int a = n >>> 1; // 01110000 00000000 00000000 00000000 = 1879048192
int b = n >>> 2; // 00111000 00000000 00000000 00000000 = 939524096
int c = n >>> 29; // 00000000 00000000 00000000 00000111 = 7
int d = n >>> 31; // 00000000 00000000 00000000 00000001 = 1

byteshort类型进行移位时,会首先转换为int再进行位移。

仔细观察可发现,左移实际上就是不断地×2,右移实际上就是不断地÷2。

位运算

位运算是按位进行与、或、非和异或的运算。我们先来看看针对单个bit的位运算。

与运算的规则是,必须两个数同时为1,结果才为1

1
2
3
4
n = 0 & 0; // 0
n = 0 & 1; // 0
n = 1 & 0; // 0
n = 1 & 1; // 1

或运算的规则是,只要任意一个为1,结果就为1

1
2
3
4
n = 0 | 0; // 0
n = 0 | 1; // 1
n = 1 | 0; // 1
n = 1 | 1; // 1

非运算的规则是,01互换:

1
2
n = ~0; // 1
n = ~1; // 0

异或运算的规则是,如果两个数不同,结果为1,否则为0

1
2
3
4
n = 0 ^ 0; // 0
n = 0 ^ 1; // 1
n = 1 ^ 0; // 1
n = 1 ^ 1; // 0

Java没有单个bit的数据类型。在Java中,对两个整数进行位运算,实际上就是按位对齐,然后依次对每一位进行运算。例如:

1
2
3
4
5
6
7
8
9
10
// 位运算
public class Main {
public static void main(String[] args) {
int i = 167776589; // 00001010 00000000 00010001 01001101
int n = 167776512; // 00001010 00000000 00010001 00000000
// & -----------------------------------
// 00001010 00000000 00010001 00000000
System.out.println(i & n); // 167776512
}
}

上述按位与运算实际上可以看作两个整数表示的IP地址10.0.17.7710.0.17.0,通过与运算,可以快速判断一个IP是否在给定的网段内。

运算优先级

在Java的计算表达式中,运算优先级从高到低依次是:

  • ()
  • ! ~ ++ --
  • * / %
  • + -
  • << >> >>>
  • &
  • |
  • += -= *= /=

记不住也没关系,只需要加括号就可以保证运算的优先级正确。

类型自动提升与强制转型

在运算过程中,如果参与运算的两个数类型不一致,那么计算结果为较大类型的整型。例如,shortint计算,结果总是int,原因是short首先自动被转型为int

1
2
3
4
5
6
7
8
9
// 类型自动提升与强制转型
public class Main {
public static void main(String[] args) {
short s = 1234;
int i = 123456;
int x = s + i; // s自动转型为int
short y = s + i; // 编译错误!
}
}

也可以将结果强制转型,即将大范围的整数转型为小范围的整数。强制转型使用(类型),例如,将int强制转型为short

1
2
int i = 12345;
short s = (short) i; // 12345

要注意,超出范围的强制转型会得到错误的结果,原因是转型时,int的两个高位字节直接被扔掉,仅保留了低位的两个字节:

1
2
3
4
5
6
7
8
9
10
11
// 强制转型
public class Main {
public static void main(String[] args) {
int i1 = 1234567;
short s1 = (short) i1; // -10617
System.out.println(s1);
int i2 = 12345678;
short s2 = (short) i2; // 24910
System.out.println(s2);
}
}

因此,强制转型的结果很可能是错的。

练习

计算前N个自然数的和可以根据公式:

请根据公式计算前N个自然数的和:

1
2
3
4
5
6
7
8
9
10
// 计算前N个自然数的和
public class Main {
public static void main(String[] args) {
int n = 100;
// TODO: sum = 1 + 2 + ... + n
int sum = ???;
System.out.println(sum);
System.out.println(sum == 5050 ? "测试通过" : "测试失败");
}
}

下载练习

小结

整数运算的结果永远是精确的;

运算结果会自动提升;

可以强制转型,但超出范围的强制转型会得到错误的结果;

应该选择合适范围的整型(intlong),没有必要为了节省内存而使用byteshort进行整数运算。

浮点数运算和整数运算相比,只能进行加减乘除这些数值计算,不能做位运算和移位运算。

在计算机中,浮点数虽然表示的范围大,但是,浮点数有个非常重要的特点,就是浮点数常常无法精确表示。

举个例子:

浮点数0.1在计算机中就无法精确表示,因为十进制的0.1换算成二进制是一个无限循环小数,很显然,无论使用float还是double,都只能存储一个0.1的近似值。但是,0.5这个浮点数又可以精确地表示。

因为浮点数常常无法精确表示,因此,浮点数运算会产生误差:

1
2
3
4
5
6
7
8
9
10
// 浮点数运算误差
public class Main {
public static void main(String[] args) {
double x = 1.0 / 10;
double y = 1 - 9.0 / 10;
// 观察x和y是否相等:
System.out.println(x);
System.out.println(y);
}
}

由于浮点数存在运算误差,所以比较两个浮点数是否相等常常会出现错误的结果。正确的比较方法是判断两个浮点数之差的绝对值是否小于一个很小的数:

1
2
3
4
5
6
7
8
// 比较x和y是否相等,先计算其差的绝对值:
double r = Math.abs(x - y);
// 再判断绝对值是否足够小:
if (r < 0.00001) {
// 可以认为相等
} else {
// 不相等
}

浮点数在内存的表示方法和整数比更加复杂。Java的浮点数完全遵循IEEE-754标准,这也是绝大多数计算机平台都支持的浮点数标准表示方法。

类型提升

如果参与运算的两个数其中一个是整型,那么整型可以自动提升到浮点型:

1
2
3
4
5
6
7
8
// 类型提升
public class Main {
public static void main(String[] args) {
int n = 5;
double d = 1.2 + 24.0 / n; // 6.0
System.out.println(d);
}
}

需要特别注意,在一个复杂的四则运算中,两个整数的运算不会出现自动提升的情况。例如:

1
double d = 1.2 + 24 / 5; // 结果不是 6.0 而是 5.2

计算结果为5.2,原因是编译器计算24 / 5这个子表达式时,按两个整数进行运算,结果仍为整数4

要修复这个计算结果,可以将24 / 5改为24.0 / 5。由于24.0是浮点数,因此,计算除法时自动将5提升为浮点数。

溢出

整数运算在除数为0时会报错,而浮点数运算在除数为0时,不会报错,但会返回几个特殊值:

  • NaN表示Not a Number
  • Infinity表示无穷大
  • -Infinity表示负无穷大

例如:

1
2
3
double d1 = 0.0 / 0; // NaN
double d2 = 1.0 / 0; // Infinity
double d3 = -1.0 / 0; // -Infinity

这三种特殊值在实际运算中很少碰到,我们只需要了解即可。

强制转型

可以将浮点数强制转型为整数。在转型时,浮点数的小数部分会被丢掉。如果转型后超过了整型能表示的最大范围,将返回整型的最大值。例如:

1
2
3
4
5
int n1 = (int) 12.3; // 12
int n2 = (int) 12.7; // 12
int n3 = (int) -12.7; // -12
int n4 = (int) (12.7 + 0.5); // 13
int n5 = (int) 1.2e20; // 2147483647

如果要进行四舍五入,可以对浮点数加上0.5再强制转型:

1
2
3
4
5
6
7
8
// 四舍五入
public class Main {
public static void main(String[] args) {
double d = 2.6;
int n = (int) (d + 0.5);
System.out.println(n);
}
}

练习

根据一元二次方程 ax2+bx+c=0ax^2+bx+c=0 的求根公式:

计算出一元二次方程的两个解:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 一元二次方程
public class Main {
public static void main(String[] args) {
double a = 1.0;
double b = 3.0;
double c = -4.0;
// 求平方根可用 Math.sqrt():
// System.out.println(Math.sqrt(2)); ==> 1.414
// TODO:
double r1 = 0;
double r2 = 0;
System.out.println(r1);
System.out.println(r2);
System.out.println(r1 == 1 && r2 == -4 ? "测试通过" : "测试失败");
}
}

下载练习

小结

浮点数常常无法精确表示,并且浮点数的运算结果可能有误差;

比较两个浮点数通常比较它们的差的绝对值是否小于一个特定值;

整型和浮点型运算时,整型会自动提升为浮点型;

可以将浮点型强制转为整型,但超出范围后将始终返回整型的最大值。

布尔运算

对于布尔类型boolean,永远只有truefalse两个值。

布尔运算是一种关系运算,包括以下几类:

  • 比较运算符:>>=<<===!=
  • 与运算 &&
  • 或运算 ||
  • 非运算 !

下面是一些示例:

1
2
3
4
5
6
boolean isGreater = 5 > 3; // true
int age = 12;
boolean isZero = age == 0; // false
boolean isNonZero = !isZero; // true
boolean isAdult = age >= 18; // false
boolean isTeenager = age >6 && age <18; // true

关系运算符的优先级从高到低依次是:

  • !
  • >>=<<=
  • ==!=
  • &&
  • ||

短路运算

布尔运算的一个重要特点是短路运算。如果一个布尔运算的表达式能提前确定结果,则后续的计算不再执行,直接返回结果。

因为false && x的结果总是false,无论xtrue还是false,因此,与运算在确定第一个值为false后,不再继续计算,而是直接返回false

我们考察以下代码:

1
2
3
4
5
6
7
8
// 短路运算
public class Main {
public static void main(String[] args) {
boolean b = 5 < 3;
boolean result = b && (5 / 0 > 0); // 此处 5 / 0 不会报错
System.out.println(result);
}
}

如果没有短路运算,&&后面的表达式会由于除数为0而报错,但实际上该语句并未报错,原因在于与运算是短路运算符,提前计算出了结果false

如果变量b的值为true,则表达式变为true && (5 / 0 > 0)。因为无法进行短路运算,该表达式必定会由于除数为0而报错,可以自行测试。

类似的,对于||运算,只要能确定第一个值为true,后续计算也不再进行,而是直接返回true

1
boolean result = true || (5 / 0 > 0); // true

三元运算符

Java还提供一个三元运算符b ? x : y,它根据第一个布尔表达式的结果,分别返回后续两个表达式之一的计算结果。示例:

1
2
3
4
5
6
7
8
// 三元运算
public class Main {
public static void main(String[] args) {
int n = -100;
int x = n >= 0 ? n : -n;
System.out.println(x);
}
}

上述语句的意思是,判断n >= 0是否成立,如果为true,则返回n,否则返回-n。这实际上是一个求绝对值的表达式。

注意到三元运算b ? x : y会首先计算b,如果btrue,则只计算x,否则,只计算y。此外,xy的类型必须相同,因为返回值不是boolean,而是xy之一。

练习

判断指定年龄是否是小学生(6~12岁):

1
2
3
4
5
6
7
8
9
// 布尔运算
public class Main {
public static void main(String[] args) {
int age = 7;
// primary student的定义: 6~12岁
boolean isPrimaryStudent = ???;
System.out.println(isPrimaryStudent ? "Yes" : "No");
}
}

下载练习

小结

与运算和或运算是短路运算;

三元运算b ? x : y后面的类型必须相同,三元运算也是“短路运算”,只计算xy



在Java中,字符和字符串是两个不同的类型。

字符类型

字符类型char是基本数据类型,它是character的缩写。一个char保存一个Unicode字符:

1
2
char c1 = 'A';
char c2 = '中';

因为Java在内存中总是使用Unicode表示字符,所以,一个英文字符和一个中文字符都用一个char类型表示,它们都占用两个字节。要显示一个字符的Unicode编码,只需将char类型直接赋值给int类型即可:

1
2
int n1 = 'A'; // 字母“A”的Unicodde编码是65
int n2 = '中'; // 汉字“中”的Unicode编码是20013

还可以直接用转义字符\u+Unicode编码来表示一个字符:

1
2
3
// 注意是十六进制:
char c3 = '\u0041'; // 'A',因为十六进制0041 = 十进制65
char c4 = '\u4e2d'; // '中',因为十六进制4e2d = 十进制20013

字符串类型

char类型不同,字符串类型String是引用类型,我们用双引号"..."表示字符串。一个字符串可以存储0个到任意个字符:

1
2
3
4
String s = ""; // 空字符串,包含0个字符
String s1 = "A"; // 包含一个字符
String s2 = "ABC"; // 包含3个字符
String s3 = "中文 ABC"; // 包含6个字符,其中有一个空格

因为字符串使用双引号"..."表示开始和结束,那如果字符串本身恰好包含一个"字符怎么表示?例如,"abc"xyz",编译器就无法判断中间的引号究竟是字符串的一部分还是表示字符串结束。这个时候,我们需要借助转义字符\

1
String s = "abc\"xyz"; // 包含7个字符: a, b, c, ", x, y, z

因为\是转义字符,所以,两个\\表示一个\字符:

1
String s = "abc\\xyz"; // 包含7个字符: a, b, c, \, x, y, z

常见的转义字符包括:

  • \" 表示字符"
  • \' 表示字符'
  • \\ 表示字符\
  • \n 表示换行符
  • \r 表示回车符
  • \t 表示Tab
  • \u#### 表示一个Unicode编码的字符

例如:

1
String s = "ABC\n\u4e2d\u6587"; // 包含6个字符: A, B, C, 换行符, 中, 文

字符串连接

Java的编译器对字符串做了特殊照顾,可以使用+连接任意字符串和其他数据类型,这样极大地方便了字符串的处理。例如:

1
2
3
4
5
6
7
8
9
// 字符串连接
public class Main {
public static void main(String[] args) {
String s1 = "Hello";
String s2 = "world";
String s = s1 + " " + s2 + "!";
System.out.println(s); // Hello world!
}
}

如果用+连接字符串和其他数据类型,会将其他数据类型先自动转型为字符串,再连接:

1
2
3
4
5
6
7
8
// 字符串连接
public class Main {
public static void main(String[] args) {
int age = 25;
String s = "age is " + age;
System.out.println(s); // age is 25
}
}

多行字符串

如果我们要表示多行字符串,使用+号连接会非常不方便:

1
2
3
String s = "first line \n"
+ "second line \n"
+ "end";

从Java 13开始,字符串可以用"""..."""表示多行字符串(Text Blocks)了。举个例子:

1
2
3
4
5
6
7
8
9
10
11
12
// 多行字符串
public class Main {
public static void main(String[] args) {
String s = """
SELECT * FROM
users
WHERE id > 100
ORDER BY name DESC
""";
System.out.println(s);
}
}

上述多行字符串实际上是5行,在最后一个DESC后面还有一个\n。如果我们不想在字符串末尾加一个\n,就需要这么写:

1
2
3
4
5
String s = """ 
SELECT * FROM
users
WHERE id > 100
ORDER BY name DESC""";

还需要注意到,多行字符串前面共同的空格会被去掉,即:

1
2
3
4
5
6
String s = """
...........SELECT * FROM
........... users
...........WHERE id > 100
...........ORDER BY name DESC
...........""";

.标注的空格都会被去掉。

如果多行字符串的排版不规则,那么,去掉的空格就会变成这样:

1
2
3
4
5
6
String s = """
......... SELECT * FROM
......... users
.........WHERE id > 100
......... ORDER BY name DESC
......... """;

即总是以最短的行首空格为基准。

不可变特性

Java的字符串除了是一个引用类型外,还有个重要特点,就是字符串不可变。考察以下代码:

1
2
3
4
5
6
7
8
9
// 字符串不可变
public class Main {
public static void main(String[] args) {
String s = "hello";
System.out.println(s); // 显示 hello
s = "world";
System.out.println(s); // 显示 world
}
}

观察执行结果,难道字符串s变了吗?其实变的不是字符串,而是变量s的“指向”。

执行String s = "hello";时,JVM虚拟机先创建字符串"hello",然后,把字符串变量s指向它:

1
2
3
4
5
6
      s


┌───┬───────────┬───┐
│ │ "hello" │ │
└───┴───────────┴───┘

紧接着,执行s = "world";时,JVM虚拟机先创建字符串"world",然后,把字符串变量s指向它:

1
2
3
4
5
6
      s ──────────────┐


┌───┬───────────┬───┬───────────┬───┐
│ │ "hello" │ │ "world" │ │
└───┴───────────┴───┴───────────┴───┘

原来的字符串"hello"还在,只是我们无法通过变量s访问它而已。因此,字符串的不可变是指字符串内容不可变。至于变量,可以一会指向字符串"hello",一会指向字符串"world"

理解了引用类型的“指向”后,试解释下面的代码输出:

1
2
3
4
5
6
7
8
9
// 字符串不可变
public class Main {
public static void main(String[] args) {
String s = "hello";
String t = s;
s = "world";
System.out.println(t); // t是"hello"还是"world"?
}
}

空值null

引用类型的变量可以指向一个空值null,它表示不存在,即该变量不指向任何对象。例如:

1
2
3
String s1 = null; // s1是null
String s2 = s1; // s2也是null
String s3 = ""; // s3指向空字符串,不是null

注意要区分空值null和空字符串"",空字符串是一个有效的字符串对象,它不等于null

练习

请将一组int值视为字符的Unicode编码,然后将它们拼成一个字符串:

1
2
3
4
5
6
7
8
9
10
11
public class Main {
public static void main(String[] args) {
// 请将下面一组int值视为字符的Unicode码,把它们拼成一个字符串:
int a = 72;
int b = 105;
int c = 65281;
// FIXME:
String s = a + b + c;
System.out.println(s);
}
}

下载练习

小结

Java的字符类型char是基本类型,字符串类型String是引用类型;

基本类型的变量是“持有”某个数值,引用类型的变量是“指向”某个对象;

引用类型的变量可以是空值null

要区分空值null和空字符串""

如果我们有一组类型相同的变量,例如,5位同学的成绩,可以这么写:

1
2
3
4
5
6
7
8
9
10
public class Main {
public static void main(String[] args) {
// 5位同学的成绩:
int n1 = 68;
int n2 = 79;
int n3 = 91;
int n4 = 85;
int n5 = 62;
}
}

但其实没有必要定义5个int变量。可以使用数组来表示“一组”int类型。代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
// 数组
public class Main {
public static void main(String[] args) {
// 5位同学的成绩:
int[] ns = new int[5];
ns[0] = 68;
ns[1] = 79;
ns[2] = 91;
ns[3] = 85;
ns[4] = 62;
}
}

定义一个数组类型的变量,使用数组类型“类型[]”,例如,int[]。和单个基本类型变量不同,数组变量初始化必须使用new int[5]表示创建一个可容纳5个int元素的数组。

Java的数组有几个特点:

  • 数组所有元素初始化为默认值,整型都是0,浮点型是0.0,布尔型是false
  • 数组一旦创建后,大小就不可改变。

要访问数组中的某一个元素,需要使用索引。数组索引从0开始,例如,5个元素的数组,索引范围是0~4

可以修改数组中的某一个元素,使用赋值语句,例如,ns[1] = 79;

可以用数组变量.length获取数组大小:

1
2
3
4
5
6
7
8
// 数组
public class Main {
public static void main(String[] args) {
// 5位同学的成绩:
int[] ns = new int[5];
System.out.println(ns.length); // 5
}
}

数组是引用类型,在使用索引访问数组元素时,如果索引超出范围,运行时将报错:

1
2
3
4
5
6
7
8
9
// 数组
public class Main {
public static void main(String[] args) {
// 5位同学的成绩:
int[] ns = new int[5];
int n = 5;
System.out.println(ns[n]); // 索引n不能超出范围
}
}

也可以在定义数组时直接指定初始化的元素,这样就不必写出数组大小,而是由编译器自动推算数组大小。例如:

1
2
3
4
5
6
7
8
// 数组
public class Main {
public static void main(String[] args) {
// 5位同学的成绩:
int[] ns = new int[] { 68, 79, 91, 85, 62 };
System.out.println(ns.length); // 编译器自动推算数组大小为5
}
}

还可以进一步简写为:

1
int[] ns = { 68, 79, 91, 85, 62 };

注意数组是引用类型,并且数组大小不可变。我们观察下面的代码:

1
2
3
4
5
6
7
8
9
10
11
// 数组
public class Main {
public static void main(String[] args) {
// 5位同学的成绩:
int[] ns;
ns = new int[] { 68, 79, 91, 85, 62 };
System.out.println(ns.length); // 5
ns = new int[] { 1, 2, 3 };
System.out.println(ns.length); // 3
}
}

数组大小变了吗?看上去好像是变了,但其实根本没变。

对于数组ns来说,执行ns = new int[] { 68, 79, 91, 85, 62 };时,它指向一个5个元素的数组:

1
2
3
4
5
6
     ns


┌───┬───┬───┬───┬───┬───┬───┐
│ │68 │79 │91 │85 │62 │ │
└───┴───┴───┴───┴───┴───┴───┘

执行ns = new int[] { 1, 2, 3 };时,它指向一个新的3个元素的数组:

1
2
3
4
5
6
     ns ──────────────────────┐


┌───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┐
│ │68 │79 │91 │85 │62 │ │ 1 │ 2 │ 3 │ │
└───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┘

但是,原有的5个元素的数组并没有改变,只是无法通过变量ns引用到它们而已。

字符串数组

如果数组元素不是基本类型,而是一个引用类型,那么,修改数组元素会有哪些不同?

字符串是引用类型,因此我们先定义一个字符串数组:

1
2
3
String[] names = {
"ABC", "XYZ", "zoo"
};

对于String[]类型的数组变量names,它实际上包含3个元素,但每个元素都指向某个字符串对象:

1
2
3
4
5
6
7
8
9
          ┌─────────────────────────┐
names │ ┌─────────────────────┼───────────┐
│ │ │ │ │
▼ │ │ ▼ ▼
┌───┬───┬─┴─┬─┴─┬───┬───────┬───┬───────┬───┬───────┬───┐
│ │░░░│░░░│░░░│ │ "ABC" │ │ "XYZ" │ │ "zoo" │ │
└───┴─┬─┴───┴───┴───┴───────┴───┴───────┴───┴───────┴───┘
│ ▲
└─────────────────┘

names[1]进行赋值,例如names[1] = "cat";,效果如下:

1
2
3
4
5
6
7
8
9
          ┌─────────────────────────────────────────────────┐
names │ ┌─────────────────────────────────┐ │
│ │ │ │ │
▼ │ │ ▼ ▼
┌───┬───┬─┴─┬─┴─┬───┬───────┬───┬───────┬───┬───────┬───┬───────┬───┐
│ │░░░│░░░│░░░│ │ "ABC" │ │ "XYZ" │ │ "zoo" │ │ "cat" │ │
└───┴─┬─┴───┴───┴───┴───────┴───┴───────┴───┴───────┴───┴───────┴───┘
│ ▲
└─────────────────┘

这里注意到原来names[1]指向的字符串"XYZ"并没有改变,仅仅是将names[1]的引用从指向"XYZ"改成了指向"cat",其结果是字符串"XYZ"再也无法通过names[1]访问到了。

对“指向”有了更深入的理解后,试解释如下代码:

1
2
3
4
5
6
7
8
9
// 数组
public class Main {
public static void main(String[] args) {
String[] names = {"ABC", "XYZ", "zoo"};
String s = names[1];
names[1] = "cat";
System.out.println(s); // s是"XYZ"还是"cat"?
}
}

小结

数组是同一数据类型的集合,数组一旦创建后,大小就不可变;

可以通过索引访问数组元素,但索引超出范围将报错;

数组元素可以是值类型(如int)或引用类型(如String),但数组本身是引用类型;

输出

在前面的代码中,我们总是使用System.out.println()来向屏幕输出一些内容。

println是print line的缩写,表示输出并换行。因此,如果输出后不想换行,可以用print()

1
2
3
4
5
6
7
8
9
10
// 输出
public class Main {
public static void main(String[] args) {
System.out.print("A,");
System.out.print("B,");
System.out.print("C.");
System.out.println();
System.out.println("END");
}
}

注意观察上述代码的执行效果。

格式化输出

Java还提供了格式化输出的功能。为什么要格式化输出?因为计算机表示的数据不一定适合人来阅读:

1
2
3
4
5
6
7
// 格式化输出
public class Main {
public static void main(String[] args) {
double d = 12900000;
System.out.println(d); // 1.29E7
}
}

如果要把数据显示成我们期望的格式,就需要使用格式化输出的功能。格式化输出使用System.out.printf(),通过使用占位符%?printf()可以把后面的参数格式化成指定格式:

1
2
3
4
5
6
7
8
// 格式化输出
public class Main {
public static void main(String[] args) {
double d = 3.1415926;
System.out.printf("%.2f\n", d); // 显示两位小数3.14
System.out.printf("%.4f\n", d); // 显示4位小数3.1416
}
}

Java的格式化功能提供了多种占位符,可以把各种数据类型“格式化”成指定的字符串:

占位符 说明
%d 格式化输出整数
%x 格式化输出十六进制整数
%f 格式化输出浮点数
%e 格式化输出科学计数法表示的浮点数
%s 格式化字符串

注意,由于%表示占位符,因此,连续两个%%表示一个%字符本身。

占位符本身还可以有更详细的格式化参数。下面的例子把一个整数格式化成十六进制,并用0补足8位:

1
2
3
4
5
6
7
// 格式化输出
public class Main {
public static void main(String[] args) {
int n = 12345000;
System.out.printf("n=%d, hex=%08x", n, n); // 注意,两个%占位符必须传入两个数
}
}

详细的格式化参数请参考JDK文档java.util.Formatter

输入

和输出相比,Java的输入就要复杂得多。

我们先看一个从控制台读取一个字符串和一个整数的例子:

1
2
3
4
5
6
7
8
9
10
11
12
import java.util.Scanner;

public class Main {
public static void main(String[] args) {
Scanner scanner = new Scanner(System.in); // 创建Scanner对象
System.out.print("Input your name: "); // 打印提示
String name = scanner.nextLine(); // 读取一行输入并获取字符串
System.out.print("Input your age: "); // 打印提示
int age = scanner.nextInt(); // 读取一行输入并获取整数
System.out.printf("Hi, %s, you are %d\n", name, age); // 格式化输出
}
}

首先,我们通过import语句导入java.util.Scannerimport是导入某个类的语句,必须放到Java源代码的开头,后面我们在Java的package中会详细讲解如何使用import

然后,创建Scanner对象并传入System.inSystem.out代表标准输出流,而System.in代表标准输入流。直接使用System.in读取用户输入虽然是可以的,但需要更复杂的代码,而通过Scanner就可以简化后续的代码。

有了Scanner对象后,要读取用户输入的字符串,使用scanner.nextLine(),要读取用户输入的整数,使用scanner.nextInt()Scanner会自动转换数据类型,因此不必手动转换。

要测试输入,必须从命令行读取用户输入,因此,需要走编译、执行的流程:

1
$ javac Main.java

这个程序编译时如果有警告,可以暂时忽略它,在后面学习IO的时候再详细解释。编译成功后,执行:

1
2
3
4
$ java Main
Input your name: Bob ◀── 输入 Bob
Input your age: 12 ◀── 输入 12
Hi, Bob, you are 12 ◀── 输出

根据提示分别输入一个字符串和整数后,我们得到了格式化的输出。

练习

请帮小明同学设计一个程序,输入上次考试成绩(int)和本次考试成绩(int),然后输出成绩提高的百分比,保留两位小数位(例如,21.75%)。

下载练习

小结

Java提供的输出包括:System.out.println() / print() / printf(),其中printf()可以格式化输出;

Java提供Scanner对象来方便输入,读取对应的类型可以使用:scanner.nextLine() / nextInt() / nextDouble() / …

在Java程序中,如果要根据条件来决定是否执行某一段代码,就需要if语句。

if语句的基本语法是:

1
2
3
if (条件) {
// 条件满足时执行
}

根据if的计算结果(true还是false),JVM决定是否执行if语句块(即花括号{}包含的所有语句)。

让我们来看一个例子:

1
2
3
4
5
6
7
8
9
10
// 条件判断
public class Main {
public static void main(String[] args) {
int n = 70;
if (n >= 60) {
System.out.println("及格了");
}
System.out.println("END");
}
}

当条件n >= 60计算结果为true时,if语句块被执行,将打印"及格了",否则,if语句块将被跳过。修改n的值可以看到执行效果。

注意到if语句包含的块可以包含多条语句:

1
2
3
4
5
6
7
8
9
10
11
// 条件判断
public class Main {
public static void main(String[] args) {
int n = 70;
if (n >= 60) {
System.out.println("及格了");
System.out.println("恭喜你");
}
System.out.println("END");
}
}

if语句块只有一行语句时,可以省略花括号{}:

1
2
3
4
5
6
7
8
9
// 条件判断
public class Main {
public static void main(String[] args) {
int n = 70;
if (n >= 60)
System.out.println("及格了");
System.out.println("END");
}
}

但是,省略花括号并不总是一个好主意。假设某个时候,突然想给if语句块增加一条语句时:

1
2
3
4
5
6
7
8
9
10
// 条件判断
public class Main {
public static void main(String[] args) {
int n = 50;
if (n >= 60)
System.out.println("及格了");
System.out.println("恭喜你"); // 注意这条语句不是if语句块的一部分
System.out.println("END");
}
}

由于使用缩进格式,很容易把两行语句都看成if语句的执行块,但实际上只有第一行语句是if的执行块。在使用git这些版本控制系统自动合并时更容易出问题,所以不推荐忽略花括号的写法。

else

if语句还可以编写一个else { ... },当条件判断为false时,将执行else的语句块:

1
2
3
4
5
6
7
8
9
10
11
12
// 条件判断
public class Main {
public static void main(String[] args) {
int n = 70;
if (n >= 60) {
System.out.println("及格了");
} else {
System.out.println("挂科了");
}
System.out.println("END");
}
}

修改上述代码n的值,观察if条件为truefalse时,程序执行的语句块。

注意,else不是必须的。

还可以用多个if ... else if ...串联。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 条件判断
public class Main {
public static void main(String[] args) {
int n = 70;
if (n >= 90) {
System.out.println("优秀");
} else if (n >= 60) {
System.out.println("及格了");
} else {
System.out.println("挂科了");
}
System.out.println("END");
}
}

串联的效果其实相当于:

1
2
3
4
5
6
7
8
9
10
11
12
13
if (n >= 90) {
// n >= 90为true:
System.out.println("优秀");
} else {
// n >= 90为false:
if (n >= 60) {
// n >= 60为true:
System.out.println("及格了");
} else {
// n >= 60为false:
System.out.println("挂科了");
}
}

在串联使用多个if时,要特别注意判断顺序。观察下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 条件判断
public class Main {
public static void main(String[] args) {
int n = 100;
if (n >= 60) {
System.out.println("及格了");
} else if (n >= 90) {
System.out.println("优秀");
} else {
System.out.println("挂科了");
}
}
}

执行发现,n = 100时,满足条件n >= 90,但输出的不是"优秀",而是"及格了",原因是if语句从上到下执行时,先判断n >= 60成功后,后续else不再执行,因此,if (n >= 90)没有机会执行了。

正确的方式是按照判断范围从大到小依次判断:

1
2
3
4
5
6
7
8
// 从大到小依次判断:
if (n >= 90) {
// ...
} else if (n >= 60) {
// ...
} else {
// ...
}

或者改写成从小到大依次判断:

1
2
3
4
5
6
7
8
// 从小到大依次判断:
if (n < 60) {
// ...
} else if (n < 90) {
// ...
} else {
// ...
}

使用if时,还要特别注意边界条件。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 条件判断
public class Main {
public static void main(String[] args) {
int n = 90;
if (n > 90) {
System.out.println("优秀");
} else if (n >= 60) {
System.out.println("及格了");
} else {
System.out.println("挂科了");
}
}
}

假设我们期望90分或更高为“优秀”,上述代码输出的却是“及格”,原因是>>=效果是不同的。

前面讲过了浮点数在计算机中常常无法精确表示,并且计算可能出现误差,因此,判断浮点数相等用==判断不靠谱:

1
2
3
4
5
6
7
8
9
10
11
// 条件判断
public class Main {
public static void main(String[] args) {
double x = 1 - 9.0 / 10;
if (x == 0.1) {
System.out.println("x is 0.1");
} else {
System.out.println("x is NOT 0.1");
}
}
}

正确的方法是利用差值小于某个临界值来判断:

1
2
3
4
5
6
7
8
9
10
11
// 条件判断
public class Main {
public static void main(String[] args) {
double x = 1 - 9.0 / 10;
if (Math.abs(x - 0.1) < 0.00001) {
System.out.println("x is 0.1");
} else {
System.out.println("x is NOT 0.1");
}
}
}

判断引用类型相等

在Java中,判断值类型的变量是否相等,可以使用==运算符。但是,判断引用类型的变量是否相等,==表示“引用是否相等”,或者说,是否指向同一个对象。例如,下面的两个String类型,它们的内容是相同的,但是,分别指向不同的对象,用==判断,结果为false

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 条件判断
public class Main {
public static void main(String[] args) {
String s1 = "hello";
String s2 = "HELLO".toLowerCase();
System.out.println(s1);
System.out.println(s2);
if (s1 == s2) {
System.out.println("s1 == s2");
} else {
System.out.println("s1 != s2");
}
}
}

要判断引用类型的变量内容是否相等,必须使用equals()方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 条件判断
public class Main {
public static void main(String[] args) {
String s1 = "hello";
String s2 = "HELLO".toLowerCase();
System.out.println(s1);
System.out.println(s2);
if (s1.equals(s2)) {
System.out.println("s1 equals s2");
} else {
System.out.println("s1 not equals s2");
}
}
}

注意:执行语句s1.equals(s2)时,如果变量s1null,会报NullPointerException

1
2
3
4
5
6
7
8
9
// 条件判断
public class Main {
public static void main(String[] args) {
String s1 = null;
if (s1.equals("hello")) {
System.out.println("hello");
}
}
}

要避免NullPointerException错误,可以利用短路运算符&&

1
2
3
4
5
6
7
8
9
// 条件判断
public class Main {
public static void main(String[] args) {
String s1 = null;
if (s1 != null && s1.equals("hello")) {
System.out.println("hello");
}
}
}

还可以把一定不是null的对象"hello"放到前面:例如:if ("hello".equals(s)) { ... }

练习

请用if ... else编写一个程序,用于计算体质指数BMI,并打印结果。

1
BMI = 体重(kg) / 身高(m)的平方

BMI结果:

  • 过轻:低于18.5
  • 正常:18.5 ~ 25
  • 过重:25 ~ 28
  • 肥胖:28 ~ 32
  • 非常肥胖:高于32

下载练习

小结

if ... else可以做条件判断,else是可选的;

不推荐省略花括号{}

多个if ... else串联要特别注意判断顺序;

要注意if的边界条件;

要注意浮点数判断相等不能直接用==运算符;

引用类型判断内容相等要使用equals(),注意避免NullPointerException

除了if语句外,还有一种条件判断,是根据某个表达式的结果,分别去执行不同的分支。

例如,在游戏中,让用户选择选项:

  1. 单人模式
  2. 多人模式
  3. 退出游戏

这时,switch语句就派上用场了。

switch语句根据switch (表达式)计算的结果,跳转到匹配的case结果,然后继续执行后续语句,直到遇到break结束执行。

我们看一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// switch
public class Main {
public static void main(String[] args) {
int option = 1;
switch (option) {
case 1:
System.out.println("Selected 1");
break;
case 2:
System.out.println("Selected 2");
break;
case 3:
System.out.println("Selected 3");
break;
}
}
}

修改option的值分别为123,观察执行结果。

如果option的值没有匹配到任何case,例如option = 99,那么,switch语句不会执行任何语句。这时,可以给switch语句加一个default,当没有匹配到任何case时,执行default

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// switch
public class Main {
public static void main(String[] args) {
int option = 99;
switch (option) {
case 1:
System.out.println("Selected 1");
break;
case 2:
System.out.println("Selected 2");
break;
case 3:
System.out.println("Selected 3");
break;
default:
System.out.println("Selected other");
break;
}
}
}

如果把switch语句翻译成if语句,那么上述的代码相当于:

1
2
3
4
5
6
7
8
9
if (option == 1) {
System.out.println("Selected 1");
} else if (option == 2) {
System.out.println("Selected 2");
} else if (option == 3) {
System.out.println("Selected 3");
} else {
System.out.println("Selected other");
}

对比if ... else if语句,对于多个==判断的情况,使用switch结构更加清晰。

同时注意,上述“翻译”只有在switch语句中对每个case正确编写了break语句才能对应得上。

使用switch时,注意case语句并没有花括号{},而且,case语句具有“穿透性”,漏写break将导致意想不到的结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// switch
public class Main {
public static void main(String[] args) {
int option = 2;
switch (option) {
case 1:
System.out.println("Selected 1");
case 2:
System.out.println("Selected 2");
case 3:
System.out.println("Selected 3");
default:
System.out.println("Selected other");
}
}
}

option = 2时,将依次输出"Selected 2""Selected 3""Selected other",原因是从匹配到case 2开始,后续语句将全部执行,直到遇到break语句。因此,任何时候都不要忘记写break

如果有几个case语句执行的是同一组语句块,可以这么写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// switch
public class Main {
public static void main(String[] args) {
int option = 2;
switch (option) {
case 1:
System.out.println("Selected 1");
break;
case 2:
case 3:
System.out.println("Selected 2, 3");
break;
default:
System.out.println("Selected other");
break;
}
}
}

使用switch语句时,只要保证有breakcase的顺序不影响程序逻辑:

1
2
3
4
5
6
7
8
9
10
11
switch (option) {
case 3:
...
break;
case 2:
...
break;
case 1:
...
break;
}

但是仍然建议按照自然顺序排列,便于阅读。

switch语句还可以匹配字符串。字符串匹配时,是比较“内容相等”。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// switch
public class Main {
public static void main(String[] args) {
String fruit = "apple";
switch (fruit) {
case "apple":
System.out.println("Selected apple");
break;
case "pear":
System.out.println("Selected pear");
break;
case "mango":
System.out.println("Selected mango");
break;
default:
System.out.println("No fruit selected");
break;
}
}
}

switch语句还可以使用枚举类型,枚举类型我们在后面讲解。

编译检查

使用IDE时,可以自动检查是否漏写了break语句和default语句,方法是打开IDE的编译检查。

在Eclipse中,选择Preferences - Java - Compiler - Errors/Warnings - Potential programming problems,将以下检查标记为Warning:

  • ‘switch’ is missing ‘default’ case:缺少default语句时警告;
  • ‘switch’ case fall-through:某个case缺少break时警告。

在Idea中,选择Preferences - Editor - Inspections - Java - Control flow issues,将以下检查标记为Warning:

  • ‘switch’ statement without ‘default’ branch:缺少default语句时警告;
  • Fallthrough in ‘switch’ statement:某个case缺少break时警告。

switch语句存在问题时,即可在IDE中获得警告提示。

switch-note

switch表达式

使用switch时,如果遗漏了break,就会造成严重的逻辑错误,而且不易在源代码中发现错误。从Java 12开始,switch语句升级为更简洁的表达式语法,使用类似模式匹配(Pattern Matching)的方法,保证只有一种路径会被执行,并且不需要break语句:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// switch
public class Main {
public static void main(String[] args) {
String fruit = "apple";
switch (fruit) {
case "apple" -> System.out.println("Selected apple");
case "pear" -> System.out.println("Selected pear");
case "mango" -> {
System.out.println("Selected mango");
System.out.println("Good choice!");
}
default -> System.out.println("No fruit selected");
}
}
}

注意新语法使用->,如果有多条语句,需要用{}括起来。不要写break语句,因为新语法只会执行匹配的语句,没有穿透效应。

很多时候,我们还可能用switch语句给某个变量赋值。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
int opt;
switch (fruit) {
case "apple":
opt = 1;
break;
case "pear":
case "mango":
opt = 2;
break;
default:
opt = 0;
break;
}

使用新的switch语法,不但不需要break,还可以直接返回值。把上面的代码改写如下:

1
2
3
4
5
6
7
8
9
10
11
12
// switch
public class Main {
public static void main(String[] args) {
String fruit = "apple";
int opt = switch (fruit) {
case "apple" -> 1;
case "pear", "mango" -> 2;
default -> 0;
}; // 注意赋值语句要以;结束
System.out.println("opt = " + opt);
}
}

这样可以获得更简洁的代码。

yield

大多数时候,在switch表达式内部,我们会返回简单的值。

但是,如果需要复杂的语句,我们也可以写很多语句,放到{...}里,然后,用yield返回一个值作为switch语句的返回值:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// yield
public class Main {
public static void main(String[] args) {
String fruit = "orange";
int opt = switch (fruit) {
case "apple" -> 1;
case "pear", "mango" -> 2;
default -> {
int code = fruit.hashCode();
yield code; // switch语句返回值
}
};
System.out.println("opt = " + opt);
}
}

练习

使用switch实现一个简单的石头、剪子、布游戏。

下载练习

小结

switch语句可以做多重选择,然后执行匹配的case语句后续代码;

switch的计算结果必须是整型、字符串或枚举类型;

注意千万不要漏写break,建议打开fall-through警告;

总是写上default,建议打开missing default警告;

从Java 14开始,switch语句正式升级为表达式,不再需要break,并且允许使用yield返回值。

while循环

循环语句就是让计算机根据条件做循环计算,在条件满足时继续循环,条件不满足时退出循环。

例如,计算从1到100的和:

1
1 + 2 + 3 + 4 + … + 100 = ?

除了用数列公式外,完全可以让计算机做100次循环累加。因为计算机的特点是计算速度非常快,我们让计算机循环一亿次也用不到1秒,所以很多计算的任务,人去算是算不了的,但是计算机算,使用循环这种简单粗暴的方法就可以快速得到结果。

我们先看Java提供的while条件循环。它的基本用法是:

1
2
3
4
while (条件表达式) {
循环语句
}
// 继续执行后续代码

while循环在每次循环开始前,首先判断条件是否成立。如果计算结果为true,就把循环体内的语句执行一遍,如果计算结果为false,那就直接跳到while循环的末尾,继续往下执行。

我们用while循环来累加1到100,可以这么写:

1
2
3
4
5
6
7
8
9
10
11
12
// while
public class Main {
public static void main(String[] args) {
int sum = 0; // 累加的和,初始化为0
int n = 1;
while (n <= 100) { // 循环条件是n <= 100
sum = sum + n; // 把n累加到sum中
n ++; // n自身加1
}
System.out.println(sum); // 5050
}
}

注意到while循环是先判断循环条件,再循环,因此,有可能一次循环都不做。

对于循环条件判断,以及自增变量的处理,要特别注意边界条件。思考一下下面的代码为何没有获得正确结果:

1
2
3
4
5
6
7
8
9
10
11
12
// while
public class Main {
public static void main(String[] args) {
int sum = 0;
int n = 0;
while (n <= 100) {
n ++;
sum = sum + n;
}
System.out.println(sum);
}
}

如果循环条件永远满足,那这个循环就变成了死循环。死循环将导致100%的CPU占用,用户会感觉电脑运行缓慢,所以要避免编写死循环代码。

如果循环条件的逻辑写得有问题,也会造成意料之外的结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
// while
public class Main {
public static void main(String[] args) {
int sum = 0;
int n = 1;
while (n > 0) {
sum = sum + n;
n ++;
}
System.out.println(n); // -2147483648
System.out.println(sum);
}
}

表面上看,上面的while循环是一个死循环,但是,Java的int类型有最大值,达到最大值后,再加1会变成负数,结果,意外退出了while循环。

练习

使用while计算从mn的和:

1
2
3
4
5
6
7
8
9
10
11
12
// while
public class Main {
public static void main(String[] args) {
int sum = 0;
int m = 20;
int n = 100;
// 使用while计算M+...+N:
while (false) {
}
System.out.println(sum);
}
}

下载练习

小结

while循环先判断循环条件是否满足,再执行循环语句;

while循环可能一次都不执行;

编写循环时要注意循环条件,并避免死循环。



在Java中,while循环是先判断循环条件,再执行循环。而另一种do while循环则是先执行循环,再判断条件,条件满足时继续循环,条件不满足时退出。它的用法是:

1
2
3
do {
执行循环语句
} while (条件表达式);

可见,do while循环会至少循环一次。

我们把对1到100的求和用do while循环改写一下:

1
2
3
4
5
6
7
8
9
10
11
12
// do-while
public class Main {
public static void main(String[] args) {
int sum = 0;
int n = 1;
do {
sum = sum + n;
n ++;
} while (n <= 100);
System.out.println(sum);
}
}

使用do while循环时,同样要注意循环条件的判断。

练习

使用do while循环计算从mn的和。

1
2
3
4
5
6
7
8
9
10
11
12
// do while
public class Main {
public static void main(String[] args) {
int sum = 0;
int m = 20;
int n = 100;
// 使用do while计算M+...+N:
do {
} while (false);
System.out.println(sum);
}
}

下载练习

小结

do while先执行循环,再判断条件;

do while循环会至少执行一次。

除了whiledo while循环,Java使用最广泛的是for循环。

for循环的功能非常强大,它使用计数器实现循环。for循环会先初始化计数器,然后,在每次循环前检测循环条件,在每次循环后更新计数器。计数器变量通常命名为i

我们把1到100求和用for循环改写一下:

1
2
3
4
5
6
7
8
9
10
// for
public class Main {
public static void main(String[] args) {
int sum = 0;
for (int i=1; i<=100; i++) {
sum = sum + i;
}
System.out.println(sum);
}
}

for循环执行前,会先执行初始化语句int i=1,它定义了计数器变量i并赋初始值为1,然后,循环前先检查循环条件i<=100,循环后自动执行i++,因此,和while循环相比,for循环把更新计数器的代码统一放到了一起。在for循环的循环体内部,不需要去更新变量i

因此,for循环的用法是:

1
2
3
for (初始条件; 循环检测条件; 循环后更新计数器) {
// 执行语句
}

如果我们要对一个整型数组的所有元素求和,可以用for循环实现:

1
2
3
4
5
6
7
8
9
10
11
12
// for
public class Main {
public static void main(String[] args) {
int[] ns = { 1, 4, 9, 16, 25 };
int sum = 0;
for (int i=0; i<ns.length; i++) {
System.out.println("i = " + i + ", ns[i] = " + ns[i]);
sum = sum + ns[i];
}
System.out.println("sum = " + sum);
}
}

上面代码的循环条件是i<ns.length。因为ns数组的长度是5,因此,当循环5次后,i的值被更新为5,就不满足循环条件,因此for循环结束。

思考

如果把循环条件改为i<=ns.length,会出现什么问题?

注意for循环的初始化计数器总是会被执行,并且for循环也可能循环0次。

使用for循环时,千万不要在循环体内修改计数器!在循环体中修改计数器常常导致莫名其妙的逻辑错误。对于下面的代码:

1
2
3
4
5
6
7
8
9
10
// for
public class Main {
public static void main(String[] args) {
int[] ns = { 1, 4, 9, 16, 25 };
for (int i=0; i<ns.length; i++) {
System.out.println(ns[i]);
i = i + 1;
}
}
}

虽然不会报错,但是,数组元素只打印了一半,原因是循环内部的i = i + 1导致了计数器变量每次循环实际上加了2(因为for循环还会自动执行i++)。因此,在for循环中,不要修改计数器的值。计数器的初始化、判断条件、每次循环后的更新条件统一放到for()语句中可以一目了然。

如果希望只访问索引号为偶数的数组元素,应该把for循环改写为:

1
2
3
4
int[] ns = { 1, 4, 9, 16, 25 };
for (int i=0; i<ns.length; i=i+2) {
System.out.println(ns[i]);
}

通过更新计数器的语句i=i+2就达到了这个效果,从而避免了在循环体内去修改变量i

使用for循环时,计数器变量i要尽量定义在for循环中:

1
2
3
4
5
6
int[] ns = { 1, 4, 9, 16, 25 };
for (int i=0; i<ns.length; i++) {
System.out.println(ns[i]);
}
// 无法访问i
int n = i; // compile error!

如果变量i定义在for循环外:

1
2
3
4
5
6
7
int[] ns = { 1, 4, 9, 16, 25 };
int i;
for (i=0; i<ns.length; i++) {
System.out.println(ns[i]);
}
// 仍然可以使用i
int n = i;

那么,退出for循环后,变量i仍然可以被访问,这就破坏了变量应该把访问范围缩到最小的原则。

灵活使用for循环

for循环还可以缺少初始化语句、循环条件和每次循环更新语句,例如:

1
2
3
4
// 不设置结束条件:
for (int i=0; ; i++) {
...
}
1
2
3
4
// 不设置结束条件和更新语句:
for (int i=0; ;) {
...
}
1
2
3
4
// 什么都不设置:
for (;;) {
...
}

通常不推荐这样写,但是,某些情况下,是可以省略for循环的某些语句的。

for each循环

for循环经常用来遍历数组,因为通过计数器可以根据索引来访问数组的每个元素:

1
2
3
4
int[] ns = { 1, 4, 9, 16, 25 };
for (int i=0; i<ns.length; i++) {
System.out.println(ns[i]);
}

但是,很多时候,我们实际上真正想要访问的是数组每个元素的值。Java还提供了另一种for each循环,它可以更简单地遍历数组:

1
2
3
4
5
6
7
8
9
// for each
public class Main {
public static void main(String[] args) {
int[] ns = { 1, 4, 9, 16, 25 };
for (int n : ns) {
System.out.println(n);
}
}
}

for循环相比,for each循环的变量n不再是计数器,而是直接对应到数组的每个元素。for each循环的写法也更简洁。但是,for each循环无法指定遍历顺序,也无法获取数组的索引。

除了数组外,for each循环能够遍历所有“可迭代”的数据类型,包括后面会介绍的ListMap等。

练习1

给定一个数组,请用for循环倒序输出每一个元素:

1
2
3
4
5
6
7
8
9
// for
public class Main {
public static void main(String[] args) {
int[] ns = { 1, 4, 9, 16, 25 };
for (int i=?; ???; ???) {
System.out.println(ns[i]);
}
}
}

练习2

利用for each循环对数组每个元素求和:

1
2
3
4
5
6
7
8
9
10
11
// for each
public class Main {
public static void main(String[] args) {
int[] ns = { 1, 4, 9, 16, 25 };
int sum = 0;
for (???) {
// TODO
}
System.out.println(sum); // 55
}
}

练习3

圆周率π可以使用公式计算:

请利用for循环计算π:

1
2
3
4
5
6
7
8
9
10
// for
public class Main {
public static void main(String[] args) {
double pi = 0;
for (???) {
// TODO
}
System.out.println(pi);
}
}

下载练习

小结

for循环通过计数器可以实现复杂循环;

for each循环可以直接遍历数组的每个元素;

最佳实践:计数器变量定义在for循环内部,循环体内部不修改计数器;

break和continue

无论是while循环还是for循环,有两个特别的语句可以使用,就是break语句和continue语句。

break

在循环过程中,可以使用break语句跳出当前循环。我们来看一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
// break
public class Main {
public static void main(String[] args) {
int sum = 0;
for (int i=1; ; i++) {
sum = sum + i;
if (i == 100) {
break;
}
}
System.out.println(sum);
}
}

使用for循环计算从1到100时,我们并没有在for()中设置循环退出的检测条件。但是,在循环内部,我们用if判断,如果i==100,就通过break退出循环。

因此,break语句通常都是配合if语句使用。要特别注意,break语句总是跳出自己所在的那一层循环。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// break
public class Main {
public static void main(String[] args) {
for (int i=1; i<=10; i++) {
System.out.println("i = " + i);
for (int j=1; j<=10; j++) {
System.out.println("j = " + j);
if (j >= i) {
break;
}
}
// break跳到这里
System.out.println("breaked");
}
}
}

上面的代码是两个for循环嵌套。因为break语句位于内层的for循环,因此,它会跳出内层for循环,但不会跳出外层for循环。

continue

break会跳出当前循环,也就是整个循环都不会执行了。而continue则是提前结束本次循环,直接继续执行下次循环。我们看一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// continue
public class Main {
public static void main(String[] args) {
int sum = 0;
for (int i=1; i<=10; i++) {
System.out.println("begin i = " + i);
if (i % 2 == 0) {
continue; // continue语句会结束本次循环
}
sum = sum + i;
System.out.println("end i = " + i);
}
System.out.println(sum); // 25
}
}

注意观察continue语句的效果。当i为奇数时,完整地执行了整个循环,因此,会打印begin i=1end i=1。在i为偶数时,continue语句会提前结束本次循环,因此,会打印begin i=2但不会打印end i=2

在多层嵌套的循环中,continue语句同样是结束本次自己所在的循环。

小结

break语句可以跳出当前循环;

break语句通常配合if,在满足条件时提前结束整个循环;

break语句总是跳出最近的一层循环;

continue语句可以提前结束本次循环;

continue语句通常配合if,在满足条件时提前结束本次循环。



我们在Java程序基础里介绍了数组这种数据类型。有了数组,我们还需要来操作它。而数组最常见的一个操作就是遍历。

通过for循环就可以遍历数组。因为数组的每个元素都可以通过索引来访问,因此,使用标准的for循环可以完成一个数组的遍历:

1
2
3
4
5
6
7
8
9
10
// 遍历数组
public class Main {
public static void main(String[] args) {
int[] ns = { 1, 4, 9, 16, 25 };
for (int i=0; i<ns.length; i++) {
int n = ns[i];
System.out.println(n);
}
}
}

为了实现for循环遍历,初始条件为i=0,因为索引总是从0开始,继续循环的条件为i<ns.length,因为当i=ns.length时,i已经超出了索引范围(索引范围是0 ~ ns.length-1),每次循环后,i++

第二种方式是使用for each循环,直接迭代数组的每个元素:

1
2
3
4
5
6
7
8
9
// 遍历数组
public class Main {
public static void main(String[] args) {
int[] ns = { 1, 4, 9, 16, 25 };
for (int n : ns) {
System.out.println(n);
}
}
}

注意:在for (int n : ns)循环中,变量n直接拿到ns数组的元素,而不是索引。

显然for each循环更加简洁。但是,for each循环无法拿到数组的索引,因此,到底用哪一种for循环,取决于我们的需要。

打印数组内容

直接打印数组变量,得到的是数组在JVM中的引用地址:

1
2
int[] ns = { 1, 1, 2, 3, 5, 8 };
System.out.println(ns); // 类似 [I@7852e922

这并没有什么意义,因为我们希望打印的数组的元素内容。因此,使用for each循环来打印它:

1
2
3
4
int[] ns = { 1, 1, 2, 3, 5, 8 };
for (int n : ns) {
System.out.print(n + ", ");
}

使用for each循环打印也很麻烦。幸好Java标准库提供了Arrays.toString(),可以快速打印数组内容:

1
2
3
4
5
6
7
8
9
// 遍历数组
import java.util.Arrays;

public class Main {
public static void main(String[] args) {
int[] ns = { 1, 1, 2, 3, 5, 8 };
System.out.println(Arrays.toString(ns));
}
}

练习

请按倒序遍历数组并打印每个元素:

1
2
3
4
5
6
7
8
9
public class Main {
public static void main(String[] args) {
int[] ns = { 1, 4, 9, 16, 25 };
// 倒序打印数组元素:
for (???) {
System.out.println(???);
}
}
}

下载练习

小结

遍历数组可以使用for循环,for循环可以访问数组索引,for each循环直接迭代每个数组元素,但无法获取索引;

使用Arrays.toString()可以快速获取数组内容。

对数组进行排序是程序中非常基本的需求。常用的排序算法有冒泡排序、插入排序和快速排序等。

我们来看一下如何使用冒泡排序算法对一个整型数组从小到大进行排序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 冒泡排序
import java.util.Arrays;

public class Main {
public static void main(String[] args) {
int[] ns = { 28, 12, 89, 73, 65, 18, 96, 50, 8, 36 };
// 排序前:
System.out.println(Arrays.toString(ns));
for (int i = 0; i < ns.length - 1; i++) {
for (int j = 0; j < ns.length - i - 1; j++) {
if (ns[j] > ns[j+1]) {
// 交换ns[j]和ns[j+1]:
int tmp = ns[j];
ns[j] = ns[j+1];
ns[j+1] = tmp;
}
}
}
// 排序后:
System.out.println(Arrays.toString(ns));
}
}

冒泡排序的特点是,每一轮循环后,最大的一个数被交换到末尾,因此,下一轮循环就可以“刨除”最后的数,每一轮循环都比上一轮循环的结束位置靠前一位。

另外,注意到交换两个变量的值必须借助一个临时变量。像这么写是错误的:

1
2
3
4
5
int x = 1;
int y = 2;

x = y; // x现在是2
y = x; // y现在还是2

正确的写法是:

1
2
3
4
5
6
int x = 1;
int y = 2;

int t = x; // 把x的值保存在临时变量t中, t现在是1
x = y; // x现在是2
y = t; // y现在是t的值1

实际上,Java的标准库已经内置了排序功能,我们只需要调用JDK提供的Arrays.sort()就可以排序:

1
2
3
4
5
6
7
8
9
10
// 排序
import java.util.Arrays;

public class Main {
public static void main(String[] args) {
int[] ns = { 28, 12, 89, 73, 65, 18, 96, 50, 8, 36 };
Arrays.sort(ns);
System.out.println(Arrays.toString(ns));
}
}

必须注意,对数组排序实际上修改了数组本身。例如,排序前的数组是:

1
int[] ns = { 9, 3, 6, 5 };

在内存中,这个整型数组表示如下:

1
2
3
      ┌───┬───┬───┬───┐
ns───▶│ 9 │ 3 │ 6 │ 5 │
└───┴───┴───┴───┘

当我们调用Arrays.sort(ns);后,这个整型数组在内存中变为:

1
2
3
      ┌───┬───┬───┬───┐
ns───▶│ 3 │ 5 │ 6 │ 9 │
└───┴───┴───┴───┘

即变量ns指向的数组内容已经被改变了。

如果对一个字符串数组进行排序,例如:

1
String[] ns = { "banana", "apple", "pear" };

排序前,这个数组在内存中表示如下:

1
2
3
4
5
6
7
8
                   ┌──────────────────────────────────┐
┌───┼──────────────────────┐ │
│ │ ▼ ▼
┌───┬─┴─┬─┴─┬───┬────────┬───┬───────┬───┬──────┬───┐
ns ─────▶│░░░│░░░│░░░│ │"banana"│ │"apple"│ │"pear"│ │
└─┬─┴───┴───┴───┴────────┴───┴───────┴───┴──────┴───┘
│ ▲
└─────────────────┘

调用Arrays.sort(ns);排序后,这个数组在内存中表示如下:

1
2
3
4
5
6
7
8
                   ┌──────────────────────────────────┐
┌───┼──────────┐ │
│ │ ▼ ▼
┌───┬─┴─┬─┴─┬───┬────────┬───┬───────┬───┬──────┬───┐
ns ─────▶│░░░│░░░│░░░│ │"banana"│ │"apple"│ │"pear"│ │
└─┬─┴───┴───┴───┴────────┴───┴───────┴───┴──────┴───┘
│ ▲
└──────────────────────────────┘

原来的3个字符串在内存中均没有任何变化,但是ns数组的每个元素指向变化了。

练习

请思考如何实现对数组进行降序排序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 降序排序
import java.util.Arrays;

public class Main {
public static void main(String[] args) {
int[] ns = { 28, 12, 89, 73, 65, 18, 96, 50, 8, 36 };
// 排序前:
System.out.println(Arrays.toString(ns));
// TODO:
// 排序后:
System.out.println(Arrays.toString(ns));
if (Arrays.toString(ns).equals("[96, 89, 73, 65, 50, 36, 28, 18, 12, 8]")) {
System.out.println("测试成功");
} else {
System.out.println("测试失败");
}
}
}

下载练习

小结

常用的排序算法有冒泡排序、插入排序和快速排序等;

冒泡排序使用两层for循环实现排序;

交换两个变量的值需要借助一个临时变量;

可以直接使用Java标准库提供的Arrays.sort()进行排序;

对数组排序会直接修改数组本身。

二维数组

二维数组就是数组的数组。定义一个二维数组如下:

1
2
3
4
5
6
7
8
9
10
11
// 二维数组
public class Main {
public static void main(String[] args) {
int[][] ns = {
{ 1, 2, 3, 4 },
{ 5, 6, 7, 8 },
{ 9, 10, 11, 12 }
};
System.out.println(ns.length); // 3
}
}

因为ns包含3个数组,因此,ns.length3。实际上ns在内存中的结构如下:

1
2
3
4
5
6
7
8
9
                    ┌───┬───┬───┬───┐
┌───┐ ┌──▶│ 1 │ 2 │ 3 │ 4 │
ns ─────▶│░░░│──┘ └───┴───┴───┴───┘
├───┤ ┌───┬───┬───┬───┐
│░░░│─────▶│ 5 │ 6 │ 7 │ 8 │
├───┤ └───┴───┴───┴───┘
│░░░│──┐ ┌───┬───┬───┬───┐
└───┘ └──▶│ 9 │10 │11 │12 │
└───┴───┴───┴───┘

如果我们定义一个普通数组arr0,然后把ns[0]赋值给它:

1
2
3
4
5
6
7
8
9
10
11
12
// 二维数组
public class Main {
public static void main(String[] args) {
int[][] ns = {
{ 1, 2, 3, 4 },
{ 5, 6, 7, 8 },
{ 9, 10, 11, 12 }
};
int[] arr0 = ns[0];
System.out.println(arr0.length); // 4
}
}

实际上arr0就获取了ns数组的第0个元素。因为ns数组的每个元素也是一个数组,因此,arr0指向的数组就是{ 1, 2, 3, 4 }。在内存中,结构如下:

1
2
3
4
5
6
7
8
9
10
11
            arr0 ─────┐

┌───┬───┬───┬───┐
┌───┐ ┌──▶│ 1 │ 2 │ 3 │ 4 │
ns ─────▶│░░░│──┘ └───┴───┴───┴───┘
├───┤ ┌───┬───┬───┬───┐
│░░░│─────▶│ 5 │ 6 │ 7 │ 8 │
├───┤ └───┴───┴───┴───┘
│░░░│──┐ ┌───┬───┬───┬───┐
└───┘ └──▶│ 9 │10 │11 │12 │
└───┴───┴───┴───┘

访问二维数组的某个元素需要使用array[row][col],例如:

1
System.out.println(ns[1][2]); // 7

二维数组的每个数组元素的长度并不要求相同,例如,可以这么定义ns数组:

1
2
3
4
5
int[][] ns = {
{ 1, 2, 3, 4 },
{ 5, 6 },
{ 7, 8, 9 }
};

这个二维数组在内存中的结构如下:

1
2
3
4
5
6
7
8
9
                    ┌───┬───┬───┬───┐
┌───┐ ┌──▶│ 1 │ 2 │ 3 │ 4 │
ns ─────▶│░░░│──┘ └───┴───┴───┴───┘
├───┤ ┌───┬───┐
│░░░│─────▶│ 5 │ 6 │
├───┤ └───┴───┘
│░░░│──┐ ┌───┬───┬───┐
└───┘ └──▶│ 7 │ 8 │ 9 │
└───┴───┴───┘

要打印一个二维数组,可以使用两层嵌套的for循环:

1
2
3
4
5
6
7
for (int[] arr : ns) {
for (int n : arr) {
System.out.print(n);
System.out.print(', ');
}
System.out.println();
}

或者使用Java标准库的Arrays.deepToString()

1
2
3
4
5
6
7
8
9
10
11
12
13
// 二维数组
import java.util.Arrays;

public class Main {
public static void main(String[] args) {
int[][] ns = {
{ 1, 2, 3, 4 },
{ 5, 6 },
{ 7, 8, 9 }
};
System.out.println(Arrays.deepToString(ns));
}
}

三维数组

三维数组就是二维数组的数组。可以这么定义一个三维数组:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
int[][][] ns = {
{
{1, 2, 3},
{4, 5, 6},
{7, 8, 9}
},
{
{10, 11},
{12, 13}
},
{
{14, 15, 16},
{17, 18}
}
};

它在内存中的结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
                              ┌───┬───┬───┐
┌───┐ ┌──▶│ 1 │ 2 │ 3 │
┌──▶│░░░│──┘ └───┴───┴───┘
│ ├───┤ ┌───┬───┬───┐
│ │░░░│─────▶│ 4 │ 5 │ 6 │
│ ├───┤ └───┴───┴───┘
│ │░░░│──┐ ┌───┬───┬───┐
┌───┐ │ └───┘ └──▶│ 7 │ 8 │ 9 │
ns ────▶│░░░│──┘ └───┴───┴───┘
├───┤ ┌───┐ ┌───┬───┐
│░░░│─────▶│░░░│─────▶│10 │11 │
├───┤ ├───┤ └───┴───┘
│░░░│──┐ │░░░│──┐ ┌───┬───┐
└───┘ │ └───┘ └──▶│12 │13 │
│ └───┴───┘
│ ┌───┐ ┌───┬───┬───┐
└──▶│░░░│─────▶│14 │15 │16 │
├───┤ └───┴───┴───┘
│░░░│──┐ ┌───┬───┐
└───┘ └──▶│17 │18 │
└───┴───┘

如果我们要访问三维数组的某个元素,例如,ns[2][0][1],只需要顺着定位找到对应的最终元素15即可。

理论上,我们可以定义任意的N维数组。但在实际应用中,除了二维数组在某些时候还能用得上,更高维度的数组很少使用。

练习

使用二维数组可以表示一组学生的各科成绩,请计算所有学生的平均分:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class Main {
public static void main(String[] args) {
// 用二维数组表示的学生成绩:
int[][] scores = {
{ 82, 90, 91 }, // 学生甲的语数英成绩
{ 68, 72, 64 }, // 学生乙的语数英成绩
{ 95, 91, 89 }, // ...
{ 67, 52, 60 },
{ 79, 81, 85 },
};
// TODO:
double average = 0;
System.out.println(average);
if (Math.abs(average - 77.733333) < 0.000001) {
System.out.println("测试成功");
} else {
System.out.println("测试失败");
}
}
}

下载练习

小结

二维数组就是数组的数组,三维数组就是二维数组的数组;

多维数组的每个数组元素长度都不要求相同;

打印多维数组可以使用Arrays.deepToString()

最常见的多维数组是二维数组,访问二维数组的一个元素使用array[row][col]

Java程序的入口是main方法,而main方法可以接受一个命令行参数,它是一个String[]数组。

这个命令行参数由JVM接收用户输入并传给main方法:

1
2
3
4
5
6
7
public class Main {
public static void main(String[] args) {
for (String arg : args) {
System.out.println(arg);
}
}
}

我们可以利用接收到的命令行参数,根据不同的参数执行不同的代码。例如,实现一个-version参数,打印程序版本号:

1
2
3
4
5
6
7
8
9
10
public class Main {
public static void main(String[] args) {
for (String arg : args) {
if ("-version".equals(arg)) {
System.out.println("v 1.0");
break;
}
}
}
}

上面这个程序必须在命令行执行,我们先编译它:

1
$ javac Main.java

然后,执行的时候,给它传递一个-version参数:

1
2
$ java Main -version
v 1.0

这样,程序就可以根据传入的命令行参数,作出不同的响应。

小结

命令行参数类型是String[]数组;

命令行参数由JVM接收用户输入并传给main方法;

如何解析命令行参数需要由程序自己实现。

留言與分享

CentOs7.3 安装 JDK1.8

分類 编程语言, Java

下载

下载Linux环境下的jdk1.8,请去(官网)中下载jdk的安装文件

上传在 /opt 目录

解压

1
2
$ cd /opt
$ tar zxvf jdk-8u144-linux-x64.tar.gz

重命名

1
2
$ cd /opt
$ mv jdk1.8.0_144/ /lib/jvm

配置环境变量

如果是对所有的用户都生效就修改vi /etc/profile 文件

如果只针对当前用户生效就修改 vi ~/.bahsrc 文件

在文件底部添加如下代码,如果上一步的路径和我的不一致要改一下

1
$ vi /etc/profile

环境变量配置内容

1
2
3
4
5
#jdk
export JAVA_HOME=/lib/jvm
export JRE_HOME=${JAVA_HOME}/jre
export CLASSPATH=.:${JAVA_HOME}/lib:${JRE_HOME}/lib
export PATH=${JAVA_HOME}/bin:$PATH

使环境变量生效

运行 source /etc/profile使/etc/profile文件生效

1
$ source /etc/profile

或者

1
$ source ~/.bashrc

验证

使用 java -version 和 javac -version 命令查看jdk版本及其相关信息,不会出现command not found错误,且显示的版本信息与前面安装的一致

1
2
3
4
[root@localhost ~]# java -version
java version "1.8.0_144"
Java(TM) SE Runtime Environment (build 1.8.0_144-b01)
Java HotSpot(TM) 64-Bit Server VM (build 25.144-b01, mixed mode)

留言與分享

  • 第 1 頁 共 1 頁
作者的圖片

Kein Chan

這是獨立全棧工程師Kein Chan的技術博客
分享一些技術教程,命令備忘(cheat-sheet)等


全棧工程師
資深技術顧問
數據科學家
Hit廣島觀光大使


Tokyo/Macau