Java程序的入口是main
方法,而main
方法可以接受一个命令行参数,它是一个String[]
数组。
这个命令行参数由JVM接收用户输入并传给main
方法:
public class Main {
public static void main(String[] args) {
for (String arg : args) {
System.out.println(arg);
}
}
}
我们可以利用接收到的命令行参数,根据不同的参数执行不同的代码。例如,实现一个-version
参数,打印程序版本号:
public class Main {
public static void main(String[] args) {
for (String arg : args) {
if ("-version".equals(arg)) {
System.out.println("v 1.0");
break;
}
}
}
}
上面这个程序必须在命令行执行,我们先编译它:
$ javac Main.java
然后,执行的时候,给它传递一个-version
参数:
$ java Main -version
v 1.0
这样,程序就可以根据传入的命令行参数,作出不同的响应。
格式://巴拉巴拉
格式:/* 巴拉巴拉 */
**注意:**注释不要嵌套。
被赋予特定含义的英语单词
1.关键字的字母全部小写。
2.代码编辑器中针对关键字有特殊的颜色标记,非常直观。
用于创建/定义一个类。类是Java最基本的组成单元。
类名要和文件名保持一致
public class HellowWorld{
//HellowWorld是类的名字
}
Javabean类:用来描述一类事物的类。比如:Student,Teacher,Dog,Cat等。
测试类:用来检查其他类是否书写正确,带有main方法的类,是程序的入口。
工具类:不是用来描述一些事物的,而是帮我们做一些事情的类。
工具类注意点
共享
static是静态的的意思,是一个修饰符,就像是一个形容词,是用来形容类,变量,方法的。
static修饰变量,这个变量就变成了静态变量,修饰方法这个方法就成了静态方法。
static关键字方便在没有创建对象的情况下来进行调用(方法/变量)
作用:
被static修饰的成员变量,叫做静态变量。使用static关键字修饰的变量可以通过 类名.变量名 直接访问
不使用static关键字访问对象的属性
使用static关键字访问对象的属性
注意:如果一个类的成员变量被static
修饰了,那么所有该类的对象都共享这个变量。无论这个类实例化多少对象,它的静态变量均相同。
在一个class
中定义的字段,我们称之为实例字段。实例字段的特点是,每个实例都有独立的字段,各个实例的同名字段互不影响。
还有一种字段,是用static
修饰的字段,称为静态字段:static field
。
实例字段在每个实例中都有自己的一个独立“空间”,但是静态字段只有一个共享“空间”,所有实例都会共享该字段。举个例子:
class Person {
public String name;
public int age;
// 定义静态字段number:
public static int number;
}
我们来看看下面的代码:
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;
}
}
对于静态字段,无论修改哪个实例的静态字段,效果都是一样的:所有实例的静态字段都被修改了,原因是静态字段并不属于实例:
┌──────────────────┐
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
本身的字段(非实例字段)。对于上面的代码,更好的写法是:
Person.number = 99;
System.out.println(Person.number);
特点:
用static
关键字修饰的方法叫做静态方法。静态方法我们已经用过,它有一个特点相信你已经很熟悉,那就是不需要创建对象就可以直接使用。==类名调用:==类名.方法名
注意:
静态方法只能直接访问静态变量和静态方法,但是不可以直接访问非静态变量,如果一定要访问的话,可以去构建一个当前类的对象,因为非静态成员变量只能通过对象去访问。
非静态方法可以访问静态变量或者静态方法,也可以访问非静态的成员变量和非静态的成员方法
静态方法中没有this关键字,因为this代表当前对象,而静态方法中是可以不用声明对象的。
输出结果:
我被调用了
上图中static{ }
就是一个静态代码块。
我们在main
方法中没有编写任何代码,可是运行的时候,程序还是会输出我被调用了
。静态代码块是不需要依赖main
方法就可以独立运行的。
关于静态代码块你只需要记住一句话:在类被加载的时候运行且只运行一次。
静态代码块中变量和方法的调用也遵守我们之前所说的规则,即只能直接调用静态的属性和方法。
因为interface
是一个纯抽象类,所以它不能定义实例字段。但是,interface
是可以有静态字段的,并且静态字段必须为final
类型:
public interface Person {
public static final int MALE = 1;
public static final int FEMALE = 2;
}
实际上,因为interface
的字段只能是public static final
类型,所以我们可以把这些修饰符都去掉,上述代码可以简写为:
public interface Person {
// 编译器会自动加上public statc final:
int MALE = 1;
int FEMALE = 2;
}
编译器会自动把该字段变为public static final
类型。
定义: 数据在程序中的书写格式
//整数
System.out.println(666);
//小数
System.out.println(3.14);
//字符串
System.out.println("王铭杰");
//字符
System.out.println('男');
//布尔类型
System.out.println(true);
System.out.println(flase);
//空类型
//null是不能直接打印的,只能以字符串的形式打印
System.out.println("null");
注意:null是不能直接打印的,只能以字符串的形式打印
在"abcd"后加四个空格
使输出的内容对其,方便阅读。
在计算机中,任意数据都是以二进制的形式来储存的。
ASCII码表
三原色小结
1.计算机中的颜色采用光学三原色。
2.分别为:红,绿,蓝。也称之为RGB
3.可以写成十进制形式。(255,255,255)
4.也可以写成十六进制形式。(FFFFFF)
变量的使用场景:当某个数据经常发生改变时,我们可以用变量来储存数据。当数据变化时,只要修改变量里面记录的值即可。
数据类型 变量名 = 数据值;
注意:参与计算。
注意:在一条语句中可以定义多个变量。
int d=100,e=200,f=300
在定义变量的时候,直接赋值。
数据类型的分类:基本数据类型
引用数据类型(数据和面向对象时学)
整数默认使用:int
浮点数默认使用:double
注意:
1.定义long类型的变量时,在数据值的后面需要加一个L作为后缀。
long a=9999999999L;
System.out.println(a);
2.定义float类型的变量时,在数据值的后面需要加一个F (f也可)作为后缀。
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
3.布尔类型
布尔类型boolean
只有true
和false
两个值,布尔类型总是关系运算的计算结果:
boolean b1 = true;
boolean b2 = false;
boolean isGreater = 5 > 3; // 计算结果为true
int age = 12;
boolean isAdult = age >= 18; // 计算结果为false
类,接口,数组,String
和char
类型不同,字符串类型String
是引用类型,我们用双引号"..."
表示字符串。一个字符串可以存储0个到任意个字符:
String s = ""; // 空字符串,包含0个字符
String s1 = "A"; // 包含一个字符
String s2 = "ABC"; // 包含3个字符
String s3 = "中文 ABC"; // 包含6个字符,其中有一个空格
因为字符串使用双引号"..."
表示开始和结束,那如果字符串本身恰好包含一个"
字符怎么表示?例如,"abc"xyz"
,编译器就无法判断中间的引号究竟是字符串的一部分还是表示字符串结束。这个时候,我们需要借助转义字符\
:
String s = "abc\"xyz"; // 包含7个字符: a, b, c, ", x, y, z
因为\
是转义字符,所以,两个\\
表示一个\
字符:
String s = "abc\\xyz"; // 包含7个字符: a, b, c, \, x, y, z
常见的转义字符包括:
\"
表示字符"
\'
表示字符'
\\
表示字符\
\n
表示换行符\r
表示回车符\t
表示Tab\u####
表示一个Unicode编码的字符例如:
String s = "ABC\n\u4e2d\u6587"; // 包含6个字符: A, B, C, 换行符, 中, 文
Java的编译器对字符串做了特殊照顾,可以使用+
连接任意字符串和其他数据类型,这样极大地方便了字符串的处理。例如:
// 字符串连接
public class Main {
public static void main(String[] args) {
String s1 = "Hello";
String s2 = "world";
String s = s1 + " " + s2 + "!";
System.out.println(s);
}
}
如果用+
连接字符串和其他数据类型,会将其他数据类型先自动转型为字符串,再连接:
// 字符串连接
public class Main {
public static void main(String[] args) {
int age = 25;
String s = "age is " + age;
System.out.println(s);
}
}
如果我们要表示多行字符串,使用+号连接会非常不方便:
String s = "first line \n"
+ "second line \n"
+ "end";
从Java 13开始,字符串可以用"""..."""
表示多行字符串(Text Blocks)了。举个例子:
// 多行字符串
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
,就需要这么写:
String s = """
SELECT * FROM
users
WHERE id > 100
ORDER BY name DESC""";
还需要注意到,多行字符串前面共同的空格会被去掉,即:
String s = """
...........SELECT * FROM
........... users
...........WHERE id > 100
...........ORDER BY name DESC
...........""";
用.
标注的空格都会被去掉。
如果多行字符串的排版不规则,那么,去掉的空格就会变成这样:
String s = """
......... SELECT * FROM
......... users
.........WHERE id > 100
......... ORDER BY name DESC
......... """;
即总是以最短的行首空格为基准。
Java的字符串除了是一个引用类型外,还有个重要特点,就是字符串不可变。考察以下代码:
// 字符串不可变
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
指向它:
s
│
▼
┌───┬───────────┬───┐
│ │ "hello" │ │
└───┴───────────┴───┘
紧接着,执行s = "world";
时,JVM虚拟机先创建字符串"world"
,然后,把字符串变量s
指向它:
s ──────────────┐
│
▼
┌───┬───────────┬───┬───────────┬───┐
│ │ "hello" │ │ "world" │ │
└───┴───────────┴───┴───────────┴───┘
原来的字符串"hello"
还在,只是我们无法通过变量s
访问它而已。因此,字符串的不可变是指字符串内容不可变。至于变量,可以一会指向字符串"hello"
,一会指向字符串"world"
。
理解了引用类型的“指向”后,试解释下面的代码输出:
// 字符串不可变
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
,它表示不存在,即该变量不指向任何对象。例如:
String s1 = null; // s1是null
String s2 = s1; // s2也是null
String s3 = ""; // s3指向空字符串,不是null
注意要区分空值null
和空字符串""
,空字符串是一个有效的字符串对象,它不等于null
。
在Java中,判断值类型的变量是否相等,可以使用==
运算符。但是,判断引用类型的变量是否相等,==
表示“引用是否相等”,或者说,是否指向同一个对象。例如,下面的两个String类型,它们的内容是相同的,但是,分别指向不同的对象,用==
判断,结果为false
:
// 条件判断
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()
方法:
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)
时,如果变量s1
为null
,会报NullPointerException
:
// 条件判断
public class Main {
public static void main(String[] args) {
String s1 = null;
if (s1.equals("hello")) {
System.out.println("hello");
}
}
}
要避免NullPointerException
错误,可以利用短路运算符&&
:
// 条件判断
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)) { ... }
。
定义:给类,方法,变量等起的名字
小驼峰命名法
规范:
1.标识符是一个单词的时候,全部小写。
tip :name
2.标识符由多个单词组成的时候,第一个单词首字母小写,第二个单词首字母大写。
tip : firstName
大驼峰命名法
规范:
1.标识符是一个单词的时候,首字母大写。
tip :Name
2.标识符由多个单词组成的时候,每个单词的首字母大写。
tip :FirstName
Java帮我们写好一个类叫Scanner,这个类就可以接收键盘输入的数字。
//导包,注意写在类定义的上面
import java.util.Scanner;
public class ScannerDemo1{
public static void main(string[] args){
//创建对象,准备用Scanner这个类
Scanner sc = new Scanner(System.in);
//用i接收键盘录入的数据
int i = sc.nextInt();
//输出i
System.out.println(i);
}
}
首先,我们通过import
语句导入java.util.Scanner
,import
是导入某个类的语句,必须放到Java源代码的开头,后面我们在Java的package
中会详细讲解如何使用import
。
然后,创建Scanner
对象并传入System.in
。System.out
代表标准输出流,而System.in
代表标准输入流。直接使用System.in
读取用户输入虽然是可以的,但需要更复杂的代码,而通过Scanner
就可以简化后续的代码。
有了Scanner
对象后,要读取用户输入的字符串,使用scanner.nextLine()
,要读取用户输入的整数,使用scanner.nextInt()
。Scanner
会自动转换数据类型,因此不必手动转换。
在前面的代码中,我们总是使用System.out.println()
来向屏幕输出一些内容。
println
是print line的缩写,表示输出并换行。因此,如果输出后不想换行,可以用print()
:
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还提供了格式化输出的功能。为什么要格式化输出?因为计算机表示的数据不一定适合人来阅读:
public class Main {
public static void main(String[] args) {
double d = 12900000;
System.out.println(d); // 1.29E7
}
}
如果要把数据显示成我们期望的格式,就需要使用格式化输出的功能。格式化输出使用System.out.printf()
,通过使用占位符%?
,printf()
可以把后面的参数格式化成指定格式:
// 格式化输出
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位:
// 格式化输出
public class Main {
public static void main(String[] args) {
int n = 12345000;
System.out.printf("n=%d, hex=%08x", n, n); // 注意,两个%占位符必须传入两个数
}
}
定义:对字面量或者变量进行操作的符号
在代码中,如果有小数参加运算,结果有可能是不精确的。
整数参加运算,结果只能得到整数。要想得到小数,必须有浮点数参与运算。
取模的应用场景:1.用取模来判断A是否能被B整除
2.判断一个数是奇数还是偶数
练习:数值拆分
数字进行运算时,数据类型不一样就不能进行运算,需要转成一样的,才能运算。
隐式转换(自动类型提升)
把一个取值范围小的数值,转成取值范围大的数据。
隐式转换的两种提升规则:
取值范围
示例:c是int类型
强制转换
如果把一个取值范围大的数值,赋值给取值范围小的变量。是不允许直接赋值的。如果一定要这么做就需要加入强制转换。
格式:目标数据类型 变量名 = (目标数据类型)被强转的数据;
示例:
数据过大进行强转会导致发生错误
可以将浮点数强制转型为整数。在转型时,浮点数的小数部分会被丢掉。如果转型后超过了整型能表示的最大范围,将返回整型的最大值。例如:
int n1 = (int) 12.3; // 12
int n2 = (int) 12.7; // 12
int n2 = (int) -12.7; // -12
int n3 = (int) (12.7 + 0.5); // 13
int n4 = (int) 1.2e20; // 2147483647
如果要进行四舍五入,可以对浮点数加上0.5再强制转型:
// 四舍五入
public class Main {
public static void main(String[] args) {
double d = 2.6;
int n = (int) (d + 0.5);
System.out.println(n);
}
}
练习:重要(打印时的操作)
当 字符 + 字符 或 字符 + 数字 时,会把字符通过ASCII码表查询到对应的数字再进行计算
(byte short char 三种类型的数据在运算的时候,都会直接先提升为int ,然后再进行运算。)
基本用法
符号 | 作用 | 说明 |
---|---|---|
++ | 加 | 变量的值加1 |
– | 减 | 变量的值减1 |
**注意事项: **
1.+ + 和 - - 无论是放在变量的前边还是后边,单独写一行结果是一样的。
2.当参与计算时
a++表示a+1前的值;++a表示a+1后的值;
**a–表示a-1前的值;–a表示a-1后的值;**
b=10;b=11;
分类:
扩展的赋值运算符隐含了强制类型转换
分类:
注意:关系运算符的结果都是boolean类型,要么是true,要么是false。千万不要把“==”误写成“=”。
练习:注意输出的boolean类型
分类:
注意:逻辑运算符的结果都是boolean类型,要么是true,要么是false。
短路逻辑运算符&&
具有短路效果
简单理解:当左边的表达式能确定最终的结果,那么右边就不会参加运行了。
格式:关系表达式?表达式1:表达式2;
实例:求两个数的较大值
//把三元运算符的结果赋值给一个变量
int max = a>b? a: b;
System.out.println(a>b? a: b);
注意:三元运算符的结果必须要被使用
三元运算符的两个结果的类型必须相同
运算规则:
练习:比较三个和尚的身高
在计算机中,整数总是以二进制的形式表示。例如,int
类型的整数7
使用4字节表示的二进制如下:
00000000 0000000 0000000 00000111
可以对整数进行移位运算。对整数7
左移1位将得到整数14
,左移两位将得到整数28
:
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进行右移,结果如下:
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
不动,结果仍然是一个负数:
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
:
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
对byte
和short
类型进行移位时,会首先转换为int
再进行位移。
仔细观察可发现,左移实际上就是不断地×2,右移实际上就是不断地÷2。
注意记住()优先于所有即可
定义:用运算符把字面量或者变量连接起来,符合java语法的式子就可以称为表达式。不同运算符连接的表达式体现的是不同类型的表达式。
顺序结构是Java程序默认的执行流程,按照代码的先后顺序,从上到下依次执行。
注意:如果对一个布尔类型的的变量进行判断,建议不要用“==”号(但是也可以),直接把变量写在小括号中即可。
boolean flag = true;
if(flag){ //等于if(flag==true){};
System.out.println("flat的值为true");
}
case穿透
如果多个case的语句体重复了,那么我们考虑利用case穿透去简化代码
if的第三种格式:一般用于对范围的判断
Switch:把有限个数据一一列举出来,让我们任选其一
注意新语法使用->
,如果有多条语句,需要用{}
括起来。不要写break
语句,因为新语法只会执行匹配的语句,没有穿透效应。
很多时候,我们还可能用switch
语句给某个变量赋值。例如:
int opt;
switch (fruit) {
case "apple":
opt = 1;
break;
case "pear":
case "mango":
opt = 2;
break;
default:
opt = 0;
break;
}
使用新的switch
语法,不但不需要break
,还可以直接返回值。把上面的代码改写如下:
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
语句的返回值:
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);
}
}
for
循环经常用来遍历数组,因为通过计数器可以根据索引来访问数组的每个元素:
int[] ns = { 1, 4, 9, 16, 25 };
for (int i=0; i
但是,很多时候,我们实际上真正想要访问的是数组每个元素的值。Java还提供了另一种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
循环能够遍历所有“可迭代”的数据类型,包括后面会介绍的List
、Map
等。
两个循环的对比
构建无限循环(死循环)
跳转控制语句
在循环的过程中,跳到其他语句上执行
continue:结束本次循环,继续下次循环。
break:结束整个循环。
Java帮我们写好一个类叫Random,这个类就可以生成一个随机数。
原型:获取0到任意数的随机数
生成任意数到任意数的随机数
数组介绍:数组指的是一种容器,可以用来储存同种数据类型的多个值。
格式1: 数据类型 【】 数据名
tip: int 【】array;
格式2: 数据类型 数组名【】
tip:int array 【】;
初始化:就是在内存中,为数组容器开辟空间,并将数据存入容器的过程。(数组一旦初始化,大小不可变)
格式:数据类型 【】数组名=new 数据类型【】{元素1,元素2,元素3…};
简化格式:数据类型 【】数组名={元素1,元素2,元素3…};
tip: int[] array = {11,22,33};
定义:初始化时只指定数组长度,由系统为数组分配默认初始值。
格式:数据类型【】数组名= new 数据类型【数组长度】
tip:int【】arr = new int【3】;
数据动态初始化和静态初始化的区别
可能遇到的问题:索引越界异常
原因:访问了不存在的索引
避免:索引的范围
格式:数组名【索引】
索引:也叫做下标,角标
索引的特点:从0开始,逐个+1增长,连续不间断
定义:将数组中所有的内容取出来,取出来之后可以(打印,求和,判断)
数组的长度
在Java中,关于数组的一个长度属性,length
调用方式:数组名.length
遍历的快捷方式
数组名.fori
使用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 (int n : ns)
循环中,变量n
直接拿到ns
数组的元素,而不是索引。
显然for each
循环更加简洁。但是,for each
循环无法拿到数组的索引,因此,到底用哪一种for
循环,取决于我们的需要。
Arrays.toString()
直接打印数组变量,得到的是数组在JVM中的引用地址:
int[] ns = { 1, 1, 2, 3, 5, 8 };
System.out.println(ns); // 类似 [I@7852e922
这并没有什么意义,因为我们希望打印的数组的元素内容。因此,使用for each
循环来打印它:
int[] ns = { 1, 1, 2, 3, 5, 8 };
for (int n : ns) {
System.out.print(n + ", ");
}
使用for each
循环打印也很麻烦。幸好Java标准库提供了Arrays.toString()
,可以快速打印数组内容:
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.打乱顺序
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));
}
}
冒泡排序的特点是,每一轮循环后,最大的一个数被交换到末尾,因此,下一轮循环就可以“刨除”最后的数,每一轮循环都比上一轮循环的结束位置靠前一位。
默认为升序排序
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));
}
}
降序排序
public static void sort(T[] a,int fromIndex, int toIndex, Comparator super T> c)
要实现减序排序,得通过包装类型数组,基本类型数组是不行滴
用java自带的函数Collections.reverseOrder()
public class text1
{
public static void main(String []args)
{
Integer[] integers=new Integer[]{2,324,4,4,6,1};
Arrays.sort(integers, Collections.reverseOrder());
for (Integer integer:integers)
{
System.out.print(integer+" ");
}
}
}
必须注意,对数组排序实际上修改了数组本身。例如,排序前的数组是:
int[] ns = { 9, 3, 6, 5 };
在内存中,这个整型数组表示如下:
┌───┬───┬───┬───┐
ns───▶│ 9 │ 3 │ 6 │ 5 │
└───┴───┴───┴───┘
当我们调用Arrays.sort(ns);
后,这个整型数组在内存中变为:
┌───┬───┬───┬───┐
ns───▶│ 3 │ 5 │ 6 │ 9 │
└───┴───┴───┴───┘
即变量ns
指向的数组内容已经被改变了。
如果对一个字符串数组进行排序,例如:
String[] ns = { "banana", "apple", "pear" };
排序前,这个数组在内存中表示如下:
┌──────────────────────────────────┐
┌───┼──────────────────────┐ │
│ │ ▼ ▼
┌───┬─┴─┬─┴─┬───┬────────┬───┬───────┬───┬──────┬───┐
ns ─────▶│░░░│░░░│░░░│ │"banana"│ │"apple"│ │"pear"│ │
└─┬─┴───┴───┴───┴────────┴───┴───────┴───┴──────┴───┘
│ ▲
└─────────────────┘
调用Arrays.sort(ns);
排序后,这个数组在内存中表示如下:
┌──────────────────────────────────┐
┌───┼──────────┐ │
│ │ ▼ ▼
┌───┬─┴─┬─┴─┬───┬────────┬───┬───────┬───┬──────┬───┐
ns ─────▶│░░░│░░░│░░░│ │"banana"│ │"apple"│ │"pear"│ │
└─┬─┴───┴───┴───┴────────┴───┴───────┴───┴──────┴───┘
│ ▲
└──────────────────────────────┘
原来的3个字符串在内存中均没有任何变化,但是ns
数组的每个元素指向变化了。
Arrays常用方法(超详解)_arrays.tostring方法-CSDN博客
1.只要是new出来的一定是在堆里面开辟了一个小空间
2.如果new了多次,那么在堆里面有多个小空间,每个
小空间中都有各自的数据
当两个数组指向同一个小空间时,其中一个数组对小空间中的值发生了改变,那么其他数组再次访问的时
候都是修改之后的结果了。
方法(method)是程序中最小的执行单元
用法:重复的代码、具有独立功能的代码可以抽取到方法中。
好处:可以提高代码的复用性
可以提高代码的可维护性
把一些代码打包在一起,用到的时候就调用
在实际开发中,我们一般把重复的代码或者具有独立功能的代码抽取到方法中,之后直接调用就可以。
方法定义:把一些代码打包在一起,该过程称为方法定义
方法调用:方法定义后不是直接运行的,需要手动调用才能执行,该过程称为方法调用。
最简单的方法定义
带参数的方法定义和调用
注意:方法调用时,参数的数量与类型必须与方法定义中小括号里面的变量一一对应,否则程序将报错。
示例:
带返回值方法的定义和调用
如果在调用处要根据方法的结果,去编写另外一段代码逻辑。
为了在调用处拿到方法产生的结果,就需要定义有返回值的方法。
带返回值方法的调用
1.定义方法的小技巧
2.方法的注意事项
4.return关键字
5.形参和实参
形参:全称形式参数,是指方法定义中的参数
实参:全称实际参数,是指方法调用中的函数。
简单记:同一个类中,方法名相同,参数不同的方法。与返回值无关。参数不同:个数不同、类型不同、顺序不同。
好处:
练习:
1.遍历数组
需求:设计一个方法用于数组遍历,要求遍历的结果是在一行上的。例如:[1,2,3,4,5]
public class Tests{
public static void main(String[] args){
int [] arr = {1,2,3,4,5};
printArr(arr);
}
public static void printArr(int [] arr){
System.out.print("[");
for(int i = 0;i<arr.length;i++){
if(i==arr.length-1){
System.out.print(arr[i]);
}
else{
System.out.print(arr[i] + ",");
}
}
System.out.print("]");
}
}
2.求数组中的最大值
需求:设计一个方法求数组的最大值,并将最大值返回
3.判断数组中数的存在情况
需求:定义一个方法判断数组中的某一个数是否存在,将结果返回给调用处。
4.拷贝数组
tip:
// main方法
public static void main(String[] args) {
guessNumberGame();
}
//方法一:guessNumberGame()
public static void guessNumberGame() {
while(true){
int choice = menu();
if(choice == 1){
game();
}else if(choice == 0){
System.out.println("白白~");
break;
}else{
System.out.println("输入错误,请重试...");
}
}
}
//方法二:game()
public static void game() {
Random random = new Random();
int toGuess = random.nextInt(100)+1;
Scanner scanner = new Scanner(System.in);
while(true){
System.out.println("请输入你猜测的数:");
int num = scanner.nextInt();
if(num>toGuess){
System.out.println("猜大了");
}else if(num<toGuess){
System.out.println("猜小了");
}else {
System.out.println("恭喜你,猜对了!");
break;
}
}
}
//方法三:menu()
public static int menu() {
System.out.println("***********************");
System.out.println(" 1、play 0、exit ");
System.out.println("***********************");
System.out.println("请输入您的选择:");
Scanner scanner = new Scanner(System.in);
int choice = scanner.nextInt();
return choice;
}
(1) main方法是程序入口,先将main方法放入栈中,最先放入,所以在栈底
(2) 在main方法中遇到guessNumberGame()语句,就会进入该方法,该方法进栈,继续执行该方法中的语句
(3) 在guessNumberGame()方法中又遇到 menu() 方法,则进入该方法,menu()方法进栈
(4) 在menu方法中有多个println方法,我们叫它们println1、println2、println3……它们都是println只是参数不同。执行println1时,它会进栈,执行完了以后,它就会被从栈中删除,这也就是所谓的入栈和出栈。
入栈:调用某个方法,就会把该方法对应的一些信息,放到栈里面;
出栈:当某个方法执行完毕,就会把该方法对应的信息从栈中删除掉。
menu方法中的其他入栈出栈就不再过多赘述。
(5) menu方法执行完了之后,它也会被从栈中删除
(6) 回到guessNumberGame方法中,接着执行碰到game方法,game方法再入栈,执行结束后出栈,以此类推到程序整个执行结束。
基本数据类型
引用数据类型(只要是new出来的都是)
除以上其他所有类型
基本数据类型(变量中储存的是真实的数据)
引用数据类型(变量中储存的是地址值)
从内存的角度去解释
基本数据类型:数据值是储存在自己的空间中
特点:赋值给其他变量,也是赋的真实的值
引用数据类型:数据值是存储在其他空间中,自己空间中存储的是地址值。
特点:赋值给其他变量,赋的是地址值。
传递基本数据类型时,传递的是真实的数据,形参的改变,不影响实际参数的值。
可以通过返回值的方式改变number的值
传递引用数据类型时,传递的是地址值,形参的改变,影响实际参数的值。
面向:拿、找
对象:能干活的东西
面向对象编程:拿东西过来做对应的事情
重点学习:学习获取已有对象并使用;学习如何自己设计对象并使用。
理解了class和instance的概念,基本上就明白了什么是面向对象编程。
class是一种对象模版,它定义了如何创建实例,因此,class本身就是一种数据类型:
而instance是对象实例,instance是根据class创建的实例,可以创建多个instance,每个instance类型相同,但各自属性可能不相同:
如何定义类
public class 类名{
1.成员变量(代表属性,一般是名词)
2.成员方法(代表行为,一般是动词)
3.构造器(后面学习)
4.代码块(后面学习)
5.内部块(后面学习)
}
public class Phone{
//属性(成员变量)
String brand;
double price;
//行为(方法)
public void call(){
}
public void playGame(){
}
}
如何得到类的对象
类名 对象名 = new 类名();
Phone p = new Phone();
如何使用对象
定义类的补充注意事项
示例:
定义一个类
得到类的对象并使用
封装的好处:
区别成员变量和局部变量
使用this.age指的是成员变量
成员变量和局部变量
成员变量:在类中,方法外的变量
局部变量:在方法中的变量
这里打印出来的age是成员变量还是局部变量呢
就近原则:谁离我近,我就用谁
打印出来的age是10。
构造方法也叫做构造器,构造函数
作用:在创建对象的时候给成员变量进行赋值的。
特点:
执行时机:
tip:
构造方法注意事项:
构造方法的定义
构造方法的重载
推荐的使用方法
类名需要见名知意
成员变量使用private修饰(保证数据安全性)
提供至少两个构造方法
成员方法
构造方法和get/set快捷键
alt + insert
alt + fn + insert
在Java中,有很多class
的定义都符合这样的规范:
private
实例字段;public
方法来读写实例字段。例如:
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 Type getXyz()
// 写方法:
public void setXyz(Type value)
那么这种class
被称为JavaBean
:
上面的字段是xyz
,那么读写方法名分别以get
和set
开头,并且后接大写字母开头的字段名Xyz
,因此两个读写方法名分别是getXyz()
和setXyz()
。
boolean
字段比较特殊,它的读方法一般命名为isXyz()
:
// 读方法:
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)。
很明显,只读属性很常见,只写属性不常见。
属性只需要定义getter
和setter
方法,不一定需要对应的字段。例如,child
只读属性定义如下:
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;
}
}
可以看出,getter
和setter
也是一种数据封装的方法。
JavaBean主要用来传递数据,即把一组数据组合成一个JavaBean便于传输。此外,JavaBean可以方便地被IDE工具分析,生成读写属性的代码,主要用在图形界面的可视化设计中。
通过IDE,可以快速生成getter
和setter
。例如,在Eclipse中,先输入以下代码:
public class Person {
private String name;
private int age;
}
然后,点击右键,在弹出的菜单中选择“Source”,“Generate Getters and Setters”,在弹出的对话框中选中需要生成getter
和setter
方法的字段,点击确定即可由IDE自动完成所有方法代码。
方法区:当运行一个类时,这个类的字节码文件就会加载到方法区进行临时存储。
创建多个对象时,class类不用重复加载到方法区中
当main方法出栈时,对象消失,堆内存中的对象的内容也消失
继承可以使得子类具有父类的和属性和方法或者重新定义、追加属性和方法等。
Java中提供一个关键字extends,用这个关键字,我们可以让一个类和另一个类建立起继承关系
public class Student extends Person {}
Student称为子类(派生类),Person称为父类(基类或超类)
使用继承的好处
什么时候用继承
当类与类之间,存在相同(共性)的内容,并满足子类是父类中的一种,就可以考虑使用继承,来优化代码时间。
Java只支持单继承,不支持多继承,但支持多层继承。
1、构造方法
父类的构造方法不能被子类继承。
但是可以通过super调用
子类中所有的构造方法默认都会访问父类中的无参构造方法。
2、成员变量
父类中的成员变量是非私有的,子类中可以直接访问,若父类中的成员变量私有了,子类是不能直接访问的。通常编码时,我们遵循封装的选择,使用private修饰成员变量,要访问父类的私有成员变量,可以在父类中提供公共的get/set方法。
3、成员方法
在父子类的继承关系当中,创建子类对象,访问成员方法的规则:
创建的对象是谁,就优先用谁,如果没有就向上找。
就近原则:谁离我进,我就用谁
先到局部变量位置找:“ziShow”。如果没有,再到成员变量位置找:“zi”。如果再没有:到父类中找:“Fu”。逐级往上
直接调用满足就近原则:谁离我近,我就用谁。
super调用,直接访问父类。
方法的重写
应用场景:当父类的方法不能满足子类现在的需求时,需要进行方法重写
书写格式:在继承体系中,子类出现了和父类中一模一样的方法声明,我们就称子类这个方法是重写的方法。
方法重写注意事项和要求
1.重写方法的名称、形参列表必须与父类中的一致。
2.子类重写父类方法时,访问权限子类必须大于等于父类
3.子类重写父类方法时,返回值类型子类必须小于等于父类
4.建议:重写的方法尽量和父类保持一致。
5.只有被添加到虚方法表中的方法才能被重写
父类中的构造方法不会被子类继承
子类中所有的构造方法默认先访问父类中的无参构造,再执行自己。
怎么调用父类构造方法
注意最下面的super(name,age)这是在调用父类带参构造方法
继承中构造方法的访问特点是什么
this:理解为一个变量,表示当前方法调用者的地址值、
super关键字的用法如下:
super
可以用来引用直接父类的实例变量。super
可以用来调用直接父类方法。super()
可以用于调用直接父类构造函数。这里画线的this是默认存在的,不用写出。
上表对 this
与 super
的差别进行了比较,从上表中不难发现,用 super 或this调用构造方法时都需要放在首行,所以super
与 this
调用构造方法的操作是不能同时出现的。
方法重载(overload)实现的是编译时的多态性(也称为前绑定),而方法重写(override)实现的是运行时的多态性(也称为后绑定)。
同类型的对象,表现出的不同形态
这个学生对象有了两种形态,一种是学生形态,另一种是人的形态。
应用场景:代码的通用(注册)
所以,多态的特性就是,运行期才能动态决定调用的子类方法。对某个类型调用某个方法,执行的实际方法可能是某个子类的覆写方法。这种不确定性的方法调用,究竟有什么作用?
我们还是来举栗子。
假设我们定义一种收入,需要给它报税,那么先定义一个Income
类:
class Income {
protected double income;
public double getTax() {
return income * 0.1; // 税率10%
}
}
对于工资收入,可以减去一个基数,那么我们可以从Income
派生出SalaryIncome
,并覆写getTax()
:
class Salary extends Income {
@Override
public double getTax() {
if (income <= 5000) {
return 0;
}
return (income - 5000) * 0.2;
}
}
如果你享受国务院特殊津贴,那么按照规定,可以全部免税:
class StateCouncilSpecialAllowance extends Income {
@Override
public double getTax() {
return 0;
}
}
现在,我们要编写一个报税的财务软件,对于一个人的所有收入进行报税,可以这么写:
public double totalTax(Income... incomes) {
double total = 0;
for (Income income: incomes) {
total = total + income.getTax();
}
return total;
}
来试一下:
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
打交道,它完全不需要知道Salary
和StateCouncilSpecialAllowance
的存在,就可以正确计算出总的税。如果我们要新增一种稿费收入,只需要从Income
派生,然后正确覆写getTax()
方法就可以。把新的类型传入totalTax()
,不需要修改任何代码。
表现形式:父类类型 对象名称 = 子类对象
多态的前提:
有继承关系
有父类引用指向子类对象
Fu f = new Zi();
有方法重写
多态的好处:
override
)方法的重写 子类从父类中继承方法,有时,子类需要修改父类中定义的方法的实现,这称做方法的重写(method overriding
)。当一个子类继承一父类,而子类中的方法与父类中的方法的名称、参数个数和类型都完全一致时,就称子类中的这个方法重写了父类中的方法。“重写”又称为“复写”、“覆盖”。
如何使用重写
class Super {
访问权限 方法返回值类型 方法1(参数1){...}
}
class Sub extends Super{
访问权限 方法返回值类型 方法1(参数1) {复写父类中的方法...}
}
**注意:**方法重写时必须遵循两个原则,否则编译器会指出程序出错。
overload
)OOP
多态性的具体变现。方法的重写和重载是Java
多态性的不同表现,重写是父类与子类之间多态性的一种表现,重载可以理解成多态的具体表现形式。
包就是文件夹。用来管理不同功能的java类,方便后期代码维护
包名的规则:公司域名反写+包的作用,需要全部英文小写,见名知意。com.itheima.domain
事实上:全类名才是一个类真正的名字。
在前面的代码中,我们把类和接口命名为Person
、Student
、Hello
等简单名字。
在现实中,如果小明写了一个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
文件:
package ming; // 申明包名ming
public class Person {
}
小军的Arrays.java
文件:
package mr.jun; // 申明包名mr.jun
public class Arrays {
}
在Java虚拟机执行的时候,JVM只看完整类名,因此,只要包名不同,类就不同。
包可以是多层结构,用.
隔开。例如:java.util
。
要特别注意:包没有父子关系。java.util和java.util.zip是不同的包,两者没有任何继承关系。
没有定义包名的class
,它使用的是默认包,非常容易引起名字冲突,因此,不推荐不写包名的做法。
我们还需要按照包结构把上面的Java文件组织起来。假设以package_sample
作为根目录,src
作为源码目录,那么所有文件结构就是:
package_sample
└─ src
├─ hong
│ └─ Person.java
│ ming
│ └─ Person.java
└─ mr
└─ jun
└─ Arrays.java
即所有Java文件对应的目录层次要和包的层次一致。
编译后的.class
文件也需要按照包结构存放。如果使用IDE,把编译后的.class
文件放到bin
目录下,那么,编译的文件结构就是:
package_sample
└─ bin
├─ hong
│ └─ Person.class
│ ming
│ └─ Person.class
└─ mr
└─ jun
└─ Arrays.class
位于同一个包的类,可以访问包作用域的字段和方法。不用public
、protected
、private
修饰的字段和方法就是包作用域。例如,Person
类定义在hello
包下面:
package hello;
public class Person {
// 包作用域:
void hello() {
System.out.println("Hello!");
}
}
Main
类也定义在hello
包下面:
package hello;
public class Main {
public static void main(String[] args) {
Person p = new Person();
p.hello(); // 可以调用,因为Main和Person在同一个包
}
}
在一个class
中,我们总会引用其他的class
。例如,小明的ming.Person
类,如果要引用小军的mr.jun.Arrays
类,他有三种写法:
第一种,直接写出完整类名,例如:
// Person.java
package ming;
public class Person {
public void run() {
mr.jun.Arrays arrays = new mr.jun.Arrays();
}
}
很显然,每次写完整类名比较痛苦。
因此,第二种写法是用import
语句,导入小军的Arrays
,然后写简单类名:
// Person.java
package ming;
// 导入完整类名:
import mr.jun.Arrays;
public class Person {
public void run() {
Arrays arrays = new Arrays();
}
}
在写import
的时候,可以使用*
,表示把这个包下面的所有class
都导入进来(但不包括子包的class
):
// Person.java
package ming;
// 导入mr.jun包的所有class:
import mr.jun.*;
public class Person {
public void run() {
Arrays arrays = new Arrays();
}
}
我们一般不推荐这种写法,因为在导入了多个包后,很难看出Arrays
类属于哪个包。
使用其他类时,需要使用全类名。
倒包(import)
使用其他类的规则
1.使用同一个包中的类时,不需要导包。
2.使用java.lang包中的类时,不需要导包。(字符串类)。
3.其他情况都需要导包。
4.如果同时使用两个包中的同名类,需要用全类名。
自动倒包
当无法识别该类时,即需要倒包时,选中写下的类,alt+回车自动倒包
最终的,不能被改变的,可修饰方法,类和变量
方法:表明该方法是最终方法,不能被重写
类:表明该类是最终类,不能被继承
变量:叫做常量,只能被赋值一次
实际开发中,常量一般作为系统的配置信息,方便维护,提高可读性。
常量的命名规范:
单个单词:全部大写
多个单词:全部大写,单词之间用下划线隔开
细节:
final修饰的变量是基本类型:那么变量存储的数据值不能发生改变。
final修饰的变量是引用类型:那么变量存储的地址值不能发生改变,对象内部的可以改变。
用来控制一个成员能够被访问的范围
可以修饰成员变量,方法,构造方法,内部类
四种作用范围由小到大(public > protected > 空着不写 >private)
public
定义为public
的class
、interface
可以被其他任何类访问。
定义为public
的field
、method
可以被其他类访问,前提是首先有访问class
的权限。
private
定义为private
的field
、method
无法被其他类访问。
private
访问权限被限定在class
的内部,而且与方法声明顺序无关。
由于Java支持嵌套类,如果一个类内部还定义了嵌套类,那么,嵌套类拥有访问private
的权限。
protected
protected
作用于继承关系。定义为protected
的字段和方法可以被子类访问,以及子类的子类:
实际开发中,一般只用private和public
1.成员变量私有
2.方法公开
特例:如果方法中的代码是抽取其他方法中共性代码,这个方法一般也私有。
局部代码块,构造代码块,静态代码块
局部代码块:在方法中出现,限定变量生命周期,及早释放,提高内存利用率
构造代码块(初始化块):在类中方法外出现,随着对象的创建而加载,创建一次对象构造代码块执行一次
静态代码块:静态代码块:随着类的加载而加载,并且只执行一次主方法类中的静态代码块:优先于主方法执行
static{}
抽象方法:将共性的行为(方法)抽取到父类之后。由于每一个子类执行的内容是不一样,
所以,在父类中不能确定具体的方法体。抽象方法,是指没有方法体的方法
抽象类:如果一个类中存在抽象方法,那么该类就必须声明为抽象类
抽象方法的定义格式:(abstract)
public abstract返回值类型方法名(参数列表);
抽象类的定义格式:
public abstract class类名
疑问:把子类中共性的内容抽取到父类之后,由于方法体不确定,需要定义为抽象。子类使用时需要重写。那么我不抽取到父类,直接在子类写不是更节约代码?
强制子类必须按照这种格式进行书写
接口:就是一种规则,是对行为的抽象
如果一个抽象类没有属性,所有方法全部都是抽象方法:就可以把该抽象类改写为接口:interface
。
接口用关键字interface来定义
public interface 接口名{}
接口不能实例化
接口和类是实现关系,通过implements关键字表示
public class 类名 implements 接口名{}
接口的子类(实现类)
注意1.接口和类的实现关系,可以单实现,也可以多实现。
public class 类名 implements 接口名1,接口名2{}
注意2.实现类还可以在继承一个类的同时实现多个接口。
public class 类名 extends 父类 implements 接口名1,接口名2{}
成员变量:
构造方法:没有
成员方法:
类和类的关系
继承关系,只能单继承,不能多继承,但是可以多层继承
类和接口的关系
实现关系,可以单实现,也可以多实现,还可以在继承一个类的同时实现多个接口
接口和接口的关系
继承关系,可以单继承,也可以多继承(extends)
细节:如果实现类实现了最下面的子接口,那么就需要重写所有的抽象方法
有方法体的方法意义:实现类就不需要立马修改了,等以后用到某个规则了,再重写就行了
默认方法
允许在接口中定义默认方法,需要使用关键字default修饰
作用:解决接口升级的问题(增加新方法)
接口中默认方法的定义格式:
接口中默认方法的注意事项:
静态方法
私有方法
为接口内部提供服务(供抽象方法和默认方法调用),不需要外类访问。
接口中私有方法的定义格式
默认方法
格式1:private 返回值类型 方法名(参数列表){}
范例1:private void show(){}
静态方法
格式2:private static 返回值类型 方法名(参数类表){}
范例2:private static void method(){}
1.接口代表规则,是行为的抽象。想要让哪个类拥有一个行为,就让这个类实现对应的接口就可以了。
2.当一个方法的参数是接口时,可以传递接口所有实现类的对象,这种方式称之为接口多态。
最近在学习java的过程中,遇到了一下代码。
代码1:
public interface Handler{
public void Hello();
}
代码2:
import Handler;
public class OtherParser{
Handler handler;
......
}
代码1说明了Handler是一个接口了,既接口不能直接实例化,必须经过实现类继承这个接口之后,实例化实现类。那为啥代码2可以直接声明Handler呢?原因是,代码2只是对Handler接口的引用(在对接口的引用时,采用的是实例化实现该接口的类,前提是你实现这个接口的类已经加上@Component注解,引用这个接口的时候才会自动注入相关的实现类),并不是实例化!
接口是永远不能被实例化的,而2中只是对接口做引用,并没有被实例化。
接口可以看成是高度抽象的抽象类,它描述的事物们所共有的方法(方法签名),也就是规定除了该接口的方法的调用参数与规则,仅仅而已,它的使用必须依赖于实现类。
例如:
public class MyHandler implements Handler{
public void Hellp(){
System.out.println("my Handler implements");
}
}
而在对接口的引用时,采用的是实例化实现该接口的类
Handler handler = new MyHander();
设计模式(Design pattern)是一套被反复使用、多数人知晓的、经过分类编目的、代码设计经验的总结。使用设计模式是为了可重用代码、让代码更容易被他人理解、保证代码可靠性、程序的重用性。
简单理解:设计模式就是各种编写程序的套路
适配器设计模式:解决接口与接口实现类之间的矛盾问题。
1.当一个接口中抽象方法过多,但是我只要使用其中一部分的
时候,就可以适配器设计模式
2.书写步骤:
编写中间类XXXAdapter,实现对应的接口
对接口中的抽象方法进行空实现(方法体空着)让真正的实现类继承中间类,并重写需要用的方法
为了避免其他类创建适配器类的对象,中间的适配器类用abstracti进行修饰
类的五大成员:属性,方法,构造方法,代码块,内部类。
写在一个类里面的类就叫做内部类
举例:在A类的内部定义B类,B类就被称为内部类
内部类的访问特点:
内部类可以直接访问外部类的成员,包括私有。
外部类要访问内部类的成员,必须创建对象。
成员内部类的代码如何书写
写在成员位置的,属于外部类的成员
成员内部类可以被一些修饰符所修饰,比如:private,默认,protected,public,static等
在成员内部类里面,JDK16之前不能定义静态变量,JDK16开始才可以定义静态变量。
如何创建成员内部类的对象
方法1:
在外部类中编写方法,对外提供内部类的对象。(private修饰)
用object接收;
方法2:
直接创建格式:外部类名.内部类名 对象名 = 外部类对象.内部类对象;
范例:car.engine s = new car().new engine();
成员内部类如何获取外部类的成员变量
outer.this. 外部类的变量
outer是外部类的名字
静态内部类只能访问外部类中的静态变量和静态方法,如果想要访问非静态的需要创建对象。
创建静态内部类对象的格式:
外部类名.内部类名 对象名 = new外部类名.内部类名();
调用非静态方法的格式:
先创建对象,用对象调用
调用静态方法的格式:
外部类名.内部类名.方法名();
匿名内部类是隐藏了名字的内部类,本质上是一个对象,是实现了该接口或继承了该抽象类的子类对象。
为什么要使用匿名内部类
在实际开发中,我们常常遇到这样的情况:一个接口/类的方法的某个实现方式在程序中只会执行一次,但为了使用它,我们需要创建它的实现类/子类去实现/重写。此时可以使用匿名内部类的方式,可以无需创建新的类,减少代码冗余。
【匿名内部类】是省略了 <实现类 / 子类>
在创建对象是,只能使用唯一一次,一般用于书写接口的实现类。
/**
* 格式:
* 接口名称 对象名 = new 接口名称(){
* // 覆盖重写所有抽象方法
* };
*/
public class AnonymityTest2 {
public static void main(String[] args) {
MyInteface my = new MyInteface(){
@Override
public void method() {
System.out.println("匿名内部类方法");
}
};
my.method();
}
}
对于"new MyInteface(){…};" 的解析:
1.new 代表对象创建的动作;
2.接口名称 【匿名内部类】要实现的接口;
3.{…} 这才是【匿名内部类】的内容,里面重写着接口的所有抽象方法。它光秃秃的,的确没名没姓的。
4.而 MyInteface my = new MyInteface(){…} 中的 my 是对象名,它是供你调用匿名类方法的对象。
【匿名对象】是省略了 <对象名称>
表示,在调用方法时,只能调用唯一一次。
public class AnonymityTest {
public static void main(String[] args) {
fun1();
}
private static void fun1() {
// 对于 Thread 来说,这就是【匿名对象】
// 对于 Runnable 来说,这就是【匿名内部类】
new Thread( new Runnable(){
@Override
public void run() {
}
}).start();
}
}
Object是所有类的父类,如果一个类没有使用extends关键词标明继承另一个类,那么这个类就默认继承object类。所以==Object类中的所有方法适用于所有类==
public class Person{}
等价于
public class Person extends Objects{}
Object类的方法
toString()方法
Object
类里面定义toString()
方法的时候返回的对象的哈希code码(对象地址字符串);toString()
方法表示出对象的属性。Object:返回对象地址字符串
public class TestToStringDemo1 {
public static void main(String[] args) {
Person p = new Person();
System.out.println(p);
}
} //输出结果:educoder.Person@7852e992
class Person extends Object {
String name = "张三";
int age = 18;
}
重写:复写了Object类中的toString()方法(快捷键alt + insert)
public class TestToStringDemo2 {
public static void main(String[] args) {
Person p = new Person();
System.out.println(p);
}
}
class Person extends Object {
String name = "张三";
int age = 18;
// 复写Object类中的toString()方法
public String toString() {
return "我是:" + this.name + ",今年:" + this.age + "岁";
}
} //输出结果:我是:张三,今年:18岁
equals()方法
Object:比较的是对象的引用是否指向同一块内存地址
public class test {
public static void main(String[] args) {
Dog dog = new Dog();
dog.name = "jack";
Dog dog1 = new Dog();
dog1.name = "jack";
System.out.println(dog);
System.out.println(dog1);
if (dog.equals(dog1)) {
System.out.println("两个对象是相同的");
} else {
System.out.println("两个对象是不相同的");
}
}
}
class Animal {
}
class Dog extends Animal {
int age = 20;
String name = "rose";
public String toString() {
return "Dog [age=" + age + ", name=" + name + "]";
}
}
/*输出结果:Dog[age = 20,name = jack]
Dog[age = 20,name = jack]
两个对象是不相同的*/
两个对象分别new了一次,开辟了两个不同的内存空间,故内存地址不同
重写:判断对象的属性值相等(快捷方式alt + insert)
public class test {
public static void main(String[] args) {
Dog dog = new Dog();
dog.name = "jack";
Dog dog1 = new Dog();
dog1.name = "jack";
System.out.println(dog);
System.out.println(dog1);
if (dog.equals(dog1)) {
System.out.println("两个对象是相同的");
} else {
System.out.println("两个对象是不相同的");
}
}
}
class Animal {
}
class Dog extends Animal {
int age = 20;
String name = "rose";
public String toString() {
return "Dog [age=" + age + ", name=" + name + "]";
}
/* getClass() 得到的是一个类对象 */
@Override
public boolean equals(Object obj) {
if (this == obj)// 两个对象的引用是否相同,如果相同,说明两个对象就是同一个
return true;
if (obj == null)// 如果比较对象为空,不需要比较,肯定不相等
return false;
if (getClass() != obj.getClass())// 比较两个对象的类型是否相同,如果不同,肯定不相同
return false;
Dog other = (Dog) obj;// 转化成相同类型后,判断属性值是否相同
if (name == null) {
if (other.name != null)
return false;
} else if (!name.equals(other.name))
return false;
return true;
}
}
Object 类的clone()方法用于克隆对象。java.lang.Cloneable接口必须由我们要创建其对象克隆的类实现。如果我们不实现Cloneable接口,clone方法将生成CloneNoteSupportedException错误。
package educoder;
public class Student implements Cloneable {
int rollno;
String name;
Student(int rollno, String name) {
this.rollno = rollno;
this.name = name;
}
public Object clone() throws CloneNotSupportedException {
return super.clone();
}
public static void main(String args[]) {
try {
Student s1 = new Student(101, "amit");
Student s2 = (Student) s1.clone();
System.out.println(s1.rollno + " " + s1.name);
System.out.println(s2.rollno + " " + s2.name);
} catch (CloneNotSupportedException c) {
}
}
}
clone()将对象的值复制到另一个对象
throw和throws
throw:代表动作,表示抛出一个异常的动作。用在方法实现中。只能用于抛出一种异常。
throws:代表一种状态,代表方法可能有异常抛出。用在方法声明中,可抛出多个异常。
在java中,string是一个引用类型,它本身是一个class。
java中可以直接表示字符串
String str = "hellow";
实际上字符串在string内部是通过一个char[]数组表示的。
java字符串不可变,这种特点是通过内部的private final char[] 字段,以及没有任何修改char[]的方法实现的。
两个字符串比较,必须使用equals方法。(不能使用==)
public class main{
public void main(Sting args){
String s1 = "hellow";
String s2 = "hell";
System.out.println(s1.equals(s2));
}
}
基本数据类型byte,short,char,int,long,float,double,boolean。他们之间的比较,应用双等号(==),比较的是他们的值。
引用数据类型:当他们用(==)进行比较的时候,比较的是他们在内存中的存放地址(确切的说,是堆内存地址)。在这点和object提供的equals是一样的。
注:对于第二种类型,除非是同一个new出来的对象,他们的比较后的结果为true,否则比较后结果为false。因为每new一次,都会重新开辟堆内存空间。
忽略大小写比较,使用equalsIgnoreCase()方法方法。
是否包含子串
boolean contains(CharSequence s)
//是否包含子串
"hellow".contains("w"); //true
contains()方法的参数是CharSequence(字符序列)而不是String
搜索子串
返回指定子字符串在此字符串中第一次出现处的索引
int indexOf(String str)
"hello".indexOf("l"); //2
返回指定子字符串在此字符串中最右边出现处的索引
int lastIndexOf(String str)
"hello".lastIndexOf("l"); //3
测试此字符串是否以指定的前缀开始
boolean startsWith(String prefix)
"hello".startsWith("he"); //true
测试此字符串是否以指定的后缀结束
boolean endsWith(String suffix)
"hello".endWith("lo") //true
提取子串
substring(int beginIndex)
返回从起始位置(beginIndex)至字符串末尾的字符串
"Hello".substring(2); // "llo"
substring(int beginIndex, int endIndex)
返回从起始位置(beginIndex)到目标位置(endIndex)之间的字符串,但不包含目标位置(endIndex)的字符串
"Hello".substring(2, 4); "ll"
注意索引号是从0开始的
trim()
移除字符串首尾空白字符。
空白字符包括空格,\t,\r,\n
"\tHellow\r\n".trim; //Hellow
注意:trim没有改变字符串的内容,而是返回了一个新字符串。
strip()
移除字符串首尾空白字符。它和trim不同的是,类似中文的空格字符\u3000也会被移除
stripLeading()
只删除字符串开头的空格
stripTrailing()
只删除字符串开头的空格
"\u3000Hello\u3000".strip(); // "Hello"
" Hello ".stripLeading(); // "Hello "
" Hello ".stripTrailing(); // " Hello"
isEmpty()
判断字符串是否为空(字符串长度为0)
isBlack()
判断字符串是否为空白字符串(只包含空白字符)
"".isEmpty(); // true,因为字符串长度为0
" ".isEmpty(); // false,因为字符串长度不为0
" \n".isBlank(); // true,因为只包含空白字符
" Hello ".isBlank(); // false,因为包含非空白字符
根据字符或字符串替换
public String replace(char oldChar, char newChar)
String s = "hello";
s.replace('l', 'w'); // "hewwo",所有字符'l'被替换为'w'
通过正则表达式替换
public String replaceAll(String reges String replacement)
String s = "A,,B;C ,D";
s.replaceAll("[\\,\\;\\s]+", ","); // "A,B,C,D"
拼接字符串使用静态方法join(),它用指定的字符串连接字符串数组
String []arr = {"A","B","C"};
String s = String.join("***",arr);
//"A***B***C"
字符串提供了formatted()方法和format()静态方法,可以传入其他参数,替换站位符,然后生成新的字符串。
public class Main {
public static void main(String[] args) {
String s = "Hi %s, your score is %d!";
System.out.println(s.formatted("Alice", 80)); //Hi Allice, your score is 80!
System.out.println(String.format("Hi %s, your score is %.2f!", "Bob", 59.5)); //Hi Bob, your score is 59.5!
}
}
占位符还可以带格式,例如%.2f
表示显示两位小数。如果你不确定用啥占位符,那就始终用%s
,因为%s
可以显示任何数据类型。
要把任意基本类型或引用类型转换为字符串,可以使用静态方法valueOf()
。这是一个重载方法,编译器会根据参数自动选择合适的方法:
String.valueOf(123); // "123"
String.valueOf(45.67); // "45.67"
String.valueOf(true); // "true"
把字符串转换为其他类型
把字符串转换为int类型
int n1 = Integer.parseInt("123"); // 123
int n2 = Integer.parseInt("ff", 16); // 按十六进制转换,255
把字符串转换为boolean类型
boolean b1 = Boolean.parseBoolean("true"); // true
boolean b2 = Boolean.parseBoolean("FALSE"); // false
转换为char[]
char[] cs = "Hello".toCharArray(); // String -> char[]
String s = new String(cs); // char[] -> String
Java-String类常用方法汇总_java string 常用方法-CSDN博客
Java编译器对String做了特殊处理,使得我们可以直接用“+”拼接字符串。
String s = "";
for (int i = 0; i < 1000; i++) {
s = s + "," + i;
}
虽然可以直接拼接字符串,但是,在循环中,每次循环都会创建新的字符串对象,然后扔掉旧的字符串。这样,绝大部分字符串都是临时对象,不但浪费内存,还会影响GC效率。
Java标准库提供了StringBuilder类,它是一个可变对象,可以预分配缓冲区,这样,往StringBuilder中新增字符时,不会创建新的临时对象:
StringBuilder sb = new StringBuilder(1024);
for (int i = 0; i < 1000; i++) {
sb.append(',');
sb.append(i);
}
String s = sb.toString();
StringBuilder
还可以进行链式操作:
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 str = new StringBuilder();
在创建时添加初始字符串
StringBuilder str = new StringBuilder("abc");
在创建时添加初始长度
StringBuilder str = new StringBuilder(初始长度);
public StringBuilder append(任意类型)
追加数据:给原有的字符串尾部加入新字符串
str.append("just");
insert()
向指定位置插入数据
每次加入新字符串之后都会改变字符串中字符的地址。插入后原来指定位置的数据向后移。
str.insert(0,"you");
deleteCharAt()
删除指定位置的数据
str.deleteCharAt(index);
delete()
删除指定范围的数据左闭右开
str.delete(beginIndex,endIndex);
public int length()
返回长度(字符出现的个数)
public String toString()
返回一个字符串(拼接后的结果),实现将StringBuilder转换为String。
public StringBuilder reverse()
返回相反的字符序列
指定拼接时的间隔符号
public StringJoiner(间隔符号)
StringJoiner str = new StringJoiner(",");
指定拼接时的间隔符号、开始符号、结束符号
public StringJoiner(间隔符号,开始符号,结束符号)
StringJoiner str = new StringJoiner(",","[","]");
public StringJoiner add(添加的内容)
添加数据,并返回对象本身
public int length()
返回长度(字符出现的个数)
public String toString
返回一个字符串(拼接之后的结果),实现将StringJoiner转换为String。
aaa—bbb—ccc
=aaa,bbb,ccc.
当我们在调用一个方法时,不需要用变量接收它的结果,可以继续调用其他方法
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());
}
}
进行链式操作的关键是,定义的append()方法会返回this,这样,就可以不断调用自身的其他方法。(add同理)
Java的数据类型分两种:
基本类型:byte , short , int , long , boolean , float , double , char
引用类型:所有class和interface类型
引用类型可以赋值为null,表示空,但基本类型不能赋值为null。
如何把一个基本类型视为对象(引用类型)
例如,要想把int基本类型变成一个引用类型,我们可以定义一个integer类,它只包含了一个实施字段int,这样,integer类就可视为int的包装类。(Wrapper Class)
public class Integer{
private int value;
public Integer(int value){
this.value = value;
}
public int intValue(){
return this.value;
}
}
定义好了Integer类,我们可以把int和integer类互相转换。
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 |
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");
}
}
int和Integer可以互相转换:
int i = 100;
Integer n = Integer.valueOf(i);
int x = n.intValue();
Java编译器可以帮助我们在int 和Integer之间转型
Integer n = 100; //编译器自动使用Integer.valueOf(int)
int x = n; //编译器自动使用Integer.intValue()
这种直接把int变为Integer的赋值写法,称为自动装箱(Auto Unboxing),反过来,把Integer变为int的赋值写法,称为自动拆箱(Auto Unboxing)。
所有的包装类型都是不变类。Integer的源码为:
public final class Integer{
private final int value;
}
一旦创建了Integer对象,该对象就是不可变的。
对两个Integer实例进行比较要注意:绝对不能用==比较,应为Integer是引用类型,必须使用equals()比较。
我们自己创建Integer的时候,有以下两个方法
Integer n = new Integer(100);
Integer n = Integer.valueOf(100);
方法2更好,这种方法就是静态工厂方法,它尽可能地返回缓存的实例以节约内存。
Integer类中的静态方法parseInt()可以把字符串解析成一个整数:
int x1 = Integer.parseInt("100"); //x1 = 100,因为按10进制解析
int x2 = Integer.parseInt("100",16);
//x2 = 256,因为按16进制解析
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
Java的包装类型还定义了一些有用的静态变量
// 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
,因此,可以非常方便地直接通过包装类型获取各种基本类型:
// 向上转型为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)的基本数据类型。byte、short、int和long都是带符号整型,最高位是符号位。而C语言则提供了CPU支持的全部数据类型,包括无符号整型。无符号整型和有符号整型的转换在Java中就需要借助包装类型的静态方法完成。
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。
Random类位于java.util包下,Random类中实现的随机算法是伪随机,也就是有规则的随机。在进行随机时,随机算法的起源数字成为种子数(seed),在种子数的基础上进行一定的变换,从而产生需要的随机数字。
**相同种子数的Random对象,相同次数生成的随机数字是完全相同的。**这点在生成多个随机数字时需要特别注意。
public Random()
该构造方法使用一个和当前系统时间对应的相对时间有关的数字作为种子数,然后使用这个种子数构造Random对象。
Random r = new Random();
public Random(long seed)
该构造方法可以通过制定一个种子数进行创建
public r = new Random(10);
public boolean nextBoolean():是生成一个随机的boolean值,生成true和false的值几率相等,也就是都是50%的几率。
public double nextDouble():是生成一个随机的double值,数值介于[0,1.0)之间。
public int nextInt():是生成在-231到231-1之间int值。如果需要生成指定区间的int值,则需要进行一定的数学变换,具体可以参看下面的使用示例中的代码。
public int nextInt(int n):是生成一个介于[0,n)的区间int值,包含0而不包含n。如果想生成指定区间int值,也需要进行一定的数学变换,具体参看下面的使用示例中的代码。
public void setSeed(long seed):是重新设置Random对象中的种子数。设置完种子数以后的Random对象和相同种子数使用new关键字创建出的Random对象相同。
public float nextFloat(int n):返回下一个伪随机数,它是取自此随机数生成器序列的、在 0.0 和 1.0 之间均匀分布的 float 值。
public long nextLong():返回下一个伪随机数,它是取自此随机数生成器序列的均匀分布的 long 值。
public double nextGaussian():返回下一个伪随机数,它是取自此随机数生成器序列的、呈高斯(“正态”)分布的 double 值,其平均值是 0.0,标准差是 1.0。
有伪随机数,就有真随机数。实际上真正的真随机数只能通过量子力学原理来获取,而我们想要的是一个不可预测的安全的随机数,SecureRandom
就是用来创建安全的随机数的:
SecureRandom sr = new SecureRandom();
System.out.println(sr.nextInt(100));
SecureRandom
无法指定种子,它使用RNG(random number generator)算法。JDK的SecureRandom
实际上有多种不同的底层实现,有的使用安全随机种子加上伪随机数算法来产生安全的随机数,有的使用真正的随机数生成器。实际使用的时候,可以优先获取高强度的安全随机数生成器,如果没有提供,再使用普通等级的安全随机数生成器:
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
来产生安全的随机数。
Math.abs(-100); //100
Math.max(100,99); //100
Math.min(1.2,2.3); //1.2
Math.pow(2,10); //2的十次方 = 1024
Math.sqrt(4); //2
Math.log(2) //7.389...
Math.log(4); // 1.386...
Math.log10(100); // 2
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
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
:
Math.random(); // 0.53907... 每次都不一样
如果我们要生成一个区间在[MIN, MAX)
的随机数,可以借助Math.random()
实现,计算如下:
// 区间在[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.util包提供了Date类来封装当前的日期和时间。
构造方法:
Date():此种形式表示分配 Date 对象并初始化此对象,以表示分配它的时间(精确到毫秒),使用该构造方法创建的对象可以获取本地的当前时间。
Date(long date):此种形式表示从 GMT 时间(格林尼治时间)1970 年 1 月 1 日 0 时 0 分 0 秒开始经过参数 date 指定的毫秒数。
这两个构造方法的使用示例如下:
Date date1 = new Date(); // 调用无参数构造函数
System.out.println(date1.toString()); // 输出:Wed May 18 21:24:40 CST 2016
Date date2 = new Date(60000); // 调用含有一个long类型参数的构造函数
System.out.println(date2); // 输出:Thu Jan 0108:01:00 CST 1970
获取当前日期时间:
Java中获取当前日期和时间很简单,使用Date
对象的toString()
方法来打印当前日期和时间,如下所示:
import java.util.Date;
public class DateDemo {
public static void main(String args[]) { // 初始化 Date 对象
Date date = new Date(); // 使用 toString() 函数显示日期时间
System.out.println(date.toString());
}
}
日期比较:
Java使用以下三种方法来比较两个日期:
在Java中,我们可以通过static final来定义常量。
定义int型常量
public class {
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;
}
引用
if (day == Weekday.SAT || day == Weekday.SUN) {
// TODO: work at home
}
定义字符串常量
public class Color{
public static final String RED = "r";
public static final String GREEN = "g";
public static final String BLUE = "b";
}
引用
String read = "r";
if(Color.RED.equals(color)){}
在Java中,我们可以通过static final
来定义常量。例如,我们希望定义周一到周日这7个常量,可以用7个不同的int
表示:
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;
}
使用常量的时候,可以这么引用:
if (day == Weekday.SAT || day == Weekday.SUN) {
// TODO: work at home
}
也可以把常量定义为字符串类型,例如,定义3种颜色的常量:
public class Color {
public static final String RED = "r";
public static final String GREEN = "g";
public static final String BLUE = "b";
}
使用常量的时候,可以这么引用:
String color = ...
if (Color.RED.equals(color)) {
// TODO:
}
无论是int
常量还是String
常量,使用这些常量来表示一组枚举值的时候,有一个严重的问题就是,编译器无法检查每个值的合理性。例如:
if (weekday == 6 || weekday == 7) {
if (tasks == Weekday.MON) {
// TODO:
}
}
上述代码编译和运行均不会报错,但存在两个问题:
Weekday
定义的常量范围是0
~6
,并不包含7
,编译器无法检查不在枚举中的int
值;enum类型和其他的class没有任何区别。enum定义的类型就是class,只不过它有以下几个特点:
例如,我们定义的Color枚举类:
public enum Color {
RED, GREEN, BLUE;
}
为了让编译器能自动检查某个值在枚举的集合内,并且,不同用途的枚举需要不同的类型来标记,不能混用,我们可以使用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
,编译器会自动检查出类型错误。例如,下面的语句不可能编译通过:
int day = 1;
if (day == Weekday.SUN) { // Compile error: bad operand types for binary operator '=='
}
其次,不可能引用到非枚举的值,因为无法通过编译。
最后,不同类型的枚举不能互相比较或者赋值,因为类型不符。例如,不能给一个Weekday
枚举类型的变量赋值为Color
枚举类型的值:
Weekday x = Weekday.SUN; // ok!
Weekday y = Color.RED; // Compile error: incompatible types
这就使得编译器可以在编译期自动检查出所有可能的潜在错误。
使用enum
定义的枚举类是一种引用类型。前面我们讲到,引用类型比较,要使用equals()
方法,如果使用==
比较,它比较的是两个引用类型的变量是否是同一个对象。因此,引用类型比较,要始终使用equals()
方法,但enum
类型可以例外。
这是因为enum
类型的每个常量在JVM中只有一个唯一实例,所以可以直接用==
比较:
if (day == Weekday.FRI) { // ok!
}
if (day.equals(Weekday.SUN)) { // ok, but more code!
}
返回常量名,例如:
String s = Weekday.SUN.name(); // "SUN"
返回定义的常量的顺序,从0开始计数,例如:
int n = Weekday.MON.ordinal(); // 1
改变枚举常量定义的顺序就会导致ordinal()返回值发生变化。例如:
public enum Weekday {
SUN, MON, TUE, WED, THU, FRI, SAT;
}
和
public enum Weekday {
MON, TUE, WED, THU, FRI, SAT, SUN;
}
的ordinal就是不同的。如果在代码中编写了类似if(x.ordinal()==1)这样的语句,就要保证enum的枚举顺序不能变。新增的常量必须放在最后。
最后,枚举类可以应用在switch语句中。因为枚举类天生具有类型信息和有限个枚举常量,所以比int、String类型更适合用在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;
//无参数,默认调用无参方法
}
先看一个需求
要求创建季节(Season) 对象,请设计并完成。
public class Enumeration01 {
public static void main(String[] args) {
//使用
Season spring = new Season("春天", "温暖");
Season summer = new Season("夏天", "炎热");
Season autumn = new Season("秋天", "凉爽");
Season winter = new Season("冬天", "寒冷");
//autumn.setName("XXX");
//autumn.setDesc("非常的热..");
//因为对于季节而已,它们的对象(具体值),是固定的四个,不会有更多
//这个设计类的思路,不能体现季节是固定的四个对象
//因此,这样的设计不好===> 枚举类[枚: 一个一个。 举: 例举。 即把具体的对象一个一个例举出来的类,就称为枚举类]
//Season other = new Season("白天", "光明");
}
}
class Season {//类
private String name;
private String desc;//描述
public Season(String name, String desc) {
this.name = name;
this.desc = desc;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getDesc() {
return desc;
}
public void setDesc(String desc) {
this.desc = desc;
}
}
分析问题
创建 Season 对象有如下特点
季节的值是有限的几个值(spring, summer, autumn, winter)
只读,不需要修改。
解决方案-枚举
枚举对应英文(enumeration, 简写 enum)
枚举是一组常量的集合。
可以这里理解:枚举属于一种特殊的类,里面只包含一组有限的特定的对象。
枚举的两种种实现方式
自定义类实现枚举
使用 enum 关键字实现枚举
4.1 自定义类实现枚举-应用案例
1.不需要提供setXxx方法,因为枚举对象值通常为只读。
2.对枚举 对象/属性 使用final + static共同修饰,实现底层优化。
3.枚举对象名通常使用全部大写,常量的命名规范。
4.枚举对象根据需要,也可以有多个属性。
public class Enumeration02 {
public static void main(String[] args) {
System.out.println(Season.SPRING);
System.out.println(Season.SUMMER);
System.out.println(Season.AUTUMN);
System.out.println(Season.WINTER);
}
}
//演示自定义枚举实现
class Season {//类
private String name;
private String desc;//描述
//定义了四个对象,固定值
public static final Season SPRING = new Season("春天", "温暖");
public static final Season SUMMER = new Season("夏天", "炎热");
public static final Season AUTUMN = new Season("秋天", "凉爽");
public static final Season WINTER = new Season("冬天", "寒冷");
//1. 将构造器私有化,目的是防止 直接 new
//2. 去掉 setXxx 方法,防止属性被修改
//3. 在 Season 内部,直接创固定的对象
//4. 优化下:可以加入 final 修饰符
private Season(String name, String desc) {
this.name = name;
this.desc = desc;
}
public String getName() {
return name;
}
public String getDesc() {
return desc;
}
@Override
public String toString() {
return "Season{" +
"name='" + name + '\'' +
", desc='" + desc + '\'' +
'}';
}
}
4.2 自定义类实现枚举–小结
小结:进行自定义类实现枚举,有如下特点:
1.构造器私有化
2.本类内部创建一组对象【四个: 春夏秋冬】
3.对外暴露对象(通过为对象添加 public final static 修饰符)
4.可以提供 get 方法,但是不要提供 set 方法
4.3 enum 关键字实现枚举–快速入门
说明:使用 enum 来实现前面的枚举案例,主要体会和自定义类实现枚举不同的地方。
public class Enumeration03 {
public static void main(String[] args) {
System.out.println(Season.SPRING);
System.out.println(Season.SUMMER);
System.out.println(Season.AUTUMN);
System.out.println(Season.WINTER);
}
}
//演示enum关键字来实现枚举类
enum Season {//类
//如果使用 enum 来实现枚举类
//1. 使用 enum 来替代 class
//2. public static final Season SPRING = new Season("春天", "温暖"); 等价于
// SPRING("春天", "温暖") 解读:常量名(实参列表)
//3. 如果有多个常量(对象), 使用 "," 间隔
//4. 如果使用 enum 来实现枚举,要求将定义常量对象,写在最前面
SPRING("春天", "温暖"),
SUMMER("夏天", "炎热"),
AUTUMN("秋天", "凉爽"),
WINTER("冬天", "寒冷");
private String name;
private String desc;//描述
private Season(String name, String desc) {
this.name = name;
this.desc = desc;
}
public String getName() {
return name;
}
public String getDesc() {
return desc;
}
@Override
public String toString() {
return "Season{" +
"name='" + name + '\'' +
", desc='" + desc + '\'' +
'}';
}
}
使用String、Integer等类型的时候,这些类型都是不变类。
不变类的特点有:
record
关键词的引入,主要是为了提供一种更为简洁、紧凑的final
类的定义方式。
record
申明的类,具备这些特点:
final
类equals
、hashCode
、toString
函数public
属性几种申明方式:
//单独文件申明:
public record range(int start, int end){}
//在类内部申明:
public class DidispaceTest {
public record range(int start, int end){}
}
1
2
//函数内申明:
public class DidispaceTest {
public void test() {
public record range(int start, int end){}
}
}
什么是集合(Collection)?集合就是“由若干个确定的元素所构成的整体”。例如,5只小兔构成的集合:
┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐
│ (\_(\ (\_/) (\_/) (\_/) (\(\ │
( -.-) (•.•) (>.<) (^.^) (='.')
│ C(")_(") (")_(") (")_(") (")_(") O(_")") │
└ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘
在数学中,我们经常遇到集合的概念。例如:
为什么要在计算机中引入集合呢?这是为了便于处理一组类似的数据,例如:
在Java中,如果一个Java对象可以在内部持有若干其他Java对象,并对外提供访问接口,我们把这种Java对象称为集合。很显然,Java的数组可以看作是一种集合:
String[] ss = new String[10]; // 可以持有10个String对象
ss[0] = "Hello"; // 可以放入String对象
String first = ss[0]; // 可以获取String对象
既然Java提供了数组这种数据类型,可以充当集合,那么,我们为什么还需要其他集合类?这是因为数组有如下限制:
因此,我们需要各种不同类型的集合类来处理不同的数据,例如:
Java标准库自带的java.util
包提供了集合类:**Collection
,它是除Map
外所有其他集合类的根接口。**Java的java.util
包主要提供了以下三种类型的集合:
List
:一种有序列表的集合,例如,按索引排列的Student
的List
;Set
:一种保证没有重复元素的集合,例如,所有无重复名称的Student
的Set
;Map
:一种通过键值(key-value)查找的映射表集合,例如,根据Student
的name
查找对应Student
的Map
。Java集合的设计有几个特点:一是实现了接口和实现类相分离,例如,有序表的接口是List
,具体的实现类有ArrayList
,LinkedList
等,二是支持泛型,我们可以限制在一个集合中只能放入同一种数据类型的元素,例如:
List list = new ArrayList<>(); // 只能放入String类型
最后,Java访问集合总是通过统一的方式——迭代器(Iterator)来实现,它最明显的好处在于无需知道集合内部元素是按什么方式存储的。
在集合类中,List
是最基础的一种集合:它是一种有序列表。
List
的行为和数组几乎完全相同:List
内部按照放入元素的先后顺序存放,每个元素都可以通过索引确定自己的位置,List
的索引和数组一样,从0
开始。
数组和List
类似,也是有序结构,如果我们使用数组,在添加和删除元素的时候,会非常不方便。例如,从一个已有的数组{'A', 'B', 'C', 'D', 'E'}
中删除索引为2
的元素:
┌───┬───┬───┬───┬───┬───┐
│ A │ B │ C │ D │ E │ │
└───┴───┴───┴───┴───┴───┘
│ │
┌───┘ │
│ ┌───┘
│ │
▼ ▼
┌───┬───┬───┬───┬───┬───┐
│ A │ B │ D │ E │ │ │
└───┴───┴───┴───┴───┴───┘
这个“删除”操作实际上是把'C'
后面的元素依次往前挪一个位置,而“添加”操作实际上是把指定位置以后的元素都依次向后挪一个位置,腾出来的位置给新加的元素。这两种操作,用数组实现非常麻烦。
因此,在实际应用中,需要增删元素的有序列表,我们使用最多的是ArrayList
。实际上,ArrayList
在内部使用了数组来存储所有元素。例如,一个ArrayList
拥有5个元素,实际数组大小为6
(即有一个空位):
size=5
┌───┬───┬───┬───┬───┬───┐
│ A │ B │ C │ D │ E │ │
└───┴───┴───┴───┴───┴───┘
当添加一个元素并指定索引到ArrayList
时,ArrayList
自动移动需要移动的元素:
size=5
┌───┬───┬───┬───┬───┬───┐
│ A │ B │ │ C │ D │ E │
└───┴───┴───┴───┴───┴───┘
然后,往内部指定索引的数组位置添加一个元素,然后把size
加1
:
size=6
┌───┬───┬───┬───┬───┬───┐
│ A │ B │ F │ C │ D │ E │
└───┴───┴───┴───┴───┴───┘
继续添加元素,但是数组已满,没有空闲位置的时候,ArrayList
先创建一个更大的新数组,然后把旧数组的所有元素复制到新数组,紧接着用新数组取代旧数组:
size=6
┌───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┐
│ A │ B │ F │ C │ D │ E │ │ │ │ │ │ │
└───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┘
现在,新数组就有了空位,可以继续添加一个元素到数组末尾,同时size
加1
:
size=7
┌───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┐
│ A │ B │ F │ C │ D │ E │ G │ │ │ │ │ │
└───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┘
可见,ArrayList
把添加和删除的操作封装起来,让我们操作List
类似于操作数组,却不用关心内部元素如何移动。
我们考察List
接口,可以看到几个主要的接口方法:
boolean add(E e)
boolean add(int index, E e)
E remove(int index)
boolean remove(Object e)
E get(int index)
int size()
但是,实现List
接口并非只能通过数组(即ArrayList
的实现方式)来实现,另一种LinkedList
通过“链表”也实现了List接口。在LinkedList
中,它的内部每个元素都指向下一个元素:
┌───┬───┐ ┌───┬───┐ ┌───┬───┐ ┌───┬───┐
HEAD ──>│ A │ ●─┼──>│ B │ ●─┼──>│ C │ ●─┼──>│ D │ │
└───┴───┘ └───┴───┘ └───┴───┘ └───┴───┘
我们来比较一下ArrayList
和LinkedList
:
ArrayList | LinkedList | |
---|---|---|
获取指定元素 | 速度很快 | 需要从头开始查找元素 |
添加元素到末尾 | 速度很快 | 速度很快 |
在指定位置添加/删除 | 需要移动元素 | 不需要移动元素 |
内存占用 | 少 | 较大 |
通常情况下,我们总是优先使用ArrayList
。
使用List
时,我们要关注List
接口的规范。List
接口允许我们添加重复的元素,即List
内部的元素可以重复:
public class Main {
public static void main(String[] args) {
List list = new ArrayList<>();
list.add("apple"); // size=1
list.add("pear"); // size=2
list.add("apple"); // 允许重复添加元素,size=3
System.out.println(list.size());
}
}
List
还允许添加null
:
public class Main {
public static void main(String[] args) {
List list = new ArrayList<>();
list.add("apple"); // size=1
list.add(null); // size=2
list.add("pear"); // size=3
String second = list.get(1); // null
System.out.println(second);
}
}
除了使用ArrayList
和LinkedList
,我们还可以通过List
接口提供的of()
方法,根据给定元素快速创建List
:
List list = List.of(1, 2, 5);
但是List.of()
方法不接受null
值,如果传入null
,会抛出NullPointerException
异常。
和数组类型一样,我们要遍历一个List
,完全可以用for
循环根据索引配合get(int)
方法遍历:
public class Main {
public static void main(String[] args) {
List<String> list = List.of("apple", "pear", "banana");
for (int i=0; i<list.size(); i++) {
String s = list.get(i);
System.out.println(s);
}
}
}
但这种方式并不推荐,一是代码复杂,二是因为get(int)
方法只有ArrayList
的实现是高效的,换成LinkedList
后,索引越大,访问速度越慢。
所以我们要始终坚持使用迭代器Iterator
来访问List
。Iterator
本身也是一个对象,但它是由List
的实例调用iterator()
方法的时候创建的。Iterator
对象知道如何遍历一个List
,并且不同的List
类型,返回的Iterator
对象实现也是不同的,但总是具有最高的访问效率。
**Iterator
对象有两个方法:boolean hasNext()
判断是否有下一个元素,E next()
返回下一个元素。**因此,使用Iterator
遍历List
代码如下:
public class Main {
public static void main(String[] args) {
List<String> list = List.of("apple", "pear", "banana");
for (Iterator<String> it = list.iterator(); it.hasNext(); ) {
String s = it.next();
System.out.println(s);
}
}
}
有童鞋可能觉得使用Iterator
访问List
的代码比使用索引更复杂。但是,要记住,**通过Iterator
遍历List
永远是最高效的方式。并且,由于Iterator
遍历是如此常用,所以,Java的for each
循环本身就可以帮我们使用Iterator
遍历。**把上面的代码再改写如下:
public class Main {
public static void main(String[] args) {
List<String> list = List.of("apple", "pear", "banana");
for (String s : list) {
System.out.println(s);
}
}
}
实际上,只要实现了Iterable
接口的集合类都可以直接用for each
循环来遍历,Java编译器本身并不知道如何遍历集合对象,但它会自动把for each
循环变成Iterator
的调用,原因就在于Iterable
接口定义了一个Iterator
方法,强迫集合类必须返回一个Iterator
实例。
把List
变为Array
有三种方法,第一种是调用toArray()
方法直接返回一个Object[]
数组:
public class Main {
public static void main(String[] args) {
List list = List.of("apple", "pear", "banana");
Object[] array = list.toArray();
for (Object s : array) {
System.out.println(s);
}
}
}
这种方法会丢失类型信息,所以实际应用很少。
第二种方式是给toArray(T[])
传入一个类型相同的Array
,List
内部自动把元素复制到传入的Array
中:
public class Main {
public static void main(String[] args) {
List list = List.of(12, 34, 56);
Integer[] array = list.toArray(new Integer[3]);
for (Integer n : array) {
System.out.println(n);
}
}
}
注意到这个toArray(T[])
方法的泛型参数
并不是List
接口定义的泛型参数
,所以,我们实际上可以传入其他类型的数组,例如我们传入Number
类型的数组,返回的仍然是Number
类型
public class Main {
public static void main(String[] args) {
List<Integer> list = List.of(12, 34, 56);
Number[] array = list.toArray(new Number[3]);
for (Number n : array) {
System.out.println(n);
}
}
}
但是,如果我们传入类型不匹配的数组,例如,String[]
类型的数组,由于List
的元素是Integer
,所以无法放入String
数组,这个方法会抛出ArrayStoreException
。
如果我们传入的数组大小和List
实际的元素个数不一致怎么办?根据List接口的文档,我们可以知道:
如果传入的数组不够大,那么List
内部会创建一个新的刚好够大的数组,填充后返回;如果传入的数组比List
元素还要多,那么填充完元素后,剩下的数组元素一律填充null
。
实际上,最常用的是传入一个“恰好”大小的数组:
Integer[] array = list.toArray(new Integer[list.size()]);
最后一种更简洁的写法是通过List
接口定义的T[] toArray(IntFunction
方法:
Integer[] array = list.toArray(Integer[]::new);
把Array
变为List
就简单多了,通过List.of(T...)
方法最简单:
Integer[] array = { 1, 2, 3 };
List list = List.of(array);
对于JDK 11之前的版本,可以使用Arrays.asList(T...)
方法把数组转换成List
。
要注意的是,返回的List
不一定就是ArrayList
或者LinkedList
,因为List
只是一个接口,如果我们调用List.of()
,它返回的是一个只读List
:
public class Main {
public static void main(String[] args) {
List list = List.of(12, 34, 56);
list.add(999); // UnsupportedOperationException
}
}
对只读List
调用add()
、remove()
方法会抛出UnsupportedOperationException
。
我们知道**List
是一种有序链表:List
内部按照放入元素的先后顺序存放,并且每个元素都可以通过索引确定自己的位置。**
List
还提供了boolean contains(Object o)
方法来判断List
是否包含某个指定元素。此外,int indexOf(Object o)
方法可以返回某个元素的索引,如果元素不存在,就返回-1
。
我们来看一个例子:
public class Main {
public static void main(String[] args) {
List<String> list = List.of("A", "B", "C");
System.out.println(list.contains("C")); // true
System.out.println(list.contains("X")); // false
System.out.println(list.indexOf("C")); // 2
System.out.println(list.indexOf("X")); // -1
}
}
这里我们注意一个问题,我们往List
中添加的"C"
和调用contains("C")
传入的"C"
是不是同一个实例?
如果这两个"C"
不是同一个实例,这段代码是否还能得到正确的结果?我们可以改写一下代码测试一下:
public class Main {
public static void main(String[] args) {
List list = List.of("A", "B", "C");
System.out.println(list.contains(new String("C"))); // true or false?
System.out.println(list.indexOf(new String("C"))); // 2 or -1?
}
}
因为我们传入的是new String("C")
,所以一定是不同的实例。结果仍然符合预期,这是为什么呢?
因为List
内部并不是通过==
判断两个元素是否相等,而是使用equals()
方法判断两个元素是否相等,例如contains()
方法可以实现如下
public class ArrayList {
Object[] elementData;
public boolean contains(Object o) {
for (int i = 0; i < elementData.length; i++) {
if (o.equals(elementData[i])) {
return true;
}
}
return false;
}
}
因此,要正确使用List
的contains()
、indexOf()
这些方法,放入的实例必须正确覆写equals()
方法,否则,放进去的实例,查找不到。我们之所以能正常放入String
、Integer
这些对象,是因为Java标准库定义的这些类已经正确实现了equals()
方法。
如何正确编写equals()
方法?equals()
方法要求我们必须满足以下条件:
null
的x
来说,x.equals(x)
必须返回true
;null
的x
和y
来说,如果x.equals(y)
为true
,则y.equals(x)
也必须为true
;null
的x
、y
和z
来说,如果x.equals(y)
为true
,y.equals(z)
也为true
,那么x.equals(z)
也必须为true
;null
的x
和y
来说,只要x
和y
状态不变,则x.equals(y)
总是一致地返回true
或者false
;null
的比较:即x.equals(null)
永远返回false
。上述规则看上去似乎非常复杂,但其实代码实现equals()
方法是很简单的,我们以Person
类为例:
public class Person {
public String name;
public int age;
}
首先,我们要定义“相等”的逻辑含义。对于Person
类,如果name
相等,并且age
相等,我们就认为两个Person
实例相等。
因此,编写equals()
方法如下:
public boolean equals(Object o) {
if (o instanceof Person p) {
return this.name.equals(p.name) && this.age == p.age;
}
return false;
}
对于引用字段比较,我们使用equals()
,对于基本类型字段的比较,我们使用==
。
如果this.name
为null
,那么equals()
方法会报错,因此,需要继续改写如下:
public boolean equals(Object o) {
if (o instanceof Person p) {
boolean nameEquals = false;
if (this.name == null && p.name == null) {
nameEquals = true;
}
if (this.name != null) {
nameEquals = this.name.equals(p.name);
}
return nameEquals && this.age == p.age;
}
return false;
}
如果Person
有好几个引用类型的字段,上面的写法就太复杂了。要简化引用类型的比较,我们使用Objects.equals()
静态方法:
public boolean equals(Object o) {
if (o instanceof Person p) {
return Objects.equals(this.name, p.name) && this.age == p.age;
}
return false;
}
因此,我们总结一下equals()
方法的正确编写方法:
instanceof
判断传入的待比较的Object
是不是当前类型,如果是,继续比较,否则,返回false
;Objects.equals()
比较,对基本类型直接用==
比较。使用Objects.equals()
比较两个引用类型是否相等的目的是省去了判断null
的麻烦。两个引用类型都是null
时它们也是相等的。
如果不调用List
的contains()
、indexOf()
这些方法,那么放入的元素就不需要实现equals()
方法。
我们知道,List
是一种顺序列表,如果有一个存储学生Student
实例的List
,要在List
中根据name
查找某个指定的Student
的分数,应该怎么办?
最简单的方法是遍历List
并判断name
是否相等,然后返回指定元素:
List list = ...
Student target = null;
for (Student s : list) {
if ("Xiao Ming".equals(s.name)) {
target = s;
break;
}
}
System.out.println(target.score);
这种需求其实非常常见,即**通过一个键去查询对应的值。使用List
来实现存在效率非常低的问题,因为平均需要扫描一半的元素才能确定,而Map
这种键值(key-value)映射表的数据结构,作用就是能高效通过key
快速查找value
(元素)。**
用Map
来实现根据name
查询某个Student
的代码如下:
public class Main {
public static void main(String[] args) {
Student s = new Student("Xiao Ming", 99);
Map<String, Student> map = new HashMap<>();
map.put("Xiao Ming", s); // 将"Xiao Ming"和Student实例映射并关联
Student target = map.get("Xiao Ming"); // 通过key查找并返回映射的Student实例
System.out.println(target == s); // true,同一个实例
System.out.println(target.score); // 99
Student another = map.get("Bob"); // 通过另一个key查找
System.out.println(another); // 未找到返回null
}
}
class Student {
public String name;
public int score;
public Student(String name, int score) {
this.name = name;
this.score = score;
}
}
通过上述代码可知:Map
是一种键-值映射表,当我们调用put(K key, V value)
方法时,就把key
和value
做了映射并放入Map
。当我们调用V get(K key)
时,就可以通过key
获取到对应的value
。如果key
不存在,则返回null
。和List
类似,Map
也是一个接口,最常用的实现类是HashMap
。
如果只是想查询某个key
是否存在,可以调用boolean containsKey(K key)
方法。
如果我们在存储Map
映射关系的时候,对同一个key调用两次put()
方法,分别放入不同的value
,会有什么问题呢?例如:
public class Main {
public static void main(String[] args) {
Map map = new HashMap<>();
map.put("apple", 123);
map.put("pear", 456);
System.out.println(map.get("apple")); // 123
map.put("apple", 789); // 再次放入apple作为key,但value变为789
System.out.println(map.get("apple")); // 789
}
}
重复放入key-value
并不会有任何问题,但是一个key
只能关联一个value
。在上面的代码中,一开始我们把key
对象"apple"
映射到Integer
对象123
,然后再次调用put()
方法把"apple"
映射到789
,这时,原来关联的value
对象123
就被“冲掉”了。实际上,put()
方法的签名是V put(K key, V value)
,如果放入的key
已经存在,put()
方法会返回被删除的旧的value
,否则,返回null
。
始终牢记:Map中不存在重复的key,因为放入相同的key,只会把原有的key-value对应的value给替换掉。
此外,在一个Map
中,虽然key
不能重复,但value
是可以重复的:
Map map = new HashMap<>();
map.put("apple", 123);
map.put("pear", 123); // ok
对Map
来说,要遍历key
可以使用for each
循环遍历Map
实例的keySet()
方法返回的Set
集合,它包含不重复的key
的集合:
public class Main {
public static void main(String[] args) {
Map<String, Integer> map = new HashMap<>();
map.put("apple", 123);
map.put("pear", 456);
map.put("banana", 789);
for (String key : map.keySet()) {
Integer value = map.get(key);
System.out.println(key + " = " + value);
}
}
}
同时遍历key
和value
可以使用for each
循环遍历Map
对象的entrySet()
集合,它包含每一个key-value
映射:
public class Main {
public static void main(String[] args) {
Map<String, Integer> map = new HashMap<>();
map.put("apple", 123);
map.put("pear", 456);
map.put("banana", 789);
for (Map.Entry<String, Integer> entry : map.entrySet()) {
String key = entry.getKey();
Integer value = entry.getValue();
System.out.println(key + " = " + value);
}
}
}
Map
和List
不同的是,Map
存储的是key-value
的映射关系,并且,它不保证顺序。在遍历的时候,遍历的顺序既不一定是put()
时放入的key
的顺序,也不一定是key
的排序顺序。使用Map
时,任何依赖顺序的逻辑都是不可靠的。以HashMap
为例,假设我们放入"A"
,"B"
,"C"
这3个key
,遍历的时候,每个key
会保证被遍历一次且仅遍历一次,但顺序完全没有保证,甚至对于不同的JDK版本,相同的代码遍历的输出顺序都是不同的!
遍历Map时,不可假设输出的key是有序的!
我们知道Map是一种键-值(key-value)映射表,可以通过key快速查找对应的value。
以HashMap为例,观察下面的代码:
Map map = new HashMap<>();
map.put("a", new Person("Xiao Ming"));
map.put("b", new Person("Xiao Hong"));
map.put("c", new Person("Xiao Jun"));
map.get("a"); // Person("Xiao Ming")
map.get("x"); // null
HashMap
之所以能根据key
直接拿到value
,原因是它内部通过空间换时间的方法,用一个大数组存储所有value
,并根据key直接计算出value
应该存储在哪个索引:
┌───┐
0 │ │
├───┤
1 │ ●─┼───> Person("Xiao Ming")
├───┤
2 │ │
├───┤
3 │ │
├───┤
4 │ │
├───┤
5 │ ●─┼───> Person("Xiao Hong")
├───┤
6 │ ●─┼───> Person("Xiao Jun")
├───┤
7 │ │
└───┘
如果key
的值为"a"
,计算得到的索引总是1
,因此返回value
为Person("Xiao Ming")
,如果key
的值为"b"
,计算得到的索引总是5
,因此返回value
为Person("Xiao Hong")
,这样,就不必遍历整个数组,即可直接读取key
对应的value
。
当我们使用key
存取value
的时候,就会引出一个问题:
我们放入Map
的key
是字符串"a"
,但是,当我们获取Map
的value
时,传入的变量不一定就是放入的那个key
对象。
换句话讲,两个key
应该是内容相同,但不一定是同一个对象。测试代码如下:
public class Main {
public static void main(String[] args) {
String key1 = "a";
Map map = new HashMap<>();
map.put(key1, 123);
String key2 = new String("a");
map.get(key2); // 123
System.out.println(key1 == key2); // false
System.out.println(key1.equals(key2)); // true
}
}
因为在Map
的内部,对key
做比较是通过equals()
实现的,这一点和List
查找元素需要正确覆写equals()
是一样的,即正确使用Map
必须保证:作为key
的对象必须正确覆写equals()
方法。
我们经常使用String
作为key
,因为String
已经正确覆写了equals()
方法。但如果我们放入的key
是一个自己写的类,就必须保证正确覆写了equals()
方法。
我们再思考一下HashMap
为什么能通过key
直接计算出value
存储的索引。相同的key
对象(使用equals()
判断时返回true
)必须要计算出相同的索引,否则,相同的key
每次取出的value
就不一定对。
通过key
计算索引的方式就是调用key
对象的hashCode()
方法,它返回一个int
整数。HashMap
正是通过这个方法直接定位key
对应的value
的索引,继而直接返回value
。
因此,正确使用Map
必须保证:
key
的对象必须正确覆写equals()
方法,相等的两个key
实例调用equals()
必须返回true
;key
的对象还必须正确覆写hashCode()
方法,且hashCode()
方法要严格遵循以下规范:hashCode()
必须相等;hashCode()
尽量不要相等。即对应两个实例a
和b
:
a
和b
相等,那么a.equals(b)
一定为true
,则a.hashCode()
必须等于b.hashCode()
;a
和b
不相等,那么a.equals(b)
一定为false
,则a.hashCode()
和b.hashCode()
尽量不要相等。上述第一条规范是正确性,必须保证实现,否则HashMap
不能正常工作。
而第二条如果尽量满足,则可以保证查询效率,因为不同的对象,如果返回相同的hashCode()
,会造成Map
内部存储冲突,使存取的效率下降。
正确编写equals()
的方法我们已经在编写equals方法一节中讲过了,以Person
类为例:
public class Person {
String firstName;
String lastName;
int age;
}
把需要比较的字段找出来:
然后,引用类型使用Objects.equals()
比较,基本类型使用==
比较。
在正确实现equals()
的基础上,我们还需要正确实现hashCode()
,即上述3个字段分别相同的实例,hashCode()
返回的int
必须相同:
public class Person {
String firstName;
String lastName;
int age;
@Override
int hashCode() {
int h = 0;
h = 31 * h + firstName.hashCode();
h = 31 * h + lastName.hashCode();
h = 31 * h + age;
return h;
}
}
注意到String
类已经正确实现了hashCode()
方法,我们在计算Person
的hashCode()
时,反复使用31*h
,这样做的目的是为了尽量把不同的Person
实例的hashCode()
均匀分布到整个int
范围。
和实现equals()
方法遇到的问题类似,如果firstName
或lastName
为null
,上述代码工作起来就会抛NullPointerException
。为了解决这个问题,我们在计算hashCode()
的时候,经常借助Objects.hash()
来计算:
int hashCode() {
return Objects.hash(firstName, lastName, age);
}
所以,编写equals()
和hashCode()
遵循的原则是:
equals()
用到的用于比较的每一个字段,都必须在hashCode()
中用于计算;equals()
中没有使用到的字段,绝不可放在hashCode()
中计算。
另外注意,对于放入HashMap
的value
对象,没有任何要求。
既然HashMap
内部使用了数组,通过计算key
的hashCode()
直接定位value
所在的索引,那么第一个问题来了:hashCode()返回的int
范围高达±21亿,先不考虑负数,HashMap
内部使用的数组得有多大?
实际上HashMap
初始化时默认的数组大小只有16,任何key
,无论它的hashCode()
有多大,都可以简单地通过:
int index = key.hashCode() & 0xf; // 0xf = 15
把索引确定在0~15,即永远不会超出数组范围,上述算法只是一种最简单的实现。
第二个问题:如果添加超过16个key-value
到HashMap
,数组不够用了怎么办?
添加超过一定数量的key-value
时,HashMap
会在内部自动扩容,每次扩容一倍,即长度为16的数组扩展为长度32,相应地,需要重新确定hashCode()
计算的索引位置。例如,对长度为32的数组计算hashCode()
对应的索引,计算方式要改为:
int index = key.hashCode() & 0x1f; // 0x1f = 31
由于扩容会导致重新分布已有的key-value
,所以,频繁扩容对HashMap
的性能影响很大。如果我们确定要使用一个容量为10000
个key-value
的HashMap
,更好的方式是创建HashMap
时就指定容量:
Map map = new HashMap<>(10000);
虽然指定容量是10000
,但HashMap
内部的数组长度总是2n,因此,实际数组长度被初始化为比10000
大的16384
(214)。
最后一个问题:如果不同的两个key
,例如"a"
和"b"
,它们的hashCode()
恰好是相同的(这种情况是完全可能的,因为不相等的两个实例,只要求hashCode()
尽量不相等),那么,当我们放入:
map.put("a", new Person("Xiao Ming"));
map.put("b", new Person("Xiao Hong"));
时,由于计算出的数组索引相同,后面放入的"Xiao Hong"
会不会把"Xiao Ming"
覆盖了?
当然不会!使用Map
的时候,只要key
不相同,它们映射的value
就互不干扰。但是,在HashMap
内部,确实可能存在不同的key
,映射到相同的hashCode()
,即相同的数组索引上,肿么办?
我们就假设"a"
和"b"
这两个key
最终计算出的索引都是5,那么,在HashMap
的数组中,实际存储的不是一个Person
实例,而是一个List
,它包含两个Entry
,一个是"a"
的映射,一个是"b"
的映射:
┌───┐
0 │ │
├───┤
1 │ │
├───┤
2 │ │
├───┤
3 │ │
├───┤
4 │ │
├───┤
5 │ ●─┼───> List>
├───┤
6 │ │
├───┤
7 │ │
└───┘
在查找的时候,例如:
Person p = map.get("a");
HashMap内部通过"a"
找到的实际上是List
,它还需要遍历这个List
,并找到一个Entry
,它的key
字段是"a"
,才能返回对应的Person
实例。
我们把不同的key
具有相同的hashCode()
的情况称之为哈希冲突。在冲突的时候,一种最简单的解决办法是用List
存储hashCode()
相同的key-value
。显然,如果冲突的概率越大,这个List
就越长,Map
的get()
方法效率就越低,这就是为什么要尽量满足条件二:
如果两个对象不相等,则两个对象的hashCode()尽量不要相等。
hashCode()
方法编写得越好,HashMap
工作的效率就越高。
因为HashMap
是一种通过对key计算hashCode()
,通过空间换时间的方式,直接定位到value所在的内部数组的索引,因此,查找效率非常高。
如果作为key的对象是enum
类型,那么,还可以使用Java集合库提供的一种EnumMap
,它在内部以一个非常紧凑的数组存储value,并且根据enum
类型的key直接定位到内部数组的索引,并不需要计算hashCode()
,不但效率最高,而且没有额外的空间浪费。
我们以DayOfWeek
这个枚举类型为例,为它做一个“翻译”功能:
public class Main {
public static void main(String[] args) {
Map<DayOfWeek, String> map = new EnumMap<>(DayOfWeek.class);
map.put(DayOfWeek.MONDAY, "星期一");
map.put(DayOfWeek.TUESDAY, "星期二");
map.put(DayOfWeek.WEDNESDAY, "星期三");
map.put(DayOfWeek.THURSDAY, "星期四");
map.put(DayOfWeek.FRIDAY, "星期五");
map.put(DayOfWeek.SATURDAY, "星期六");
map.put(DayOfWeek.SUNDAY, "星期日");
System.out.println(map);
System.out.println(map.get(DayOfWeek.MONDAY));
}
}
我们已经知道,HashMap
是一种以空间换时间的映射表,它的实现原理决定了内部的Key是无序的,即遍历HashMap
的Key时,其顺序是不可预测的(但每个Key都会遍历一次且仅遍历一次)。
还有一种Map
,它在内部会对Key进行排序,这种Map
就是SortedMap
。注意到SortedMap
是接口,它的实现类是TreeMap
。
┌───┐
│Map│
└───┘
▲
┌────┴─────┐
│ │
┌───────┐ ┌─────────┐
│HashMap│ │SortedMap│
└───────┘ └─────────┘
▲
│
┌─────────┐
│ TreeMap │
└─────────┘
SortedMap
保证遍历时以Key的顺序来进行排序。例如,放入的Key是"apple"
、"pear"
、"orange"
,遍历的顺序一定是"apple"
、"orange"
、"pear"
,因为String
默认按字母排序:
public class Main {
public static void main(String[] args) {
Map<String, Integer> map = new TreeMap<>();
map.put("orange", 1);
map.put("apple", 2);
map.put("pear", 3);
for (String key : map.keySet()) {
System.out.println(key);
}
// apple, orange, pear
}
}
**使用TreeMap
时,放入的Key必须实现Comparable
接口。String
、Integer
这些类已经实现了Comparable
接口,因此可以直接作为Key使用。**作为Value的对象则没有任何要求。
如果作为Key的class没有实现Comparable
接口,那么,必须在创建TreeMap
时同时指定一个自定义排序算法:
public class Main {
public static void main(String[] args) {
Map<Person, Integer> map = new TreeMap<>(new Comparator<Person>() {
public int compare(Person p1, Person p2) {
return p1.name.compareTo(p2.name);
}
});
map.put(new Person("Tom"), 1);
map.put(new Person("Bob"), 2);
map.put(new Person("Lily"), 3);
for (Person key : map.keySet()) {
System.out.println(key);
}
// {Person: Bob}, {Person: Lily}, {Person: Tom}
System.out.println(map.get(new Person("Bob"))); // 2
}
}
class Person {
public String name;
Person(String name) {
this.name = name;
}
public String toString() {
return "{Person: " + name + "}";
}
}
注意到Comparator
接口要求实现一个比较方法,它负责比较传入的两个元素a
和b
,如果a,则返回负数,通常是
-1
,如果a==b
,则返回0
,如果a>b
,则返回正数,通常是1
。TreeMap
内部根据比较结果对Key进行排序。
从上述代码执行结果可知,打印的Key确实是按照Comparator
定义的顺序排序的。如果要根据Key查找Value,我们可以传入一个new Person("Bob")
作为Key,它会返回对应的Integer
值2
。
另外,注意到Person
类并未覆写equals()
和hashCode()
,因为TreeMap
不使用equals()
和hashCode()
。
我们来看一个稍微复杂的例子:这次我们定义了Student
类,并用分数score
进行排序,高分在前:
public class Main {
public static void main(String[] args) {
Map<Student, Integer> map = new TreeMap<>(new Comparator<Student>() {
public int compare(Student p1, Student p2) {
return p1.score > p2.score ? -1 : 1;
}
});
map.put(new Student("Tom", 77), 1);
map.put(new Student("Bob", 66), 2);
map.put(new Student("Lily", 99), 3);
for (Student key : map.keySet()) {
System.out.println(key);
}
System.out.println(map.get(new Student("Bob", 66))); // null?
}
}
class Student {
public String name;
public int score;
Student(String name, int score) {
this.name = name;
this.score = score;
}
public String toString() {
return String.format("{%s: score=%d}", name, score);
}
}
在for
循环中,我们确实得到了正确的顺序。但是,且慢!根据相同的Key:new Student("Bob", 66)
进行查找时,结果为null
!
这是怎么肥四?难道TreeMap
有问题?遇到TreeMap
工作不正常时,我们首先回顾Java编程基本规则:出现问题,不要怀疑Java标准库,要从自身代码找原因。
在这个例子中,TreeMap
出现问题,原因其实出在这个Comparator
上:
public int compare(Student p1, Student p2) {
return p1.score > p2.score ? -1 : 1;
}
在p1.score
和p2.score
不相等的时候,它的返回值是正确的,但是,在p1.score
和p2.score
相等的时候,它并没有返回0
!这就是为什么TreeMap
工作不正常的原因:TreeMap
在比较两个Key是否相等时,依赖Key的compareTo()
方法或者Comparator.compare()
方法。在两个Key相等时,必须返回0
。因此,修改代码如下:
public int compare(Student p1, Student p2) {
if (p1.score == p2.score) {
return 0;
}
return p1.score > p2.score ? -1 : 1;
}
或者直接借助Integer.compare(int, int)
也可以返回正确的比较结果。
我们知道,Map
用于存储key-value的映射,对于充当key的对象,是不能重复的,并且,不但需要正确覆写equals()
方法,还要正确覆写hashCode()
方法。
如果我们只需要存储不重复的key,并不需要存储映射的value,那么就可以使用Set
。
Set
用于存储不重复的元素集合,它主要提供以下几个方法:
Set
:boolean add(E e)
Set
删除:boolean remove(Object e)
boolean contains(Object e)
我们来看几个简单的例子:
public class Main {
public static void main(String[] args) {
Set<String> set = new HashSet<>();
System.out.println(set.add("abc")); // true
System.out.println(set.add("xyz")); // true
System.out.println(set.add("xyz")); // false,添加失败,因为元素已存在
System.out.println(set.contains("xyz")); // true,元素存在
System.out.println(set.contains("XYZ")); // false,元素不存在
System.out.println(set.remove("hello")); // false,删除失败,因为元素不存在
System.out.println(set.size()); // 2,一共两个元素
}
}
Set
实际上相当于只存储key、不存储value的Map
。我们经常用Set
用于去除重复元素。
因为放入Set
的元素和Map
的key类似,都要正确实现equals()
和hashCode()
方法,否则该元素无法正确地放入Set
。
最常用的Set
实现类是HashSet
,实际上,HashSet
仅仅是对HashMap
的一个简单封装,它的核心代码如下:
public class HashSet implements Set {
// 持有一个HashMap:
private HashMap map = new HashMap<>();
// 放入HashMap的value:
private static final Object PRESENT = new Object();
public boolean add(E e) {
return map.put(e, PRESENT) == null;
}
public boolean contains(Object o) {
return map.containsKey(o);
}
public boolean remove(Object o) {
return map.remove(o) == PRESENT;
}
}
Set
接口并不保证有序,而SortedSet
接口则保证元素是有序的:
HashSet
是无序的,因为它实现了Set
接口,并没有实现SortedSet
接口;TreeSet
是有序的,因为它实现了SortedSet
接口。用一张图表示:
┌───┐
│Set│
└───┘
▲
┌────┴─────┐
│ │
┌───────┐ ┌─────────┐
│HashSet│ │SortedSet│
└───────┘ └─────────┘
▲
│
┌─────────┐
│ TreeSet │
└─────────┘
我们来看HashSet
的输出:
public class Main {
public static void main(String[] args) {
Set set = new HashSet<>();
set.add("apple");
set.add("banana");
set.add("pear");
set.add("orange");
for (String s : set) {
System.out.println(s);
}
}
}
注意输出的顺序既不是添加的顺序,也不是String
排序的顺序,在不同版本的JDK中,这个顺序也可能是不同的。
public class Main {
public static void main(String[] args) {
Set<String> set = new TreeSet<>();
set.add("apple");
set.add("banana");
set.add("pear");
set.add("orange");
for (String s : set) {
System.out.println(s);
}
}
}
使用TreeSet
和使用TreeMap
的要求一样,添加的元素必须正确实现Comparable
接口,如果没有实现Comparable
接口,那么创建TreeSet
时必须传入一个Comparator
对象。
队列(Queue
)是一种经常使用的集合。Queue
实际上是实现了一个先进先出(FIFO:First In First Out)的有序表。它和List
的区别在于,List
可以在任意位置添加和删除元素,而Queue
只有两个操作:
超市的收银台就是一个队列:
在Java的标准库中,队列接口Queue
定义了以下几个方法:
int size()
:获取队列长度;boolean add(E)
/boolean offer(E)
:添加元素到队尾;E remove()
/E poll()
:获取队首元素并从队列中删除;E element()
/E peek()
:获取队首元素但并不从队列中删除。对于具体的实现类,有的Queue有最大队列长度限制,有的Queue没有。注意到添加、删除和获取队列元素总是有两个方法,这是因为在添加或获取元素失败时,这两个方法的行为是不同的。我们用一个表格总结如下:
throw Exception | 返回false或null | |
---|---|---|
添加元素到队尾 | add(E e) | boolean offer(E e) |
取队首元素并删除 | E remove() | E poll() |
取队首元素但不删除 | E element() | E peek() |
举个栗子,假设我们有一个队列,对它做一个添加操作,如果调用add()
方法,当添加失败时(可能超过了队列的容量),它会抛出异常:
Queue q = ...
try {
q.add("Apple");
System.out.println("添加成功");
} catch(IllegalStateException e) {
System.out.println("添加失败");
}
如果我们调用offer()
方法来添加元素,当添加失败时,它不会抛异常,而是返回false
:
Queue q = ...
if (q.offer("Apple")) {
System.out.println("添加成功");
} else {
System.out.println("添加失败");
}
当我们需要从Queue
中取出队首元素时,如果当前Queue
是一个空队列,调用remove()
方法,它会抛出异常:
Queue q = ...
try {
String s = q.remove();
System.out.println("获取成功");
} catch(IllegalStateException e) {
System.out.println("获取失败");
}
如果我们调用poll()
方法来取出队首元素,当获取失败时,它不会抛异常,而是返回null
:
Queue q = ...
String s = q.poll();
if (s != null) {
System.out.println("获取成功");
} else {
System.out.println("获取失败");
}
因此,两套方法可以根据需要来选择使用。
注意:不要把null
添加到队列中,否则poll()
方法返回null
时,很难确定是取到了null
元素还是队列为空。
接下来我们以poll()
和peek()
为例来说说“获取并删除”与“获取但不删除”的区别。对于Queue
来说,每次调用poll()
,都会获取队首元素,并且获取到的元素已经从队列中被删除了:
public class Main {
public static void main(String[] args) {
Queue<String> q = new LinkedList<>();
// 添加3个元素到队列:
q.offer("apple");
q.offer("pear");
q.offer("banana");
// 从队列取出元素:
System.out.println(q.poll()); // apple
System.out.println(q.poll()); // pear
System.out.println(q.poll()); // banana
System.out.println(q.poll()); // null,因为队列是空的
}
}
如果用peek()
,因为获取队首元素时,并不会从队列中删除这个元素,所以可以反复获取:
public class Main {
public static void main(String[] args) {
Queue<String> q = new LinkedList<>();
// 添加3个元素到队列:
q.offer("apple");
q.offer("pear");
q.offer("banana");
// 队首永远都是apple,因为peek()不会删除它:
System.out.println(q.peek()); // apple
System.out.println(q.peek()); // apple
System.out.println(q.peek()); // apple
}
}
从上面的代码中,我们还可以发现,LinkedList
即实现了List
接口,又实现了Queue
接口,但是,在使用的时候,如果我们把它当作List,就获取List的引用,如果我们把它当作Queue,就获取Queue的引用:
// 这是一个List:
List<String> list = new LinkedList<>();
// 这是一个Queue:
Queue<String> queue = new LinkedList<>();
我们知道,Queue
是一个先进先出(FIFO)的队列。
在银行柜台办业务时,我们假设只有一个柜台在办理业务,但是办理业务的人很多,怎么办?
可以每个人先取一个号,例如:A1
、A2
、A3
……然后,按照号码顺序依次办理,实际上这就是一个Queue
。
如果这时来了一个VIP客户,他的号码是V1
,虽然当前排队的是A10
、A11
、A12
……但是柜台下一个呼叫的客户号码却是V1
。
这个时候,我们发现,要实现“VIP插队”的业务,用Queue
就不行了,因为Queue
会严格按FIFO的原则取出队首元素。我们需要的是优先队列:PriorityQueue
。
PriorityQueue
和Queue
的区别在于,它的出队顺序与元素的优先级有关,对PriorityQueue
调用remove()
或poll()
方法,返回的总是优先级最高的元素。
==要使用PriorityQueue
,我们就必须给每个元素定义“优先级”。==我们以实际代码为例,先看看PriorityQueue
的行为:
public class Main {
public static void main(String[] args) {
Queue<String> q = new PriorityQueue<>();
// 添加3个元素到队列:
q.offer("apple");
q.offer("pear");
q.offer("banana");
System.out.println(q.poll()); // apple
System.out.println(q.poll()); // banana
System.out.println(q.poll()); // pear
System.out.println(q.poll()); // null,因为队列为空
}
}
我们放入的顺序是"apple"
、"pear"
、"banana"
,但是取出的顺序却是"apple"
、"banana"
、"pear"
,这是因为从字符串的排序看,"apple"
排在最前面,"pear"
排在最后面。
因此,放入PriorityQueue
的元素,必须实现Comparable
接口,PriorityQueue
会根据元素的排序顺序决定出队的优先级。
如果我们要放入的元素并没有实现Comparable
接口怎么办?PriorityQueue
允许我们提供一个Comparator
对象来判断两个元素的顺序。我们以银行排队业务为例,实现一个PriorityQueue
:
public class Main {
public static void main(String[] args) {
Queue<User> q = new PriorityQueue<>(new UserComparator());
// 添加3个元素到队列:
q.offer(new User("Bob", "A1"));
q.offer(new User("Alice", "A2"));
q.offer(new User("Boss", "V1"));
System.out.println(q.poll()); // Boss/V1
System.out.println(q.poll()); // Bob/A1
System.out.println(q.poll()); // Alice/A2
System.out.println(q.poll()); // null,因为队列为空
}
}
class UserComparator implements Comparator<User> {
public int compare(User u1, User u2) {
if (u1.number.charAt(0) == u2.number.charAt(0)) {
// 如果两人的号都是A开头或者都是V开头,比较号的大小:
return u1.number.compareTo(u2.number);
}
if (u1.number.charAt(0) == 'V') {
// u1的号码是V开头,优先级高:
return -1;
} else {
return 1;
}
}
}
class User {
public final String name;
public final String number;
public User(String name, String number) {
this.name = name;
this.number = number;
}
public String toString() {
return name + "/" + number;
}
}
compareTo比较大小-CSDN博客
我们知道,Queue
是队列,只能一头进,另一头出。
如果把条件放松一下,允许两头都进,两头都出,这种队列叫双端队列(Double Ended Queue),学名Deque
。
Java集合提供了接口Deque
来实现一个双端队列,它的功能是:
我们来比较一下Queue
和Deque
出队和入队的方法:
Queue | Deque | |
---|---|---|
添加元素到队尾 | add(E e) / offer(E e) | addLast(E e) / offerLast(E e) |
取队首元素并删除 | E remove() / E poll() | E removeFirst() / E pollFirst() |
取队首元素但不删除 | E element() / E peek() | E getFirst() / E peekFirst() |
添加元素到队首 | 无 | addFirst(E e) / offerFirst(E e) |
取队尾元素并删除 | 无 | E removeLast() / E pollLast() |
取队尾元素但不删除 | 无 | E getLast() / E peekLast() |
对于添加元素到队尾的操作,Queue
提供了add()
/offer()
方法,而Deque
提供了addLast()
/offerLast()
方法。添加元素到队首、取队尾元素的操作在Queue
中不存在,在Deque
中由addFirst()
/removeLast()
等方法提供。
注意到Deque
接口实际上扩展自Queue
:
public interface Deque extends Queue {
...
}
因此,Queue
提供的add()
/offer()
方法在Deque
中也可以使用,但是,使用Deque
,最好不要调用offer()
,而是调用offerLast()
:
public class Main {
public static void main(String[] args) {
Deque<String> deque = new LinkedList<>();
deque.offerLast("A"); // A
deque.offerLast("B"); // A <- B
deque.offerFirst("C"); // C <- A <- B
System.out.println(deque.pollFirst()); // C, 剩下A <- B
System.out.println(deque.pollLast()); // B, 剩下A
System.out.println(deque.pollFirst()); // A
System.out.println(deque.pollFirst()); // null
}
}
如果直接写deque.offer()
,我们就需要思考,offer()
实际上是offerLast()
,我们明确地写上offerLast()
,不需要思考就能一眼看出这是添加到队尾。
因此,使用Deque
,推荐总是明确调用offerLast()
/offerFirst()
或者pollFirst()
/pollLast()
方法。
Deque
是一个接口,它的实现类有ArrayDeque
和LinkedList
。
我们发现LinkedList
真是一个全能选手,它即是List
,又是Queue
,还是Deque
。但是我们在使用的时候,总是用特定的接口来引用它,这是因为持有接口说明代码的抽象层次更高,而且接口本身定义的方法代表了特定的用途。
// 不推荐的写法:
LinkedList d1 = new LinkedList<>();
d1.offerLast("z");
// 推荐的写法:
Deque d2 = new LinkedList<>();
d2.offerLast("z");
可见面向抽象编程的一个原则就是:尽量持有接口,而不是具体的实现类。
栈(Stack)是一种后进先出(LIFO:Last In First Out)的数据结构。
什么是LIFO呢?我们先回顾一下Queue
的特点FIFO:
────────────────────────
(\(\ (\(\ (\(\ (\(\ (\(\
(='.') ─> (='.') (='.') (='.') ─> (='.')
O(_")") O(_")") O(_")") O(_")") O(_")")
────────────────────────
所谓FIFO,是最先进队列的元素一定最早出队列,而LIFO是最后进Stack
的元素一定最早出Stack
。如何做到这一点呢?只需要把队列的一端封死:
───────────────────────────────┐
(\(\ (\(\ (\(\ (\(\ (\(\ │
(='.') <─> (='.') (='.') (='.') (='.')│
O(_")") O(_")") O(_")") O(_")") O(_")")│
───────────────────────────────┘
因此,Stack
是这样一种数据结构:只能不断地往Stack
中压入(push)元素,最后进去的必须最早弹出(pop)来:
Stack
只有入栈和出栈的操作:
push(E)
;pop()
;peek()
。在Java中,我们用Deque
可以实现Stack
的功能:
push(E)
/addFirst(E)
;pop()
/removeFirst()
;peek()
/peekFirst()
。为什么Java的集合类没有单独的Stack
接口呢?因为有个遗留类名字就叫Stack
,出于兼容性考虑,所以没办法创建Stack
接口,只能用Deque
接口来“模拟”一个Stack
了。
当我们把Deque
作为Stack
使用时,注意只调用push()
/pop()
/peek()
方法,不要调用addFirst()
/removeFirst()
/peekFirst()
方法,这样代码更加清晰。
Stack在计算机中使用非常广泛,JVM在处理Java方法调用的时候就会通过栈这种数据结构维护方法调用的层次。例如:
static void main(String[] args) {
foo(123);
}
static String foo(x) {
return "F-" + bar(x + 1);
}
static int bar(int x) {
return x << 2;
}
JVM会创建方法调用栈,每调用一个方法时,先将参数压栈,然后执行对应的方法;当方法返回时,返回值压栈,调用方法通过出栈操作获得方法返回值。
因为方法调用栈有容量限制,嵌套调用过多会造成栈溢出,即引发StackOverflowError
:
public class Main {
public static void main(String[] args) {
increase(1);
}
static int increase(int x) {
return increase(x) + 1;
}
}
我们再来看一个Stack
的用途:对整数进行进制的转换就可以利用栈。
例如,我们要把一个int
整数12500
转换为十六进制表示的字符串,如何实现这个功能?
首先我们准备一个空栈:
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
└───┘
然后计算12500÷16=781…4,余数是4
,把余数4
压栈:
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ 4 │
└───┘
然后计算781÷16=48…13,余数是13
,13
的十六进制用字母D
表示,把余数D
压栈:
│ │
│ │
│ │
│ │
│ │
│ D │
│ │
│ 4 │
└───┘
然后计算48÷16=3…0,余数是0
,把余数0
压栈:
│ │
│ │
│ │
│ 0 │
│ │
│ D │
│ │
│ 4 │
└───┘
最后计算3÷16=0…3,余数是3
,把余数3
压栈:
│ │
│ 3 │
│ │
│ 0 │
│ │
│ D │
│ │
│ 4 │
└───┘
当商是0
的时候,计算结束,我们把栈的所有元素依次弹出,组成字符串30D4
,这就是十进制整数12500
的十六进制表示的字符串。
在编写程序的时候,我们使用的带括号的数学表达式实际上是中缀表达式,即运算符在中间,例如:1 + 2 * (9 - 5)
。
但是计算机执行表达式的时候,它并不能直接计算中缀表达式,而是通过编译器把中缀表达式转换为后缀表达式,例如:1 2 9 5 - * +
。
这个编译过程就会用到栈。我们先跳过编译这一步(涉及运算优先级,代码比较复杂),看看如何通过栈计算后缀表达式。
计算后缀表达式不考虑优先级,直接从左到右依次计算,因此计算起来简单。首先准备一个空的栈:
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
└───┘
然后我们依次扫描后缀表达式1 2 9 5 - * +
,遇到数字1
,就直接扔到栈里:
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ 1 │
└───┘
紧接着,遇到数字2
,9
,5
,也扔到栈里:
│ │
│ 5 │
│ │
│ 9 │
│ │
│ 2 │
│ │
│ 1 │
└───┘
接下来遇到减号时,弹出栈顶的两个元素,并计算9-5=4
,把结果4
压栈:
│ │
│ │
│ │
│ 4 │
│ │
│ 2 │
│ │
│ 1 │
└───┘
接下来遇到*
号时,弹出栈顶的两个元素,并计算2*4=8
,把结果8
压栈:
│ │
│ │
│ │
│ │
│ │
│ 8 │
│ │
│ 1 │
└───┘
接下来遇到+
号时,弹出栈顶的两个元素,并计算1+8=9
,把结果9
压栈:
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ 9 │
└───┘
扫描结束后,没有更多的计算了,弹出栈的唯一一个元素,得到计算结果9
。
ava的集合类都可以使用for each
循环,List
、Set
和Queue
会迭代每个元素,Map
会迭代每个key。以List
为例:
List list = List.of("Apple", "Orange", "Pear");
for (String s : list) {
System.out.println(s);
}
实际上,Java编译器并不知道如何遍历List
。上述代码能够编译通过,只是因为编译器把for each
循环通过Iterator
改写为了普通的for
循环:
for (Iterator it = list.iterator(); it.hasNext(); ) {
String s = it.next();
System.out.println(s);
}
我们把这种通过Iterator
对象遍历集合的模式称为迭代器。
使用迭代器的好处在于,调用方总是以统一的方式遍历各种集合类型,而不必关心它们内部的存储结构。
使用Collections - 廖雪峰的官方网站 (liaoxuefeng.com)
JAVA推出泛型以前,程序员可以构建一个元素类型为Object
的集合,该集合能够存储任意的数据类型对象,而在使用该集合的过程中,需要程序员明确知道存储每个元素的数据类型,否则很容易引发ClassCastException异常(类型转化异常),在对集合中的元素处理时极其不方便。
java泛型提供了编译时类型安全检测机制,该机制允许我们在编译时检测到非法的类型数据结构。泛型的本质就是参数化类型,也就是所操作的数据类型被指定为一个参数
好处:
class 类名称 <泛型标志,泛型标志,…>{
private 泛型标志 变量名;
…
}
常用的泛型标识:T,E,K,V
泛型标识–类型形参
T 创建对象的时候里指定具体的数据类型(是由外部使用类的时候来指定)
泛型类在创建对象的时候,来指定操作的具体数据类型。
泛型类在创建对象的时候,没有指定类型,将按照Object类型来操作。
泛型类不支持基本数据类型。
同一泛型类,根据不同的数据类型创建的对象,本质上都是这一泛型类的类型
public class ProductGetter<T>{
Random random = new Random();
//奖品
private T product;
//奖品池
ArrayList<T> list = new ArrayList<>();
//添加奖品
public void addProduct(T t){
list.add(t);
}
//抽奖
public T getProduct(){
product = list.get(random.nextInt(list.size()));
return product;
}
}
泛型类派生子类,子类也是泛型类,那么子类的泛型标识要和父类一致。
泛型类派生子类,如果子类不是泛型类,那么父类要明确数据类型
泛型方法的调用,类型是通过调用方法的时候来指定
类型通配符的下限
异常是程序出现了不正常的情况。
因为Java的异常是class,它的继承关系如下:
Throwable的父类是Object类
某些异常是应用程序逻辑处理的一部分,应该捕获并处理。例如:
还有一些异常是程序逻辑编写不对造成的,应该修复程序本身。例如:
如果程序出现了问题,我们没有做任何处理,最终JVM会做默认的处理
try{
可能出现问题的代码;
}catch(异常类名 变量名){
异常的处理代码;
}
程序从try里面的代码开始执行
出现异常,会自动生成一个异常类对象,该异常对象将被提价给Java运行时的系统
当Java运行时的系统接收到异常对象时,会到catch中去找匹配的异常类,找到后进行异常的处理
执行完毕后,程序还可以继续往下执行
public class ExceptionDemo01{
public static void main(String[] args){
System.out.println("开始");
method();
System.out.println("结束");
}
public static void methods(){
int []arr = {1,2,3};
System.out.println(arr[3]);
}
}
因为数组越界访问报错并且程序结束
public class ExceptionDemo01{
public static void main(String[] args){
System.out.println("开始");
method();
System.out.println("结束");
}
public static void methods(){
try{
int []arr = {1,2,3};
System.out.println(arr[3]); //new 了一个异常对象e
}catch(ArrayIndexOutOfBoundsException e){
System.out.println("你访问的数组的索引不存在");
}
}
}
可以使用多个catch语句,每个catch分别捕获对应的Exception及其子类。JVM在捕获到异常后,会从上到下匹配catch语句,匹配到某个catch后,执行catch代码块,然后不再继续匹配。
多个catch语句只有一个能被执行,子类必须写在前面,因为如果父类写在前面,父类是永远不会被捕获到的。
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");
}
}
无论是否有异常发生,如果我们都希望执行一些语句。
可以把执行语句写若干遍:正常执行的放到try
中,每个catch
再写一遍。
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");
}
}
等价于
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保证一些代码必须执行且最后执行
如果某些异常的处理逻辑相同,但是异常本身不存在继承关系,那么就得编写多条catch
子句:
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");
}
}
因为处理IOException
和NumberFormatException
的代码是相同的,所以我们可以把它两用|
合并到一起:
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");
}
}
public String getMessage() 异常的原因
public String toString() 异常的类名,原因
public void printStackTrace 异常的类名,原因,位置
public class ExceptionDemo01{
public static void main(String[] args){
System.out.println("开始");
method();
System.out.println("结束");
}
public static void methods(){
try{
int []arr = {1,2,3};
System.out.println(arr[3]); //new 了一个异常对象e
}catch(ArrayIndexOutOfBoundsException e){
System.out.println("e.getMessage");
System.out.println("e.toString");
e.printStackTrace;
}
}
}
Java中的异常被分为两大类:编译时异常和运行时异常,也被称为受检异常和非受检异常
所有的RuntimeException类及其子类被称为运行时异常,其他的类都是编译时异常
运行时异常:
public class ExceptionDemo03{
public static void main(String args){
method();
}
public static void method(){
int []arr = {1,2,3};
System.out.println(arr[3]);
}
}
编译时异常:
public class ExceptionDemo03{
public static void main(String args){
method();
}
public static void method(){
try{
String s = "2048-08-09"
SimpleDateFormat sdf = new SimleDateFormat("yyyy-MM-dd");
Date d = sdf.parse(s);
System.out.println(d);
}catch(ParseException e){
e.printStackTrace;
}
}
}
虽然我们通过try…catch…可以对异常进行处理,但是并不是所有的情况我们都有权限进行异常的处理。
针对这种情况,Java提供了throws处理方案
在方法定义的时候,使用throws Xxx
表示该方法可能抛出的异常类型。调用方在调用的时候,必须强制捕获这些异常,否则编译器会报错。
格式:
throws 异常类名;
public class ExceptionDemo03{
public static void main(String args){
method();
}
public static void method() throws ArrayIndexOutOfBoudsException{
int []arr = {1,2,3};
System.out.println(arr[3]);
}
}
void process2(String s) {
if (s==null) {
throw new NullPointerException();
}
}
throws:
用在方法声明后面,跟的是异常类名
表示抛出异常,由该方法的调用者来处理
表示出现异常的一种可能性,并不一定会发生这些异常
throw:
Java标准库定义的常用异常包括:
Exception
│
├─ RuntimeException
│ │
│ ├─ NullPointerException
│ │
│ ├─ IndexOutOfBoundsException
│ │
│ ├─ SecurityException
│ │
│ └─ IllegalArgumentException
│ │
│ └─ NumberFormatException
│
├─ IOException
│ │
│ ├─ UnsupportedCharsetException
│ │
│ ├─ FileNotFoundException
│ │
│ └─ SocketException
│
├─ ParseException
│
├─ GeneralSecurityException
│
├─ SQLException
│
└─ TimeoutException
当我们在代码中需要抛出异常时,尽量使用JDK已定义的异常类型。例如,参数检查不合法,应该抛出IllegalArgumentException
:
static void process1(int age) {
if (age <= 0) {
throw new IllegalArgumentException();
}
}
在一个大型项目中,可以自定义新的异常类型,但是,保持一个合理的异常继承体系是非常重要的。
一个常见的做法是自定义一个BaseException
作为“根异常”,然后,派生出各种业务类型的异常。
BaseException
需要从一个适合的Exception
派生,通常建议从RuntimeException
派生:
public class BaseException extends RuntimeException {
}
其他业务类型的异常就可以从BaseException
派生:
public class UserNotFoundException extends BaseException {
}
public class LoginFailedException extends BaseException {
}
...
自定义的BaseException
应该提供多个构造方法:
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可以根据父类快速生成子类的构造方法。
在所有的RuntimeException
异常中,Java程序员最熟悉的恐怕就是NullPointerException
了。
NullPointerException
即空指针异常,俗称NPE。如果一个对象为null
,调用其方法或访问其字段就会产生NullPointerException
,这个异常通常是由JVM抛出的,例如:
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
,遵循原则是早暴露,早修复,严禁使用catch
来隐藏这种编码错误:
// 错误示例: 捕获NullPointerException
try {
transferMoney(from, to, amount);
} catch (NullPointerException e) {
}
好的编码习惯可以极大地降低NullPointerException
的产生,例如:
成员变量在定义时初始化:
public class Person {
private String name = "";
}
使用空字符串""
而不是默认的null
可避免很多NullPointerException
,编写业务逻辑时,用空字符串""
表示未填写比null
安全得多。
返回空字符串""
、空数组而不是null
:
public String[] readLinesFromFile(String file) {
if (getFileSize(file) == 0) {
// 返回空数组而不是null:
return new String[0];
}
...
}
这样可以使得调用方无需检查结果是否为null
。
如果调用方一定要根据null
判断,比如返回null
表示文件不存在,那么考虑返回Optional
:
public Optional readFromFile(String file) {
if (!fileExist(file)) {
return Optional.empty();
}
...
}
这样调用方必须通过Optional.isPresent()
判断是否有结果。
前面介绍了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中,我们要打印日志,有时候得这么写:
int score = 99;
p.setScore(score);
log.info("Set score " + score + " for Person " + p.getName() + " ok.");
拼字符串是一个非常麻烦的事情,所以SLF4J的日志接口改进成这样了:
int score = 99;
p.setScore(score);
logger.info("Set score {} for Person {} ok.", score, p.getName());
我们靠猜也能猜出来,SLF4J的日志接口传入的是一个带占位符的字符串,用后面的变量自动替换占位符,所以看起来更加自然。
如何使用SLF4J?它的接口实际上和Commons Logging几乎一模一样:
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变成了Logger,LogFactory变成了LoggerFactory。
使用SLF4J和Logback和前面讲到的使用Commons Logging加Log4j是类似的,先分别下载SLF4J和Logback,然后把以下jar包放到classpath下:
然后使用SLF4J的Logger和LoggerFactory即可。和Log4j类似,我们仍然需要一个Logback的配置文件,把logback.xml
放到classpath下,配置如下:
%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n
%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n
utf-8
log/output.log
log/output.log.%i
1MB
暂时认为配置文件可有可无,参考下面博客配置
Sl4J的使用_Sharry洗手溢的博客-CSDN博客
以上4个类都是抽象类,Java的IO流所涉及的40多个类都是从以上四个抽象类派生出来的。
对流的理解
InputStream抽象类是所有类字节输入流的超类
常用的子类
最重要的方法就是int read()
,如下:
public abstract int read() throws IOException;
通过close()
方法来关闭流。关闭流就会释放对应的底层资源。
我们需要用try ... finally
来保证InputStream
在无论是否发生IO错误的时候都能够正确地关闭:
这个方法会读取输入流的下一个字节,并返回字节表示的int值(0~255)。如果已读到末尾,返回-1表示不能继续读取了。
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
public class fileInputStream {
public static void main(String[] args) {
readFile1();
}
public static void readFile1(){
String filePath = "C:\\Users\\DELL\\Desktop\\新建 文本文档.txt";
int readDate = 0;
FileInputStream fileInputStream = null;
try {
//创建FileInputStream对象,用于读取文件
fileInputStream = new FileInputStream(filePath);
//从该输入流依次读取一个字节的数据。如果没有输入可用,此方法将停止
//如果返回1,读取完毕
while((readDate = fileInputStream.read()) !=-1){
System.out.print((char)readDate); //转成catch显示
}
} catch (FileNotFoundException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
} finally {
//关闭文件流,释放资源
try {
fileInputStream.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
在读取流的时候,一次读取一个字节并不是最高效的方法。很多流支持一次性读取多个字节到缓冲区,对于文件和网络流来说,利用缓冲区一次性读取多个字节效率往往要高很多。InputStream
提供了两个重载方法来支持读取多个字节:
int read(byte[] b)
:读取若干字节并填充到byte[]
数组,返回读取的字节数int read(byte[] b, int off, int len)
:指定byte[]
数组的偏移量和最大填充数利用上述方法一次读取多个字节时,需要先定义一个byte[]
数组作为缓冲区,read()
方法会尽可能多地读取字节到缓冲区, 但不会超过缓冲区的大小。read()
方法的返回值不再是字节的int
值,而是返回实际读取了多少个字节。如果返回-1
,表示没有更多的数据了。
public void readFile2(){
String filePath = "C:\\Users\\DELL\\Desktop\\新建 文本文档.txt";
int readDate = 0;
//字节数组
byte[] buf = new byte[8];
FileInputStream fileInputStream = null;
try {
fileInputStream = new FileInputStream(filePath);
//从该输入流读取最多b.length字节的数据到字节数组。此方法将阻塞,直到某些输入可用
//如果返回-1,表示读取完毕
//读过读取正常,返回实际读取的字节数
while ((readDate = fileInputStream.read(buf)) !=-1){
System.out.print(new String(buf,0,readDate));
}
} catch (FileNotFoundException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}finally {
try {
fileInputStream.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
public static void writeFile(){
//创建FileOutputStream对象
String filePath = "e:\\a.txt"; //如果没有这个文件会自动创建
FileOutputStream fileOutputStream = null;
//得到 FileOutputStream对象
//1.new FileOutputStream(filePath) 创建方式,当写入内容式,会覆盖原来的内容
//2.new FileOutputStream(filePath,true)创建方式,当写入内容时,内容追加到原来文件后边
try {
fileOutputStream = new FileOutputStream(filePath,true);
//写入一个字节的内容
//fileOutStteam.write('H');
//写入字符串
String str = "hello world";
//str.getBytes() 可以把 字符串->字节数组
//fileOutputStream.write(str.getBytes());
//write(byte[] b,int off,int len)将len字节从位于偏移量off的指定字节数组写入此文件输出流
fileOutputStream.write(str.getBytes(),0,3);
} catch (FileNotFoundException e) {
e.printStackTrace();
} catch (IOException e) {
throw new RuntimeException(e);
} finally {
try {
fileOutputStream.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
public static void copy(){
//完成文件拷贝,将e:\\Koala.jpg拷贝到c:\\中
//步骤:1.创建文件的输入流,将文件读入到程序
//2.创建文件的输出流。将读取到的文件数据,写入到指定的文件
String srcFilePath = "e:\\Koala.jpg";
String destFilePath = "c:\\koala.jpg";
FileInputStream fileInputStream = null;
FileOutputStream fileOutputStream = null;
try {
fileInputStream = new FileInputStream(srcFilePath);
fileOutputStream = new FileOutputStream(destFilePath,true);
//定义一个字节数组,提高读取效果
byte[] buf = new byte [256];
int readLen = 0;
while((readLen = fileInputStream.read(buf)) != -1){
//读取到后,就写入到文件,一边读,一边写
fileOutputStream.write(buf,0,readLen);
//防止最后一次读取时未读满字符数组
}
System.out.println("拷贝成功");
} catch (IOException e) {
e.printStackTrace();
}finally {
try {
if(fileInputStream != null){
fileInputStream.close();
}
if(fileOutputStream != null){
fileInputStream .close();
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
相关:
public static void FileRead1(){
String filePath = "e:\\s.txt";
FileReader fileReader = null;
int date;
try {
fileReader = new FileReader(filePath);
//循环读取,使用read,单个字符读取
while ((date = fileReader.read()) != -1){
System.out.print((char)date);
}
} catch (Exception e) {
e.printStackTrace();
} finally {
if(fileReader != null){
try {
fileReader.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
public static void FileRead2(){
String filePath = "e:\\s.txt";
int readLen = 0;
FileReader fileReader = null;
char []arr = new char[128];
try {
fileReader = new FileReader(filePath);
//循环读取,使用read(arr),返回的是实际读取到的字符数
//如果返回-1,说明文件结束
while((readLen = fileReader.read(arr)) != -1){
System.out.print(new String(arr,0,readLen));
}
} catch (IOException e) {
e.printStackTrace();
} finally {
if (fileReader != null){
try {
fileReader.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
new FileWrite(File/String):覆盖模式,相当于流的指针在首端。
new FileWrite(File/String,true):追加模式,相当于流的指针在尾端
write(int):写入单个字符
write(char[]):写入指定数组
write(char[],off,len)写入指定数组的指定部分
write(String):写入整个字符串
write(string,off,len):写入字符串的指定部分
String类:toCharArray:将String转换成char[]
对应的FileWriter,一定要关闭流,或者flush才能真正的吧数据写入到文件中
public static void FileWrite1(){
String filePath = "e:\\s.txt";
FileWriter fileWriter = null;
char []arr = {'a','b','c'};
try {
fileWriter = new FileWriter(filePath);
//write(int):写入单个字符
fileWriter.write('H');
//write(char[]):写入指定数组
fileWriter.write(arr);
//write(char[],off,len)写入指定数组的指定部分
fileWriter.write("韩顺平教育".toCharArray(),0,3);
//write(String):写入整个字符串
fileWriter.write("hello world");
//write(string,off,len):写入字符串的指定部分
fileWriter.write("上海天津",0,2);
} catch (IOException e) {
e.printStackTrace();
} finally {
try {
fileWriter.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
数据源:存放数据的地方
节点流和输出流一览图
区别和联系
BufferedReader和BufferedWriter属于字符流,是按照字符来读取数据的。
关闭时,只需要关闭外层流(处理流)即可。(在关闭处理流式,底层会自动关闭它封装的节点流)
BufferedReader类中,有属性Reader,即可以封装一个节点流(Reader的子类)
public static void BufferedReader1(){
String filePath = "e:\\s.txt";
BufferedReader bufferedReader = null;
try {
bufferedReader = new BufferedReader(new FileReader(filePath));
//读取
String line; //按行读取,效率高
//说明
//bufferedReader.readLine是按行读取文件
//正常读取返回String,返回null表示文件读取完毕
while((line = bufferedReader.readLine()) != null){
System.out.println(line);
}
} catch (IOException e) {
e.printStackTrace();
}finally {
try {
//关闭流,这里注意,只需要关闭BufferedReader,因为底层会自动关闭节点流
bufferedReader.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
BufferedWriter类中,有属性Writer,即可以封装一个节点流(Writer的子类)
public static void BufferedWriter1(){
String filePath = "e:\\s.txt";
BufferedWriter bufferedWriter = null;
try {
bufferedWriter = new BufferedWriter(new FileWriter(filePath,true));
bufferedWriter.write("hello world");
bufferedWriter.newLine(); //插入一个和系统相关的换行
bufferedWriter.write("hello china");
} catch (IOException e) {
e.printStackTrace();
} finally {
try {
bufferedWriter.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
文件转存
public static void readFile(){
String srcFilePath = "e:\\s.txt";
String destFilePath = "e:\\s1.txt";
BufferedReader bufferedReader = null;
BufferedWriter bufferedWriter = null;
String line;
try {
bufferedReader = new BufferedReader(new FileReader(srcFilePath));
bufferedWriter = new BufferedWriter(new FileWriter(destFilePath,true));
while ((line = bufferedReader.readLine()) != null){
bufferedWriter.write(line);
bufferedWriter.newLine();
}
} catch (IOException e) {
e.printStackTrace();
} finally {
if (bufferedReader != null){
try {
bufferedReader.close();
} catch (IOException e) {
e.printStackTrace();
}
}
if (bufferedWriter != null){
try {
bufferedWriter.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
BufferedInputStream类中,有属性InputStream,即可以封装一个节点流(InputStream的子类)
BufferedInputStream是字节流,在创建BufferedInputStream时,会创建一个内部缓冲区数组
BufferedOutputStream类中,有属性OutputStream,即可以封装一个节点流(OutputStream的子类)
BufferedOutputStream是字节流,实现缓冲的输出流,可以将多个字节写入底层输出流中,而不必对每个字节写入调用底层系统
public static void Bufferedcopy(){
String srcFilePath ="C:\\Users\\DELL\\Pictures\\Screenshots\\屏幕截图_20221205_173725.png" ;
String destFilePath = "e:\\s.png";
BufferedInputStream bufferedInputStream = null;
BufferedOutputStream bufferedOutputStream = null;
try {
bufferedInputStream = new BufferedInputStream(new FileInputStream(srcFilePath));
bufferedOutputStream = new BufferedOutputStream(new FileOutputStream(destFilePath));
//循环读取文件,并写入
byte[] buff = new byte[1024];
int readLen = 0;
while ((readLen = bufferedInputStream.read(buff)) != -1){
bufferedOutputStream.write(buff,0,readLen);
}
} catch (IOException e) {
e.printStackTrace();
} finally {
//关闭流,关闭外层的处理流即可,底层会去关闭节点流
try {
if (bufferedInputStream != null){
bufferedInputStream.close();
}
if (bufferedOutputStream != null){
bufferedOutputStream.close();
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
对象流 ObjectInputStream和ObjectOutputStream
看一个需求
序列化和反序列化
序列化就是在保存数据时,保存数据的值和数据类型
反序列化就是在恢复数据时,恢复数据的值和数据类型
需要让某个对象支持序列化序列化机制,则必须让其类是可序列化的,为了让某个类是可序列化的,该类必须实现如下两个接口之一:
Serializable //这是一个标记接口(里面没有任何方法),使用这个
Externalizable //该接口有方法实现,一般使用上面的方法
public static void ObjectOutStream(){
//序列化后,保存的文件格式,不是存文本,而是按照它的格式来保存
String filePath = "e:\\data.txt";
ObjectOutputStream objectOutputStream = null;
try {
objectOutputStream = new ObjectOutputStream(new FileOutputStream(filePath));
//序列化数据到文件中
objectOutputStream.writeInt(100); //int -> Integer(实现了Serializable)
objectOutputStream.writeBoolean(true); //boolean -> Boolean(实现了Serializable)
objectOutputStream.writeChar('a'); //char -> Character(实现了Serializable)
objectOutputStream.writeDouble(9.5); //double -> Double(实现了Serializable)
objectOutputStream.writeUTF("韩顺平教育"); //String
objectOutputStream.writeObject(new Dog("旺柴",10));
} catch (IOException e) {
e.printStackTrace();
} finally {
try {
objectOutputStream.close();
System.out.println("数据保存完毕(序列化格式)");
} catch (IOException e) {
e.printStackTrace();
}
}
}
public static class Dog implements Serializable{
private String name;
private int age;
public Dog(String name, int age) {
this.name = name;
this.age = age;
}
@Override
public String toString() {
return "Dog{" +
"name='" + name + '\'' +
", 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;
}
}
public static void ObjectInputStream(){
//指定反序列化的文件
String filePath = "e:\\data.txt";
ObjectInputStream objectInputStream = null;
try {
objectInputStream = new ObjectInputStream(new FileInputStream(filePath));
//读取
//读取(反序列化)的顺序需要和你保存数据(序列化)的顺序一致
//否则会出现异常
System.out.println(objectInputStream.readInt());
System.out.println(objectInputStream.readBoolean());
System.out.println(objectInputStream.readChar());
System.out.println(objectInputStream.readDouble());
System.out.println(objectInputStream.readUTF());
Object dog = objectInputStream.readObject();
System.out.println(dog);
//dog的编译类型为object,运行类型为Dog
//如果我们需要调用Dog的方法,需要向下转型
//需要我们将Dog类的定义,拷贝到能够引用的地方
afa.Dog dog2 = (afa.Dog)dog;
System.out.println(dog2.getName());
} catch (IOException e) {
e.printStackTrace();
} catch (ClassNotFoundException e) {
e.printStackTrace();
} finally {
try {
if (objectInputStream != null) {
objectInputStream.close();
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
读写顺序要一致
要求实现序列化或反序列化对象,需要实现Serializable
序列化的类中建议添加SerialVersionUID,为了提高版本的兼容性(了解就行)
序列化对象时,默认将里面所有的类型都进行序列化,但除了static或transient修饰的成员(这些属性不会被保存)
序列化对象时,要求里面属性的类型也需要实现序列化接口,(Dog类中有属性Master(主人),则Mastrer类应该实现序列化接口)
序列化具备可继承性,也就是如果某类已经实现了序列化,则它的所有子类也已经默认实现了序列化。67
System.in是System类的public final static InputStream in = null
System.in 编译类型 InputStream
System.in 运行类型 BufferedInputStream
表示标准输入 键盘
System.out是Stream类的public final static PrintStream out = null;
编译类型 PrintfStream
运行类型 PrintfStream
表示标准输出 显示器
默认情况下,读取文件是按照utf-8编码
文件编码如果是其他编码,就会出现乱码
Charset指的是编码
应用案例
Charset指的是编码
应用案例
PrintStream和PrintWriter
打印流只有输出流,没有输入流
现代操作系统(Windows,macOS,Linux)都可以执行多任务。多任务就是同时运行多个任务,例如:
CPU执行代码都是一条一条顺序执行的,但是,即使是单核cpu,也可以同时运行多个任务。因为操作系统执行多任务实际上就是让CPU对多个任务轮流交替执行。
例如,假设我们有语文、数学、英语3门作业要做,每个作业需要30分钟。我们把这3门作业看成是3个任务,可以做1分钟语文作业,再做1分钟数学作业,再做1分钟英语作业:
这样轮流做下去,在某些人眼里看来,做作业的速度就非常快,看上去就像同时在做3门作业一样
类似的,操作系统轮流让多个任务交替执行,例如,让浏览器执行0.001秒,让QQ执行0.001秒,再让音乐播放器执行0.001秒,在人看来,CPU就是在同时执行多个任务。
即使是多核CPU,因为通常任务的数量远远多于CPU的核数,所以任务也是交替执行的。
在计算机中,我们把一个任务称为一个进程,浏览器就是一个进程,视频播放器是另一个进程,类似的,音乐播放器和Word都是进程。
某些进程内部还需要同时执行多个子任务。例如,我们在使用Word时,Word可以让我们一边打字,一边进行拼写检查,同时还可以在后台进行打印,我们把子任务称为线程。
进程和线程的关系就是:一个进程可以包含一个或多个线程,但至少会有一个线程。
┌──────────┐
│Process │
│┌────────┐│
┌──────────┐││ Thread ││┌──────────┐
│Process ││└────────┘││Process │
│┌────────┐││┌────────┐││┌────────┐│
┌──────────┐││ Thread ││││ Thread ││││ Thread ││
│Process ││└────────┘││└────────┘││└────────┘│
│┌────────┐││┌────────┐││┌────────┐││┌────────┐│
││ Thread ││││ Thread ││││ Thread ││││ Thread ││
│└────────┘││└────────┘││└────────┘││└────────┘│
└──────────┘└──────────┘└──────────┘└──────────┘
┌──────────────────────────────────────────────┐
│ Operating System │
└──────────────────────────────────────────────┘
操作系统调度的最小任务单位其实不是进程,而是线程。常用的Windows、Linux等操作系统都采用抢占式多任务,如何调度线程完全由操作系统决定,程序自己不能决定什么时候执行,以及执行多长时间。
因为同一个应用程序,既可以有多个进程,也可以有多个线程,因此,实现多任务的方法,有以下几种:
多进程模式(每个进程只有一个线程):
┌──────────┐ ┌──────────┐ ┌──────────┐
│Process │ │Process │ │Process │
│┌────────┐│ │┌────────┐│ │┌────────┐│
││ Thread ││ ││ Thread ││ ││ Thread ││
│└────────┘│ │└────────┘│ │└────────┘│
└──────────┘ └──────────┘ └──────────┘
多线程模式(一个进程有多个线程):
┌────────────────────┐
│Process │
│┌────────┐┌────────┐│
││ Thread ││ Thread ││
│└────────┘└────────┘│
│┌────────┐┌────────┐│
││ Thread ││ Thread ││
│└────────┘└────────┘│
└────────────────────┘
多进程+多线程模式(复杂度最高):
┌──────────┐┌──────────┐┌──────────┐
│Process ││Process ││Process │
│┌────────┐││┌────────┐││┌────────┐│
││ Thread ││││ Thread ││││ Thread ││
│└────────┘││└────────┘││└────────┘│
│┌────────┐││┌────────┐││┌────────┐│
││ Thread ││││ Thread ││││ Thread ││
│└────────┘││└────────┘││└────────┘│
└──────────┘└──────────┘└──────────┘
进程和线程是包含关系,但是多任务既可以由多进程实现,也可以由单进程内的多线程实现,还可以混合多进程+多线程。
具体采用哪种方式,要考虑到进程和线程的特点。
和多线程相比,多进程的缺点在于:
而多进程的优点在于:
多进程稳定性比多线程高,因为在多进程的情况下,一个进程崩溃不会影响其他进程,而在多线程的情况下,任何一个线程崩溃会直接导致整个进程崩溃。
Java语言内置了多线程支持:一个Java程序实际上是一个JVM进程,JVM进程用一个主线程来执行main()
方法,在main()
方法内部,我们又可以启动多个线程。此外,JVM还有负责垃圾回收的其他工作线程等。
因此,对于大多数Java程序来说,我们说多任务,实际上是说如何使用多线程实现多任务。
和单线程相比,多线程编程的特点在于:多线程经常需要读写共享数据,并且需要同步。例如,播放电影时,就必须由一个线程播放视频,另一个线程播放音频,两个线程需要协调运行,否则画面和声音就不同步。因此,多线程编程的复杂度高,调试更困难。
Java多线程编程的特点又在于:
因此,必须掌握Java多线程编程才能继续深入学习其他内容。
Java语言内置了多线程支持。当Java程序启动的时候,实际上是启动了一个JVM进程,然后,JVM启动主线程来执行main()
方法。在main()
方法中,我们又可以启动其他线程。
要**创建一个新线程非常容易,我们需要实例化一个Thread
实例,然后调用它的start()
方法**:
public class Main {
public static void main(String[] args) {
Thread t = new Thread();
t.start(); // 启动新线程
}
}
但是这个线程启动后实际上什么也不做就立刻结束了。我们希望新线程能执行指定的代码,有以下几种方法:
方法一:从Thread
派生一个自定义类,然后覆写run()
方法:
public class Main {
public static void main(String[] args) {
Thread t = new MyThread();
t.start(); // 启动新线程
}
}
class MyThread extends Thread {
@Override
public void run() {
System.out.println("start new thread!");
}
}
执行上述代码,注意到**start()
方法会在内部自动调用实例的run()
方法。**
方法二:创建Thread
实例时,传入一个Runnable
实例:
public class Main {
public static void main(String[] args) {
Thread t = new Thread(new MyRunnable());
t.start(); // 启动新线程
}
}
class MyRunnable implements Runnable {
@Override
public void run() {
System.out.println("start new thread!");
}
}
或者用Java8引入的lambda语法进一步简写为:
public class Main {
public static void main(String[] args) {
Thread t = new Thread(() -> {
System.out.println("start new thread!");
});
t.start(); // 启动新线程
}
}
使用线程执行的打印语句,和直接在main()
方法执行有区别吗?
区别大了去了。我们看以下代码:
我们用蓝色表示主线程,也就是main
线程,main
线程执行的代码有4行,首先打印main start
,然后创建Thread
对象,紧接着调用start()
启动新线程。当start()
方法被调用时,JVM就创建了一个新线程,我们通过实例变量t
来表示这个新线程对象,并开始执行。
接着,main
线程继续执行打印main end
语句,而t
线程在main
线程执行的同时会并发执行,打印thread run
和thread end
语句。
当run()
方法结束时,新线程就结束了。而main()
方法结束时,主线程也结束了。
我们再来看线程的执行顺序:
main
线程肯定是先打印main start
,再打印main end
;t
线程肯定是先打印thread run
,再打印thread end
。但是,除了可以肯定,main start
会先打印外,main end
打印在thread run
之前、thread end
之后或者之间,都无法确定。因为从t
线程开始运行以后,两个线程就开始同时运行了,并且由操作系统调度,程序本身无法确定线程的调度顺序。
要模拟并发执行的效果,我们可以在线程中调用Thread.sleep()
,强迫当前线程暂停一段时间:
public class Main {
public static void main(String[] args) {
System.out.println("main start...");
Thread t = new Thread() {
public void run() {
System.out.println("thread run...");
try {
Thread.sleep(10); //t线程sleep10ms,main线程sleep时间到,此时只有main一个线程,执行main end...
} catch (InterruptedException e) {}
System.out.println("thread end."); //t线程苏醒,执行thread end。
}
};
t.start();
try {
Thread.sleep(20); //main线程sleep20ms,此时只有t一个线程,执行t的run方法 thread rum...
} catch (InterruptedException e) {}
System.out.println("main end...");
}
}
sleep()
传入的参数是毫秒。调整暂停时间的大小,我们可以看到main
线程和t
线程执行的先后顺序。
要特别注意:直接调用Thread
实例的run()
方法是无效的:
public class Main {
public static void main(String[] args) {
Thread t = new MyThread();
t.run();
}
}
class MyThread extends Thread {
public void run() {
System.out.println("hello");
}
}
直接调用run()
方法,相当于调用了一个普通的Java方法,当前线程并没有任何改变,也不会启动新线程。上述代码实际上是在main()
方法内部又调用了run()
方法,打印hello
语句是在main
线程中执行的,没有任何新线程被创建。
必须调用Thread
实例的start()
方法才能启动新线程,如果我们查看Thread
类的源代码,会看到start()
方法内部调用了一个private native void start0()
方法,native
修饰符表示这个方法是由JVM虚拟机内部的C代码实现的,不是由Java代码实现的。
可以对线程设定优先级,设定优先级的方法是:
Thread.setPriority(int n) // 1~10, 默认值5
JVM自动把1(低)~10(高)的优先级映射到操作系统实际优先级上(不同操作系统有不同的优先级数量)。优先级高的线程被操作系统调度的优先级较高,操作系统对高优先级线程可能调度更频繁,但我们决不能通过设置优先级来确保高优先级的线程一定会先执行。
**在Java程序中,一个线程对象只能调用一次start()
方法启动新线程,并在新线程中执行run()
方法。一旦run()
方法执行完毕,线程就结束了。**因此,Java线程的状态有以下几种:
run()
方法的Java代码;sleep()
方法正在计时等待;run()
方法执行完毕。用一个状态转移图表示如下:
┌─────────────┐
│ New │
└─────────────┘
│
▼
┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐
┌─────────────┐ ┌─────────────┐
││ Runnable │ │ Blocked ││
└─────────────┘ └─────────────┘
│┌─────────────┐ ┌─────────────┐│
│ Waiting │ │Timed Waiting│
│└─────────────┘ └─────────────┘│
─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
│
▼
┌─────────────┐
│ Terminated │
└─────────────┘
当线程启动后,它可以在Runnable
、Blocked
、Waiting
和Timed Waiting
这几个状态之间切换,直到最后变成Terminated
状态,线程终止。
线程终止的原因有:
run()
方法执行到return
语句返回;run()
方法因为未捕获的异常导致线程终止;Thread
实例调用stop()
方法强制终止(强烈不推荐使用)。一个线程还可以等待另一个线程直到其运行结束。例如,main
线程在启动t
线程后,可以通过t.join()
等待t
线程结束后再继续运行:
public class Main {
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(() -> {
System.out.println("hello");
});
System.out.println("start");
t.start();
t.join();
System.out.println("end");
}
}
当main
线程对线程对象t
调用join()
方法时,主线程将等待变量t
表示的线程运行结束,即join
就是指等待该线程结束,然后才继续往下执行自身线程。所以,上述代码打印顺序可以肯定是main
线程先打印start
,t
线程再打印hello
,main
线程最后再打印end
。
如果t
线程已经结束,对实例t
调用join()
会立刻返回。此外,join(long)
的重载方法也可以指定一个等待时间,超过等待时间后就不再继续等待。
如果线程需要执行一个长时间任务,就可能需要能中断线程。中断线程就是其他线程给该线程发一个信号,该线程收到信号后结束执行run()
方法,使得自身线程能立刻结束运行。
我们举个栗子:假设从网络下载一个100M的文件,如果网速很慢,用户等得不耐烦,就可能在下载过程中点“取消”,这时,程序就需要中断下载线程的执行。
中断一个线程非常简单,只需要在其他线程中对目标线程调用interrupt()
方法,目标线程需要反复检测自身状态是否是interrupted状态,如果是,就立刻结束运行。
我们还是看示例代码:
public class Main {
public static void main(String[] args) throws InterruptedException {
Thread t = new MyThread();
t.start();
Thread.sleep(1); // 暂停1毫秒
t.interrupt(); // 中断t线程
t.join(); // 等待t线程结束
System.out.println("end");
}
}
class MyThread extends Thread {
public void run() {
int n = 0;
while (! isInterrupted()) {
n ++;
System.out.println(n + " hello!");
}
}
}
main
线程通过调用t.interrupt()
方法中断t
线程,但是要注意,interrupt()
方法仅仅向t
线程发出了“中断请求”,至于t
线程是否能立刻响应,要看具体代码。而t
线程的while
循环会检测isInterrupted()
,所以上述代码能正确响应interrupt()
请求,使得自身立刻结束运行run()
方法。
如果线程处于等待状态,例如,t.join()
会让main
线程进入等待状态,此时,如果对main
线程调用interrupt()
,join()
方法会立刻抛出InterruptedException
,因此,目标线程只要捕获到join()
方法抛出的InterruptedException
,就说明有其他线程对其调用了interrupt()
方法,通常情况下该线程应该立刻结束运行。
public class Main {
public static void main(String[] args) throws InterruptedException {
Thread t = new MyThread();
t.start();
Thread.sleep(1000);
t.interrupt(); // 中断t线程
t.join(); // 等待t线程结束
System.out.println("end");
}
}
class MyThread extends Thread {
public void run() {
Thread hello = new HelloThread();
hello.start(); // 启动hello线程
try {
hello.join(); // 等待hello线程结束
} catch (InterruptedException e) {
System.out.println("interrupted!");
//这儿接收到中断指令后,不再执行等待,继续执行run最后一步中断hello线程,而后该MyThread线程中断
}
hello.interrupt();
}
}
class HelloThread extends Thread {
public void run() {
int n = 0;
while (!isInterrupted()) {
n++;
System.out.println(n + " hello!");
try {
Thread.sleep(100);
} catch (InterruptedException e) {
break;
}
}
}
}
main
线程通过调用t.interrupt()
从而通知t
线程中断,而此时t
线程正位于hello.join()
的等待中,此方法会立刻结束等待并抛出InterruptedException
。由于我们在t
线程中捕获了InterruptedException
,因此,就可以准备结束该线程。在t
线程结束前,对hello
线程也进行了interrupt()
调用通知其中断。如果去掉这一行代码,可以发现hello
线程仍然会继续运行,且JVM不会退出。
另一个常用的中断线程的方法是设置标志位。我们通常会用一个running
标志位来标识线程是否应该继续运行,在外部线程中,通过把HelloThread.running
置为false
,就可以让线程结束:
public class Main {
public static void main(String[] args) throws InterruptedException {
HelloThread t = new HelloThread();
t.start();
Thread.sleep(1);
t.running = false; // 标志位置为false
}
}
class HelloThread extends Thread {
public volatile boolean running = true;
public void run() {
int n = 0;
while (running) {
n ++;
System.out.println(n + " hello!");
}
System.out.println("end!");
}
}
注意到HelloThread
的标志位boolean running
是一个线程间共享的变量。线程间共享变量需要使用volatile
关键字标记,确保每个线程都能读取到更新后的变量值。
为什么要对线程间共享的变量用关键字volatile
声明?这涉及到Java的内存模型。在Java虚拟机中,变量的值保存在主内存中,但是,当线程访问变量时,它会先获取一个副本,并保存在自己的工作内存中。如果线程修改了变量的值,虚拟机会在某个时刻把修改后的值回写到主内存,但是,这个时间是不确定的!
┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐
Main Memory
│ │
┌───────┐┌───────┐┌───────┐
│ │ var A ││ var B ││ var C │ │
└───────┘└───────┘└───────┘
│ │ ▲ │ ▲ │
─ ─ ─│─│─ ─ ─ ─ ─ ─ ─ ─│─│─ ─ ─
│ │ │ │
┌ ─ ─ ┼ ┼ ─ ─ ┐ ┌ ─ ─ ┼ ┼ ─ ─ ┐
▼ │ ▼ │
│ ┌───────┐ │ │ ┌───────┐ │
│ var A │ │ var C │
│ └───────┘ │ │ └───────┘ │
Thread 1 Thread 2
└ ─ ─ ─ ─ ─ ─ ┘ └ ─ ─ ─ ─ ─ ─ ┘
这会导致如果一个线程更新了某个变量,另一个线程读取的值可能还是更新前的。例如,主内存的变量a = true
,线程1执行a = false
时,它在此刻仅仅是把变量a
的副本变成了false
,主内存的变量a
还是true
,在JVM把修改后的a
回写到主内存之前,其他线程读取到的a
的值仍然是true
,这就造成了多线程之间共享的变量不一致。
因此,volatile
关键字的目的是告诉虚拟机:
volatile
关键字解决的是可见性问题:当一个线程修改了某个共享变量的值,其他线程能够立刻看到修改后的值。
如果我们去掉volatile
关键字,运行上述程序,发现效果和带volatile
差不多,这是因为在x86的架构下,JVM回写主内存的速度非常快,但是,换成ARM的架构,就会有显著的延迟。
Java程序入口就是由JVM启动main
线程,main
线程又可以启动其他线程。当所有线程都运行结束时,JVM退出,进程结束。
如果有一个线程没有退出,JVM进程就不会退出。所以,必须保证所有线程都能及时结束。
但是有一种线程的目的就是无限循环,例如,一个定时触发任务的线程:
class TimerThread extends Thread {
@Override
public void run() {
while (true) {
System.out.println(LocalTime.now());
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
break;
}
}
}
}
如果这个线程不结束,JVM进程就无法结束。问题是,由谁负责结束这个线程?
然而这类线程经常没有负责人来负责结束它们。但是,当其他线程结束时,JVM进程又必须要结束,怎么办?
答案是使用守护线程(Daemon Thread)。
守护线程是指为其他线程服务的线程。在JVM中,所有非守护线程都执行完毕后,无论有没有守护线程,虚拟机都会自动退出。
因此,JVM退出时,不必关心守护线程是否已结束。
如何创建守护线程呢?方法和普通线程一样,只是在调用start()
方法前,调用setDaemon(true)
把该线程标记为守护线程:
Thread t = new MyThread();
t.setDaemon(true);
t.start();
在守护线程中,编写代码要注意:守护线程不能持有任何需要关闭的资源,例如打开文件等,因为虚拟机退出时,守护线程没有任何机会来关闭文件,这会导致数据丢失。
当多个线程同时运行时,线程的调度由操作系统决定,程序本身无法决定。因此,任何一个线程都有可能在任何指令处被操作系统暂停,然后在某个时间段后继续执行。
这个时候,有个单线程模型下不存在的问题就来了:如果多个线程同时读写共享变量,会出现数据不一致的问题。
我们来看一个例子:
public class Main {
public static void main(String[] args) throws Exception {
var add = new AddThread();
var dec = new DecThread();
add.start();
dec.start();
add.join();
dec.join();
System.out.println(Counter.count);
}
}
class Counter {
public static int count = 0;
}
class AddThread extends Thread {
public void run() {
for (int i=0; i<10000; i++) { Counter.count += 1; }
}
}
class DecThread extends Thread {
public void run() {
for (int i=0; i<10000; i++) { Counter.count -= 1; }
}
}
上面的代码很简单,两个线程同时对一个int
变量进行操作,一个加10000次,一个减10000次,最后结果应该是0,但是,每次运行,结果实际上都是不一样的。
这是因为对变量进行读取和写入时,结果要正确,必须保证是原子操作。原子操作是指不能被中断的一个或一系列操作。
例如,对于语句:
n = n + 1;
看上去是一行语句,实际上对应了3条指令:
ILOAD
IADD
ISTORE
我们假设n
的值是100
,如果两个线程同时执行n = n + 1
,得到的结果很可能不是102
,而是101
,原因在于:
┌───────┐ ┌───────┐
│Thread1│ │Thread2│
└───┬───┘ └───┬───┘
│ │
│ILOAD (100) │
│ │ILOAD (100)
│ │IADD
│ │ISTORE (101)
│IADD │
│ISTORE (101)│
▼ ▼
如果线程1在执行ILOAD
后被操作系统中断,此刻如果线程2被调度执行,它执行ILOAD
后获取的值仍然是100
,最终结果被两个线程的ISTORE
写入后变成了101
,而不是期待的102
。
这说明**多线程模型下,要保证逻辑正确,对共享变量进行读写时,必须保证一组指令以原子方式执行:即某一个线程执行时,其他线程必须等待:**
┌───────┐ ┌───────┐
│Thread1│ │Thread2│
└───┬───┘ └───┬───┘
│ │
│-- lock -- │
│ILOAD (100) │
│IADD │
│ISTORE (101) │
│-- unlock -- │
│ │-- lock --
│ │ILOAD (101)
│ │IADD
│ │ISTORE (102)
│ │-- unlock --
▼ ▼
通过加锁和解锁的操作,就能保证3条指令总是在一个线程执行期间,不会有其他线程会进入此指令区间。即使在执行期线程被操作系统中断执行,其他线程也会因为无法获得锁导致无法进入此指令区间。只有执行线程将锁释放后,其他线程才有机会获得锁并执行。这种加锁和解锁之间的代码块我们称之为临界区(Critical Section),任何时候临界区最多只有一个线程能执行。
可见,保证一段代码的原子性就是通过加锁和解锁实现的。
Java程序使用synchronized
关键字对一个对象进行加锁:
synchronized(lock) {
n = n + 1;
}
synchronized
保证了代码块在任意时刻最多只有一个线程能执行。我们把上面的代码用synchronized
改写如下:
public class Main {
public static void main(String[] args) throws Exception {
var add = new AddThread();
var dec = new DecThread();
add.start();
dec.start();
add.join();
dec.join();
System.out.println(Counter.count);
}
}
class Counter {
public static final Object lock = new Object();
public static int count = 0;
}
class AddThread extends Thread {
public void run() {
for (int i=0; i<10000; i++) {
synchronized(Counter.lock) {
Counter.count += 1;
}
}
}
}
class DecThread extends Thread {
public void run() {
for (int i=0; i<10000; i++) {
synchronized(Counter.lock) {
Counter.count -= 1;
}
}
}
}
**它表示用Counter.lock
实例作为锁,两个线程在执行各自的synchronized(Counter.lock) { ... }
代码块时,必须先获得锁,才能进入代码块进行。执行结束后,在synchronized
语句块结束会自动释放锁。这样一来,对Counter.count
变量进行读写就不可能同时进行。**上述代码无论运行多少次,最终结果都是0。
使用synchronized
解决了多线程同步访问共享变量的正确性问题。但是,它的缺点是带来了性能下降。因为synchronized
代码块无法并发执行。此外,加锁和解锁需要消耗一定的时间,所以,synchronized
会降低程序的执行效率。
我们来概括一下如何使用synchronized
:
synchronized(lockObject) { ... }
。在使用synchronized
的时候,不必担心抛出异常。因为无论是否有异常,都会在synchronized
结束处正确释放锁:
public void add(int m) {
synchronized (obj) {
if (m < 0) {
throw new RuntimeException();
}
this.value += m;
} // 无论有无异常,都会在此释放锁
}
我们再来看一个错误使用synchronized
的例子:
public class Main {
public static void main(String[] args) throws Exception {
var add = new AddThread();
var dec = new DecThread();
add.start();
dec.start();
add.join();
dec.join();
System.out.println(Counter.count);
}
}
class Counter {
public static final Object lock1 = new Object();
public static final Object lock2 = new Object();
public static int count = 0;
}
class AddThread extends Thread {
public void run() {
for (int i=0; i<10000; i++) {
synchronized(Counter.lock1) {
Counter.count += 1;
}
}
}
}
class DecThread extends Thread {
public void run() {
for (int i=0; i<10000; i++) {
synchronized(Counter.lock2) {
Counter.count -= 1;
}
}
}
}
结果并不是0,这是因为两个线程各自的synchronized
锁住的不是同一个对象!这使得两个线程各自都可以同时获得锁:因为JVM只保证同一个锁在任意时刻只能被一个线程获取,但两个不同的锁在同一时刻可以被两个线程分别获取。
因此,使用synchronized
的时候,获取到的是哪个锁非常重要。锁对象如果不对,代码逻辑就不对。
我们再看一个例子:
public class Main {
public static void main(String[] args) throws Exception {
var ts = new Thread[] { new AddStudentThread(), new DecStudentThread(), new AddTeacherThread(), new DecTeacherThread() };
for (var t : ts) {
t.start();
}
for (var t : ts) {
t.join();
}
System.out.println(Counter.studentCount);
System.out.println(Counter.teacherCount);
}
}
class Counter {
public static final Object lock = new Object();
public static int studentCount = 0;
public static int teacherCount = 0;
}
class AddStudentThread extends Thread {
public void run() {
for (int i=0; i<10000; i++) {
synchronized(Counter.lock) {
Counter.studentCount += 1;
}
}
}
}
class DecStudentThread extends Thread {
public void run() {
for (int i=0; i<10000; i++) {
synchronized(Counter.lock) {
Counter.studentCount -= 1;
}
}
}
}
class AddTeacherThread extends Thread {
public void run() {
for (int i=0; i<10000; i++) {
synchronized(Counter.lock) {
Counter.teacherCount += 1;
}
}
}
}
class DecTeacherThread extends Thread {
public void run() {
for (int i=0; i<10000; i++) {
synchronized(Counter.lock) {
Counter.teacherCount -= 1;
}
}
}
}
上述代码的4个线程对两个共享变量分别进行读写操作,但是使用的锁都是Counter.lock
这一个对象,这就造成了原本可以并发执行的Counter.studentCount += 1
和Counter.teacherCount += 1
,现在无法并发执行了,执行效率大大降低。实际上,需要同步的线程可以分成两组:AddStudentThread
和DecStudentThread
,AddTeacherThread
和DecTeacherThread
,组之间不存在竞争,因此,应该使用两个不同的锁,即:
AddStudentThread
和DecStudentThread
使用lockStudent
锁:
synchronized(Counter.lockStudent) {
...
}
AddTeacherThread
和DecTeacherThread
使用lockTeacher
锁:
synchronized(Counter.lockTeacher) {
...
}
这样才能最大化地提高执行效率。
JVM规范定义了几种原子操作:
long
和double
除外)赋值,例如:int n = m
;List list = anotherList
。long
和double
是64位数据,JVM没有明确规定64位赋值操作是不是一个原子操作,不过在x64平台的JVM是把long
和double
的赋值作为原子操作实现的。
单条原子操作的语句不需要同步。例如:
public void set(int m) {
synchronized(lock) {
this.value = m;
}
}
就不需要同步。
对引用也是类似。例如:
public void set(String s) {
this.value = s;
}
上述赋值语句并不需要同步。
但是,如果是多行赋值语句,就必须保证是同步操作,例如:
class Point {
int x;
int y;
public void set(int x, int y) {
synchronized(this) {
this.x = x;
this.y = y;
}
}
}
多线程连续读写多个变量时,同步的目的是为了保证程序逻辑正确!
不但写需要同步,读也需要同步:
class Point {
int x;
int y;
public void set(int x, int y) {
synchronized(this) {
this.x = x;
this.y = y;
}
}
public int[] get() {
int[] copy = new int[2];
copy[0] = x;
copy[1] = y;
}
}
假定当前坐标是(100, 200)
,那么当设置新坐标为(110, 220)
时,上述未同步的多线程读到的值可能有:
如果读取到(110, 200)
,即读到了更新后的x,更新前的y,那么可能会造成程序的逻辑错误,无法保证读取的多个变量状态保持一致。
有些时候,通过一些巧妙的转换,可以把非原子操作变为原子操作。例如,上述代码如果改造成:
class Point {
int[] ps;
public void set(int x, int y) {
int[] ps = new int[] { x, y };
this.ps = ps;
}
}
就不再需要写同步,因为this.ps = ps
是引用赋值的原子操作。而语句:
int[] ps = new int[] { x, y };
这里的ps
是方法内部定义的局部变量,每个线程都会有各自的局部变量,互不影响,并且互不可见,并不需要同步。
不过要注意,读方法在复制int[]
数组的过程中仍然需要同步。
如果多线程读写的是一个不可变对象,那么无需同步,因为不会修改对象的状态:
class Data {
List names;
void set(String[] names) {
this.names = List.of(names);
}
List get() {
return this.names;
}
}
注意到set()
方法内部创建了一个不可变List
,这个List
包含的对象也是不可变对象String
,因此,整个List
对象都是不可变的,因此读写均无需同步。
分析变量是否能被多线程访问时,首先要理清概念,多线程同时执行的是方法。对于下面这个例子:
class Status {
List names;
int x;
int y;
void set(String[] names, int n) {
List ns = List.of(names);
this.names = ns;
int step = n * 10;
this.x += step;
this.y += step;
}
StatusRecord get() {
return new StatusRecord(this.names, this.x, this.y);
}
}
如果有A、B两个线程,同时执行是指:
类的成员变量names
、x
、y
显然能被多线程同时读写,但局部变量(包括方法参数)如果没有“逃逸”,那么只有当前线程可见。局部变量step
仅在set()
方法内部使用,因此每个线程同时执行set时都有一份独立的step存储在线程的栈上,互不影响,但是局部变量ns
虽然每个线程也各有一份,但后续赋值后对其他线程就变成可见了。对set()
方法同步时,如果要最小化synchronized
代码块,可以改写如下:
void set(String[] names, int n) {
// 局部变量其他线程不可见:
List ns = List.of(names);
int step = n * 10;
synchronized(this) {
this.names = ns;
this.x += step;
this.y += step;
}
}
因此,深入理解多线程还需理解变量在栈上的存储方式,基本类型和引用类型的存储方式也不同。
我们知道Java程序依靠synchronized
对线程进行同步,使用synchronized
的时候,锁住的是哪个对象非常重要。
==让线程自己选择锁对象往往会使得代码逻辑混乱,也不利于封装。更好的方法是把synchronized
逻辑封装起来。==例如,我们编写一个计数器如下:
public class Counter {
private int count = 0;
public void add(int n) {
synchronized(this) {
count += n;
}
}
public void dec(int n) {
synchronized(this) {
count -= n;
}
}
public int get() {
return count;
}
}
这样一来,线程调用add()
、dec()
方法时,它不必关心同步逻辑,因为synchronized
代码块在add()
、dec()
方法内部。并且,我们注意到,synchronized
锁住的对象是this
,即当前实例,这又使得创建多个Counter
实例的时候,它们之间互不影响,可以并发执行:
var c1 = Counter();
var c2 = Counter();
// 对c1进行操作的线程:
new Thread(() -> {
c1.add();
}).start();
new Thread(() -> {
c1.dec();
}).start();
// 对c2进行操作的线程:
new Thread(() -> {
c2.add();
}).start();
new Thread(() -> {
c2.dec();
}).start();
现在,对于Counter
类,多线程可以正确调用。
如果一个类被设计为允许多线程正确访问,我们就说这个类就是“线程安全”的(thread-safe),上面的Counter
类就是线程安全的。Java标准库的java.lang.StringBuffer
也是线程安全的。
还有一些不变类,例如String
,Integer
,LocalDate
,它们的所有成员变量都是final
,多线程同时访问时只能读不能写,这些不变类也是线程安全的。
最后,类似Math
这些只提供静态方法,没有成员变量的类,也是线程安全的。
除了上述几种少数情况,大部分类,例如ArrayList
,都是非线程安全的类,我们不能在多线程中修改它们。但是,如果所有线程都只读取,不写入,那么ArrayList
是可以安全地在线程间共享的。
没有特殊说明时,一个类默认是非线程安全的。
我们再观察Counter
的代码:
public class Counter {
public void add(int n) {
synchronized(this) {
count += n;
}
}
...
}
当我们锁住的是this
实例时,实际上可以用synchronized
修饰这个方法。下面两种写法是等价的:
public void add(int n) {
synchronized(this) { // 锁住this
count += n;
} // 解锁
}
public synchronized void add(int n) { // 锁住this
count += n;
} // 解锁
因此,用synchronized
修饰的方法就是同步方法,它表示整个方法都必须用this
实例加锁。
我们再思考一下,如果对一个静态方法添加synchronized
修饰符,它锁住的是哪个对象?
public synchronized static void test(int n) {
...
}
对于static
方法,是没有this
实例的,因为static
方法是针对类而不是实例。但是我们注意到任何一个类都有一个由JVM自动创建的Class
实例,因此,对static
方法添加synchronized
,锁住的是该类的Class
实例。上述synchronized static
方法实际上相当于:
public class Counter {
public static void test(int n) {
synchronized(Counter.class) {
...
}
}
}
我们再考察Counter
的get()
方法:
public class Counter {
private int count;
public int get() {
return count;
}
...
}
它没有同步,因为读一个int
变量不需要同步。
然而,如果我们把代码稍微改一下,返回一个包含两个int
的对象:
public class Counter {
private int first;
private int last;
public Pair get() {
Pair p = new Pair();
p.first = first;
p.last = last;
return p;
}
...
}
就必须要同步了。
Java的线程锁是可重入的锁。
什么是可重入的锁?我们还是来看例子:
public class Counter {
private int count = 0;
public synchronized void add(int n) {
if (n < 0) {
dec(-n);
} else {
count += n;
}
}
public synchronized void dec(int n) {
count += n;
}
}
观察synchronized
修饰的add()
方法,一旦线程执行到add()
方法内部,说明它已经获取了当前实例的this
锁。如果传入的n < 0
,将在add()
方法内部调用dec()
方法。由于dec()
方法也需要获取this
锁,现在问题来了:
对同一个线程,能否在获取到锁以后继续获取同一个锁?
答案是肯定的。JVM允许同一个线程重复获取同一个锁,这种能被同一个线程反复获取的锁,就叫做可重入锁。
由于Java的线程锁是可重入锁,所以,获取锁的时候,不但要判断是否是第一次获取,还要记录这是第几次获取。每获取一次锁,记录+1,每退出synchronized
块,记录-1,减到0的时候,才会真正释放锁。
一个线程可以获取一个锁后,再继续获取另一个锁。例如:
public void add(int m) {
synchronized(lockA) { // 获得lockA的锁
this.value += m;
synchronized(lockB) { // 获得lockB的锁
this.another += m;
} // 释放lockB的锁
} // 释放lockA的锁
}
public void dec(int m) {
synchronized(lockB) { // 获得lockB的锁
this.another -= m;
synchronized(lockA) { // 获得lockA的锁
this.value -= m;
} // 释放lockA的锁
} // 释放lockB的锁
}
在获取多个锁的时候,不同线程获取多个不同对象的锁可能导致死锁。对于上述代码,线程1和线程2如果分别执行add()
和dec()
方法时:
add()
,获得lockA
;dec()
,获得lockB
。随后:
lockB
,失败,等待中;lockA
,失败,等待中。此时,两个线程各自持有不同的锁,然后各自试图获取对方手里的锁,造成了双方无限等待下去,这就是死锁。
死锁发生后,没有任何机制能解除死锁,只能强制结束JVM进程。
因此,在编写多线程应用时,要特别注意防止死锁。因为死锁一旦形成,就只能强制结束进程。
那么我们应该如何避免死锁呢?答案是:线程获取锁的顺序要一致。即严格按照先获取lockA
,再获取lockB
的顺序,改写dec()
方法如下:
public void dec(int m) {
synchronized(lockA) { // 获得lockA的锁
this.value -= m;
synchronized(lockB) { // 获得lockB的锁
this.another -= m;
} // 释放lockB的锁
} // 释放lockA的锁
}
在Java程序中,synchronized
解决了多线程竞争的问题。例如,对于一个任务管理器,多个线程同时往队列中添加任务,可以用synchronized
加锁:
class TaskQueue {
Queue queue = new LinkedList<>();
public synchronized void addTask(String s) {
this.queue.add(s);
}
}
但是synchronized
并没有解决多线程协调的问题。
仍然以上面的TaskQueue
为例,我们再编写一个getTask()
方法取出队列的第一个任务:
class TaskQueue {
Queue queue = new LinkedList<>();
public synchronized void addTask(String s) {
this.queue.add(s);
}
public synchronized String getTask() {
while (queue.isEmpty()) {
}
return queue.remove();
}
}
上述代码看上去没有问题:getTask()
内部先判断队列是否为空,如果为空,就循环等待,直到另一个线程往队列中放入了一个任务,while()
循环退出,就可以返回队列的元素了。
但实际上while()
循环永远不会退出。因为线程在执行while()
循环时,已经在getTask()
入口获取了this
锁,其他线程根本无法调用addTask()
,因为addTask()
执行条件也是获取this
锁。
因此,执行上述代码,线程会在getTask()
中因为死循环而100%占用CPU资源。
如果深入思考一下,我们想要的执行效果是:
addTask()
不断往队列中添加任务;getTask()
从队列中获取任务。如果队列为空,则getTask()
应该等待,直到队列中至少有一个任务时再返回。因此,多线程协调运行的原则就是:当条件不满足时,线程进入等待状态;当条件满足时,线程被唤醒,继续执行任务。
对于上述TaskQueue
,我们先改造getTask()
方法,在条件不满足时,线程进入等待状态:
public synchronized String getTask() {
while (queue.isEmpty()) {
this.wait();
}
return queue.remove();
}
当一个线程执行到getTask()
方法内部的while
循环时,它必定已经获取到了this
锁,此时,线程执行while
条件判断,如果条件成立(队列为空),线程将执行this.wait()
,进入等待状态。
这里的关键是:wait()
方法必须在当前获取的锁对象上调用,这里获取的是this
锁,因此调用this.wait()
。
调用wait()
方法后,线程进入等待状态,wait()
方法不会返回,直到将来某个时刻,线程从等待状态被其他线程唤醒后,wait()
方法才会返回,然后,继续执行下一条语句。
有些仔细的童鞋会指出:即使线程在getTask()
内部等待,其他线程如果拿不到this
锁,照样无法执行addTask()
,肿么办?
这个问题的关键就在于**wait()
方法的执行机制非常复杂。首先,它不是一个普通的Java方法,而是定义在Object
类的一个native
方法,也就是由JVM的C代码实现的。其次,必须在synchronized
块中才能调用wait()
方法,因为wait()
方法调用时,会释放线程获得的锁,wait()
方法返回后,线程又会重新试图获得锁。**
因此,只能在锁对象上调用wait()
方法。因为在getTask()
中,我们获得了this
锁,因此,只能在this
对象上调用wait()
方法:
public synchronized String getTask() {
while (queue.isEmpty()) {
// 释放this锁:
this.wait();
// 重新获取this锁
}
return queue.remove();
}
当一个线程在this.wait()
等待时,它就会释放this
锁,从而使得其他线程能够在addTask()
方法获得this
锁。
现在我们面临第二个问题:如何让等待的线程被重新唤醒,然后从wait()
方法返回?答案是在相同的锁对象上调用notify()
方法。我们修改addTask()
如下:
public synchronized void addTask(String s) {
this.queue.add(s);
this.notify(); // 唤醒在this锁等待的线程
}
注意到在往队列中添加了任务后,线程立刻对this
锁对象调用notify()
方法,这个方法会唤醒一个正在this
锁等待的线程(就是在getTask()
中位于this.wait()
的线程),从而使得等待线程从this.wait()
方法返回。
我们来看一个完整的例子:
public class Main {
public static void main(String[] args) throws InterruptedException {
var q = new TaskQueue();
var ts = new ArrayList<Thread>();
for (int i=0; i<5; i++) {
var t = new Thread() {
public void run() {
// 执行task:
while (true) {
try {
String s = q.getTask();
System.out.println("execute task: " + s);
} catch (InterruptedException e) {
return;
}
}
}
};
t.start();
ts.add(t);
}
var add = new Thread(() -> {
for (int i=0; i<10; i++) {
// 放入task:
String s = "t-" + Math.random();
System.out.println("add task: " + s);
q.addTask(s);
try { Thread.sleep(100); } catch(InterruptedException e) {}
}
});
add.start();
add.join();
Thread.sleep(100);
for (var t : ts) {
t.interrupt();
}
}
}
class TaskQueue {
Queue<String> queue = new LinkedList<>();
public synchronized void addTask(String s) {
this.queue.add(s);
this.notifyAll();
}
public synchronized String getTask() throws InterruptedException {
while (queue.isEmpty()) {
this.wait();
}
return queue.remove();
}
}
这个例子中,我们重点关注addTask()
方法,内部调用了this.notifyAll()
而不是this.notify()
,使用notifyAll()
将唤醒所有当前正在this
锁等待的线程,而notify()
只会唤醒其中一个(具体哪个依赖操作系统,有一定的随机性)。这是因为可能有多个线程正在getTask()
方法内部的wait()
中等待,使用notifyAll()
将一次性全部唤醒。通常来说,notifyAll()
更安全。有些时候,如果我们的代码逻辑考虑不周,用notify()
会导致只唤醒了一个线程,而其他线程可能永远等待下去醒不过来了。
但是,注意到wait()
方法返回时需要重新获得this
锁。假设当前有3个线程被唤醒,唤醒后,首先要等待执行addTask()
的线程结束此方法后,才能释放this
锁,随后,这3个线程中只能有一个获取到this
锁,剩下两个将继续等待。
再注意到我们在while()
循环中调用wait()
,而不是if
语句:
public synchronized String getTask() throws InterruptedException {
if (queue.isEmpty()) {
this.wait();
}
return queue.remove();
}
这种写法实际上是错误的,因为线程被唤醒时,需要再次获取this
锁。多个线程被唤醒后,只有一个线程能获取this
锁,此刻,该线程执行queue.remove()
可以获取到队列的元素,然而,剩下的线程如果获取this
锁后执行queue.remove()
,此刻队列可能已经没有任何元素了,所以,要始终在while
循环中wait()
,并且每次被唤醒后拿到this
锁就必须再次判断:
while (queue.isEmpty()) {
this.wait();
}
所以,正确编写多线程代码是非常困难的,需要仔细考虑的条件非常多,任何一个地方考虑不周,都会导致多线程运行时不正常。
时候,它们之间互不影响,可以并发执行:
var c1 = Counter();
var c2 = Counter();
// 对c1进行操作的线程:
new Thread(() -> {
c1.add();
}).start();
new Thread(() -> {
c1.dec();
}).start();
// 对c2进行操作的线程:
new Thread(() -> {
c2.add();
}).start();
new Thread(() -> {
c2.dec();
}).start();
现在,对于Counter
类,多线程可以正确调用。
如果一个类被设计为允许多线程正确访问,我们就说这个类就是“线程安全”的(thread-safe),上面的Counter
类就是线程安全的。Java标准库的java.lang.StringBuffer
也是线程安全的。
还有一些不变类,例如String
,Integer
,LocalDate
,它们的所有成员变量都是final
,多线程同时访问时只能读不能写,这些不变类也是线程安全的。
最后,类似Math
这些只提供静态方法,没有成员变量的类,也是线程安全的。
除了上述几种少数情况,大部分类,例如ArrayList
,都是非线程安全的类,我们不能在多线程中修改它们。但是,如果所有线程都只读取,不写入,那么ArrayList
是可以安全地在线程间共享的。
没有特殊说明时,一个类默认是非线程安全的。
我们再观察Counter
的代码:
public class Counter {
public void add(int n) {
synchronized(this) {
count += n;
}
}
...
}
当我们锁住的是this
实例时,实际上可以用synchronized
修饰这个方法。下面两种写法是等价的:
public void add(int n) {
synchronized(this) { // 锁住this
count += n;
} // 解锁
}
public synchronized void add(int n) { // 锁住this
count += n;
} // 解锁
因此,用synchronized
修饰的方法就是同步方法,它表示整个方法都必须用this
实例加锁。
我们再思考一下,如果对一个静态方法添加synchronized
修饰符,它锁住的是哪个对象?
public synchronized static void test(int n) {
...
}
对于static
方法,是没有this
实例的,因为static
方法是针对类而不是实例。但是我们注意到任何一个类都有一个由JVM自动创建的Class
实例,因此,对static
方法添加synchronized
,锁住的是该类的Class
实例。上述synchronized static
方法实际上相当于:
public class Counter {
public static void test(int n) {
synchronized(Counter.class) {
...
}
}
}
我们再考察Counter
的get()
方法:
public class Counter {
private int count;
public int get() {
return count;
}
...
}
它没有同步,因为读一个int
变量不需要同步。
然而,如果我们把代码稍微改一下,返回一个包含两个int
的对象:
public class Counter {
private int first;
private int last;
public Pair get() {
Pair p = new Pair();
p.first = first;
p.last = last;
return p;
}
...
}
就必须要同步了。
Java的线程锁是可重入的锁。
什么是可重入的锁?我们还是来看例子:
public class Counter {
private int count = 0;
public synchronized void add(int n) {
if (n < 0) {
dec(-n);
} else {
count += n;
}
}
public synchronized void dec(int n) {
count += n;
}
}
观察synchronized
修饰的add()
方法,一旦线程执行到add()
方法内部,说明它已经获取了当前实例的this
锁。如果传入的n < 0
,将在add()
方法内部调用dec()
方法。由于dec()
方法也需要获取this
锁,现在问题来了:
对同一个线程,能否在获取到锁以后继续获取同一个锁?
答案是肯定的。JVM允许同一个线程重复获取同一个锁,这种能被同一个线程反复获取的锁,就叫做可重入锁。
由于Java的线程锁是可重入锁,所以,获取锁的时候,不但要判断是否是第一次获取,还要记录这是第几次获取。每获取一次锁,记录+1,每退出synchronized
块,记录-1,减到0的时候,才会真正释放锁。
一个线程可以获取一个锁后,再继续获取另一个锁。例如:
public void add(int m) {
synchronized(lockA) { // 获得lockA的锁
this.value += m;
synchronized(lockB) { // 获得lockB的锁
this.another += m;
} // 释放lockB的锁
} // 释放lockA的锁
}
public void dec(int m) {
synchronized(lockB) { // 获得lockB的锁
this.another -= m;
synchronized(lockA) { // 获得lockA的锁
this.value -= m;
} // 释放lockA的锁
} // 释放lockB的锁
}
在获取多个锁的时候,不同线程获取多个不同对象的锁可能导致死锁。对于上述代码,线程1和线程2如果分别执行add()
和dec()
方法时:
add()
,获得lockA
;dec()
,获得lockB
。随后:
lockB
,失败,等待中;lockA
,失败,等待中。此时,两个线程各自持有不同的锁,然后各自试图获取对方手里的锁,造成了双方无限等待下去,这就是死锁。
死锁发生后,没有任何机制能解除死锁,只能强制结束JVM进程。
因此,在编写多线程应用时,要特别注意防止死锁。因为死锁一旦形成,就只能强制结束进程。
那么我们应该如何避免死锁呢?答案是:线程获取锁的顺序要一致。即严格按照先获取lockA
,再获取lockB
的顺序,改写dec()
方法如下:
public void dec(int m) {
synchronized(lockA) { // 获得lockA的锁
this.value -= m;
synchronized(lockB) { // 获得lockB的锁
this.another -= m;
} // 释放lockB的锁
} // 释放lockA的锁
}
在Java程序中,synchronized
解决了多线程竞争的问题。例如,对于一个任务管理器,多个线程同时往队列中添加任务,可以用synchronized
加锁:
class TaskQueue {
Queue queue = new LinkedList<>();
public synchronized void addTask(String s) {
this.queue.add(s);
}
}
但是synchronized
并没有解决多线程协调的问题。
仍然以上面的TaskQueue
为例,我们再编写一个getTask()
方法取出队列的第一个任务:
class TaskQueue {
Queue queue = new LinkedList<>();
public synchronized void addTask(String s) {
this.queue.add(s);
}
public synchronized String getTask() {
while (queue.isEmpty()) {
}
return queue.remove();
}
}
上述代码看上去没有问题:getTask()
内部先判断队列是否为空,如果为空,就循环等待,直到另一个线程往队列中放入了一个任务,while()
循环退出,就可以返回队列的元素了。
但实际上while()
循环永远不会退出。因为线程在执行while()
循环时,已经在getTask()
入口获取了this
锁,其他线程根本无法调用addTask()
,因为addTask()
执行条件也是获取this
锁。
因此,执行上述代码,线程会在getTask()
中因为死循环而100%占用CPU资源。
如果深入思考一下,我们想要的执行效果是:
addTask()
不断往队列中添加任务;getTask()
从队列中获取任务。如果队列为空,则getTask()
应该等待,直到队列中至少有一个任务时再返回。因此,多线程协调运行的原则就是:当条件不满足时,线程进入等待状态;当条件满足时,线程被唤醒,继续执行任务。
对于上述TaskQueue
,我们先改造getTask()
方法,在条件不满足时,线程进入等待状态:
public synchronized String getTask() {
while (queue.isEmpty()) {
this.wait();
}
return queue.remove();
}
当一个线程执行到getTask()
方法内部的while
循环时,它必定已经获取到了this
锁,此时,线程执行while
条件判断,如果条件成立(队列为空),线程将执行this.wait()
,进入等待状态。
这里的关键是:wait()
方法必须在当前获取的锁对象上调用,这里获取的是this
锁,因此调用this.wait()
。
调用wait()
方法后,线程进入等待状态,wait()
方法不会返回,直到将来某个时刻,线程从等待状态被其他线程唤醒后,wait()
方法才会返回,然后,继续执行下一条语句。
有些仔细的童鞋会指出:即使线程在getTask()
内部等待,其他线程如果拿不到this
锁,照样无法执行addTask()
,肿么办?
这个问题的关键就在于**wait()
方法的执行机制非常复杂。首先,它不是一个普通的Java方法,而是定义在Object
类的一个native
方法,也就是由JVM的C代码实现的。其次,必须在synchronized
块中才能调用wait()
方法,因为wait()
方法调用时,会释放线程获得的锁,wait()
方法返回后,线程又会重新试图获得锁。**
因此,只能在锁对象上调用wait()
方法。因为在getTask()
中,我们获得了this
锁,因此,只能在this
对象上调用wait()
方法:
public synchronized String getTask() {
while (queue.isEmpty()) {
// 释放this锁:
this.wait();
// 重新获取this锁
}
return queue.remove();
}
当一个线程在this.wait()
等待时,它就会释放this
锁,从而使得其他线程能够在addTask()
方法获得this
锁。
现在我们面临第二个问题:如何让等待的线程被重新唤醒,然后从wait()
方法返回?答案是在相同的锁对象上调用notify()
方法。我们修改addTask()
如下:
public synchronized void addTask(String s) {
this.queue.add(s);
this.notify(); // 唤醒在this锁等待的线程
}
注意到在往队列中添加了任务后,线程立刻对this
锁对象调用notify()
方法,这个方法会唤醒一个正在this
锁等待的线程(就是在getTask()
中位于this.wait()
的线程),从而使得等待线程从this.wait()
方法返回。
我们来看一个完整的例子:
public class Main {
public static void main(String[] args) throws InterruptedException {
var q = new TaskQueue();
var ts = new ArrayList<Thread>();
for (int i=0; i<5; i++) {
var t = new Thread() {
public void run() {
// 执行task:
while (true) {
try {
String s = q.getTask();
System.out.println("execute task: " + s);
} catch (InterruptedException e) {
return;
}
}
}
};
t.start();
ts.add(t);
}
var add = new Thread(() -> {
for (int i=0; i<10; i++) {
// 放入task:
String s = "t-" + Math.random();
System.out.println("add task: " + s);
q.addTask(s);
try { Thread.sleep(100); } catch(InterruptedException e) {}
}
});
add.start();
add.join();
Thread.sleep(100);
for (var t : ts) {
t.interrupt();
}
}
}
class TaskQueue {
Queue<String> queue = new LinkedList<>();
public synchronized void addTask(String s) {
this.queue.add(s);
this.notifyAll();
}
public synchronized String getTask() throws InterruptedException {
while (queue.isEmpty()) {
this.wait();
}
return queue.remove();
}
}
这个例子中,我们重点关注addTask()
方法,内部调用了this.notifyAll()
而不是this.notify()
,使用notifyAll()
将唤醒所有当前正在this
锁等待的线程,而notify()
只会唤醒其中一个(具体哪个依赖操作系统,有一定的随机性)。这是因为可能有多个线程正在getTask()
方法内部的wait()
中等待,使用notifyAll()
将一次性全部唤醒。通常来说,notifyAll()
更安全。有些时候,如果我们的代码逻辑考虑不周,用notify()
会导致只唤醒了一个线程,而其他线程可能永远等待下去醒不过来了。
但是,注意到wait()
方法返回时需要重新获得this
锁。假设当前有3个线程被唤醒,唤醒后,首先要等待执行addTask()
的线程结束此方法后,才能释放this
锁,随后,这3个线程中只能有一个获取到this
锁,剩下两个将继续等待。
再注意到我们在while()
循环中调用wait()
,而不是if
语句:
public synchronized String getTask() throws InterruptedException {
if (queue.isEmpty()) {
this.wait();
}
return queue.remove();
}
这种写法实际上是错误的,因为线程被唤醒时,需要再次获取this
锁。多个线程被唤醒后,只有一个线程能获取this
锁,此刻,该线程执行queue.remove()
可以获取到队列的元素,然而,剩下的线程如果获取this
锁后执行queue.remove()
,此刻队列可能已经没有任何元素了,所以,要始终在while
循环中wait()
,并且每次被唤醒后拿到this
锁就必须再次判断:
while (queue.isEmpty()) {
this.wait();
}
所以,正确编写多线程代码是非常困难的,需要仔细考虑的条件非常多,任何一个地方考虑不周,都会导致多线程运行时不正常。