一、面向对象编程
1、方法
总结:
方法可以让外部代码安全地访问实例字段;
方法是一组执行语句,并且可以执行任意逻辑;
方法内部遇到return时返回,void表示不返回任何值(注意和返回null不同);
外部代码通过public方法操作实例,内部代码可以调用private方法;
理解方法的参数绑定。
2、构造方法
有参构造;
无参构造;
1、先初始化字段;2、执行构造方法的代码进行初始化;
⚠️:一个构造方法可以调用其他构造方法,这样做的目的是便于代码复用。调用其他构造方法的语法是this(…):
class Person {
private String name;
private int age;
public Person(String name, int age) { //有参构造
this.name = name;
this.age = age;
}
public Person(String name) {
this(name, 18); // 调用另一个构造方法Person(String, int)
}
public Person() { //无参构造
this("Unnamed"); // 调用另一个构造方法Person(String)
}
}
总结:
1、实例在创建时通过new操作符会调用其对应的构造方法,构造方法用于初始化实例;
2、没有定义构造方法时,编译器会自动创建一个默认的无参数构造方法;
3、可以定义多个构造方法,编译器根据参数自动判断;
4、可以在一个构造方法内部调用另一个构造方法,便于代码复用。
3、方法重载(OverloadA)
方法名相同,但各自的参数不同,称为方法重载(Overload),注意:方法重载的返回值类型通常都是相同的。
方法重载的目的是,功能类似的方法使用同一名字,更容易记住,因此,调用起来更简单。
例如:
class Hello {
public void hello() {
System.out.println("Hello, world!");
}
public void hello(String name) {
System.out.println("Hello, " + name + "!");
}
public void hello(String name, int age) {
if (age < 18) {
System.out.println("Hi, " + name + "!");
} else {
System.out.println("Hello, " + name + "!");
}
}
}
总结:
1、方法重载是指多个方法的方法名相同,但各自的参数不同;
2、重载方法应该完成类似的功能,参考String的indexOf();
3、重载方法返回值类型应该相同。
4、多态(Polymorphic) | 重写(Override)
多态是指,针对某个类型的方法调用,其真正执行的方法取决于运行时期实际类型的方法。Java的实例方法调用是基于运行时的实际类型的动态调用,而非变量的声明类型。
在继承关系中,子类如果定义了一个与父类方法签名完全相同的方法,被称为重写(Override)
Override和Overload不同:
如果方法签名不同,就是Overload,Overload方法是一个新方法;
如果方法签名相同,并且返回值也相同,就是Override。
总结:
1、子类可以重写父类的方法(Override),重写在子类中改变了父类方法的行为;
2、Java的方法调用总是作用于运行期对象的实际类型,这种行为称为多态;
3、final修饰符有多种作用:
4、final修饰的方法可以阻止被覆写;
5、final修饰的class可以阻止被继承;
6、final修饰的field必须在创建对象时初始化,随后不可修改。
5、继承
Java只允许一个class继承自一个类,因此,一个类有且仅有一个父类。只有Object特殊,它没有父类。
特点:继承有个特点,就是子类无法访问父类的private字段或者private方法。为了让子类可以访问父类的字段,我们需要把private改为protected,用protected修饰的字段可以被子类访问。
class Person {
private String name;
private int age;
public String getName() {...}
public void setName(String name) {...}
public int getAge() {...}
public void setAge(int age) {...}
}
class Student extends Person {
// 不要重复name和age字段/方法,
// 只需要定义新增score字段/方法:
private int score;
public int getScore() { … }
public void setScore(int score) { … }
}
向上转型:
把一个子类类型安全地变为父类类型的赋值,被称为向上转型(upcasting)。
向下转型:
把一个父类类型强制转型为子类类型,就是向下转型(downcasting)
总结:
1、继承是面向对象编程的一种强大的代码复用方式;
2、Java只允许单继承,所有类最终的根类是Object;
3、protected允许子类访问父类的字段和方法;
4、子类的构造方法可以通过super()调用父类的构造方法;
5、可以安全地向上转型为更抽象的类型;
6、可以强制向下转型,最好借助instanceof判断;
7、子类和父类的关系是is,has关系不能用继承。
6、抽象类(abstract)
如果一个class定义了方法,但没有具体执行代码,这个方法就是抽象方法,抽象方法用abstract修饰。
因为无法执行抽象方法,因此这个类也必须申明为抽象类(abstract class)。
使用abstract修饰的类就是抽象类。我们无法实例化一个抽象类:
例如:Person p = new Person(); // 编译错误
总结:
通过abstract定义的方法是抽象方法,它只有定义,没有实现。抽象方法定义了子类必须实现的接口规范;
定义了抽象方法的class必须被定义为抽象类,从抽象类继承的子类必须实现抽象方法;
如果不实现抽象方法,则该子类仍是一个抽象类;
面向抽象编程使得调用者只关心抽象方法的定义,不关心子类的具体实现。
7、接口(interface)
抽象类和接口对比 | 抽象类(abstract class) | 接口(interface) |
继承 | 只能extends一个class | 可以implements多个interface |
字段 | 可以定义实例字段 | 不能定义实例字段 |
抽象方法 | 可以定义抽象方法 | 可以定义抽象方法 |
非抽象方法 | 可以定义非抽象方法 | 可以定义default方法 |
一个interface可以继承自另一个interface。interface继承自interface使用extends,它相当于扩展了接口的方法。
合理设计interface和abstract class的继承关系,可以充分复用代码。一般来说,公共逻辑适合放在abstract class中,具体逻辑放到各个子类,而接口层次代表抽象程度。
总结:
Java的接口(interface)定义了纯抽象规范,一个类可以实现多个接口;
接口也是数据类型,适用于向上转型和向下转型;
接口的所有方法都是抽象方法,接口不能定义实例字段;
接口可以定义default方法(JDK>=1.8)。
8、静态(static)字段、方法
静态方法经常用于工具类。例如:
Arrays.sort()
Math.random()
main方法
总结:
静态字段属于所有实例“共享”的字段,实际上是属于class的字段;
调用静态方法不需要实例,无法访问this,但可以访问静态字段和其他静态方法;
静态方法常用于工具类和辅助方法。
9、作用域
总结:
Java内建的访问权限包括public、protected、private和package权限;
Java在方法内部定义的变量是局部变量,局部变量的作用域从变量声明开始,到一个块结束;
final修饰符不是访问权限,它可以修饰class、field和method;
一个.java文件只能包含一个public类,但可以包含多个非public类。
10、内部类(Inner Class)
如果一个类定义在另一个类的内部,这个类就是内部类
例如:
class Outer {
class Inner {
// 定义了一个Inner Class
}
}
总结:
Java的内部类可分为Inner Class、Anonymous Class和Static Nested Class三种:
Inner Class和Anonymous Class本质上是相同的,都必须依附于Outer Class的实例,即隐含地持有Outer.this实例,并拥有Outer Class的private访问权限;
Static Nested Class是独立类,但拥有Outer Class的private访问权限。
二、Java核心类
1、字符串(String)和编码
String常用方法:
比较:equals:比较的是内容(必须用它)、==:比较的是内存地址
搜索:indexOf(查找的值,开始的索引位置)、lastIndexOf(从后向前寻找)
截取:substring(start,截取的个数)、substr(start,截取的个数)
替换:replace
分割:split(转为数组)
拼接:join (推荐用StringBuilder的append方法进行拼接)
格式化:format
类型转换:value
转为小写:toLowerCase
转为大写:toUpperCase
总结:
1、Java字符串String是不可变对象;
2、字符串操作不改变原字符串内容,而是返回新字符串;
3、常用的字符串操作:提取子串、查找、替换、大小写转换等;
4、Java使用Unicode编码表示String和char;
5、转换编码就是将String和byte[]转换,需要指定编码;
6、转换为byte[]时,始终优先考虑UTF-8编码。
2、String、StringBuilder、StringBuffer
| String | StringBuilder(推荐使用) | StringBuffer |
---|
是否可变 | 不可变,二次赋值只会多创建对象 | 可变,用来高效拼接字符串 | 可变,自带缓冲区,字符串大小超过容量自动扩容 |
线程安全 | 实际上线程安全 | 不安全 | 安全 |
运行速度 | 3 | 1 | 2 |
线程操作 | 线程安全 | 单线程操作、线程不安全 | 多线程操作、线程安全 |
3、包装类
基本类型:byte(1),short(2),int(3),long(8),boolean(1),float(4),double(8),char(2)
包装类型:Byte,Short,Long,Integer,Boolean,Float,Double,Character
引用类型:所有class和interface类型
自动装箱和自动拆箱只发生在编译阶段,装箱和拆箱会影响代码的执行效率
拆箱:包装类-->基本数据类型,一般调用 xxValue() 方法,例如:int x = n.intValue();
装箱:基本数据类型-->包装类,一般调用 valueOf() 方法
总结:
1、Java核心库提供的包装类型可以把基本类型包装为class;
2、自动装箱和自动拆箱都是在编译期完成的(JDK>=1.5);
3、装箱和拆箱会影响执行效率,且拆箱时可能发生NullPointerException;
4、包装类型的比较必须使用equals;
5、整数和浮点数的包装类型都继承自Number;
6、包装类型提供了大量实用方法。
4、JavaBean
JavaBean主要用来传递数据,即把一组数据组合成一个JavaBean便于传输。
例如:
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; }
}
其中:下面这种class被称为JavaBean,也就是常见的get、set方法
// 读方法:
public boolean isChild()
// 写方法:
public void setChild(boolean value)
总结:
1、JavaBean是一种符合命名规范的class,它通过getter和setter来定义属性;
2、属性是一种通用的叫法,并非Java语法规定;
3、可以利用IDE快速生成getter和setter;
4、使用Introspector.getBeanInfo()可以获取属性列表。
5、枚举(enum)
开发过程中的一些常量定义为枚举,例如:
public enum RoleEnum {
ROLE_SUPER, ROLE_ADMIN, ROLE_USER
}
总结:
1、枚举是引用类型,用 equals()方法比较
2、Java使用enum定义枚举类型,它被编译器编译为final class Xxx extends Enum { … };
3、通过name()获取常量定义的字符串,注意不要使用toString();
4、通过ordinal()返回常量定义的顺序(无实质意义);
5、可以为enum编写构造方法、字段和方法
6、enum的构造方法要声明为private,字段强烈建议声明为final;
7、enum适合用在switch语句中。
6、BigInteger、BigDecimal
BigInteger:
BigInteger用于表示任意大小的整数;
BigInteger是不变类,并且继承自Number;
将BigInteger转换成基本类型时可使用 longValueExact() 等方法保证结果准确
BigDecimal:
BigDecimal用于表示精确的小数,常用于财务计算;
比较BigDecimal的值是否相等,必须使用 compareTo() 而不能使用equals()
add(BigDecimal) //BigDecimal对象中的值相加,返回BigDecimal对象
subtract(BigDecimal) //BigDecimal对象中的值相减,返回BigDecimal对象
multiply(BigDecimal) //BigDecimal对象中的值相乘,返回BigDecimal对象
divide(BigDecimal) //BigDecimal对象中的值相除,返回BigDecimal对象
valueOf(double) //返回BigDecimal对象
toString() //将BigDecimal对象中的值转换成字符串
doubleValue() //将BigDecimal对象中的值转换成双精度数
floatValue() //将BigDecimal对象中的值转换成单精度数
longValue() //将BigDecimal对象中的值转换成长整数
intValue() //将BigDecimal对象中的值转换成整数
setScale(int, RoundingMode) //保留几位小数,第一个参数几位,第二个参数保留规则
toPlainString() //返回不带指数字段的BigDecimal的字符串表示形式,非科学计数
ROUND_CEILING //向正无穷方向舍入
ROUND_DOWN //向零方向舍入
ROUND_FLOOR //向负无穷方向舍入
ROUND_HALF_DOWN //向(距离)最近的一边舍入,除非两边(的距离)是相等,如果是这样,向下舍入, 例如1.55 保留一位小数结果为1.5
ROUND_HALF_EVEN //向(距离)最近的一边舍入,除非两边(的距离)是相等,如果是这样,如果保留位数是奇数,使用ROUND_HALF_UP,如果是偶数,使用ROUND_HALF_DOWN
ROUND_HALF_UP //向(距离)最近的一边舍入,除非两边(的距离)是相等,如果是这样,向上舍入, 1.55保留一位小数结果为1.6
ROUND_UNNECESSARY //计算结果是精确的,不需要舍入模式
ROUND_UP //向远离0的方向舍入
7、常用工具类(Math、Random、SecureRandom)
Math:数学计算
绝对值:abs
最大最小值:max、min
xy次方:pow
√x:sqrt
double pi = Math.PI; // 3.14159...
double e = Math.E; // 2.7182818...
Math.sin(Math.PI / 6); // sin(π/6) = 0.5
Random:生成伪随机数
Random r = new Random();
r.nextInt(); // 2071575453,每次都不一样
r.nextInt(10); // 5,生成一个[0,10)之间的int
r.nextLong(); // 8811649292570369305,每次都不一样
r.nextFloat(); // 0.54335...生成一个[0,1)之间的float
r.nextDouble(); // 0.3716...生成一个[0,1)之间的double
SecureRandom:生成安全的随机数
mport 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));
}
}
三、异常处理
1、Java的异常
异常类继承关系图:
┌───────────┐
│ Object │
└───────────┘
▲
│
┌───────────┐
│ Throwable │
└───────────┘
▲
┌─────────┴─────────┐
│ │
┌───────────┐ ┌───────────┐
│ Error │ │ Exception │
└───────────┘ └───────────┘
▲ ▲
┌───────┘ ┌────┴──────────┐
│ │ │
┌─────────────────┐ ┌─────────────────┐┌───────────┐
│OutOfMemoryError │... │RuntimeException ││IOException│...
└─────────────────┘ └─────────────────┘└───────────┘
▲
┌───────────┴─────────────┐
│ │
┌─────────────────────┐ ┌─────────────────────────┐
│NullPointerException │ │IllegalArgumentException │...
└─────────────────────┘ └─────────────────────────┘
Throwable是异常体系的根,它继承自Object。Throwable有两个体系:Error和Exception
一、Error表示严重的错误,必须解决的问题,例如:
1、OutOfMemoryError:内存溢出(出现该错误,1、VM参数指定分配内存太少,2、应用用的太多,用完没释放)
2、NoClassDefFoundError:无法加载某个Class
3、StackOverflowError:栈溢出(1、增加线程堆栈大小(-Xss)2、递归循环出现栈溢出需要修改代码)
二、Exception则是运行时的错误,它可以被捕获并处理,例如:
1、NumberFormatException:数值类型的格式错误
2、FileNotFoundException:未找到文件
3、SocketException:读取网络失败
三、一些异常是程序逻辑编写不对造成的,应该修复程序本身,例如:
1、NullPointerException:对某个null的对象调用方法或字段
2、IndexOutOfBoundsException:数组索引越界
总结:
1、Java使用异常来表示错误,并通过try ... catch捕获异常;
2、Java的异常是class,并且从Throwable继承;
3、Error是无需捕获的严重错误,Exception是应该捕获的可处理的错误;
4、不推荐捕获了异常但不进行任何处理。
5、RuntimeException无需强制捕获,非RuntimeException(Checked Exception)需强制捕获,或者用throws声明;
Java标准库定义的常用异常包括:
Exception
│
├─ RuntimeException(运行时异常,编译期间可以不处理,但是可能发生在运行时期)
│ │
│ ├─ NullPointerException(空指针异常,俗称NPE,通常由JVM抛出)
│ │
│ ├─ IndexOutOfBoundsException(数组索引越界异常)
│ │
│ ├─ SecurityException(安全异常)
│ │
│ └─ IllegalArgumentException(非法参数异常,一般是传递的参数不合法或不正确)
│ │
│ └─ NumberFormatException(数字格式化异常)
│
├─ IOException(输入输出异常,即I/O异常)
│ │
│ ├─ UnsupportedCharsetException(表明操作不支持的异常,在所有 add 和 remove 操作中抛出这个异常)
│ │
│ ├─ FileNotFoundException(文件找不到)
│ │
│ └─ SocketException(一般是传输时候发生的通信异常)
│
├─ ParseException(编译异常,不解决没法运行,必须处理)
│
├─ GeneralSecurityException(通用的安全性异常类)
│
├─ SQLException(访问关系数据库类产生的异常)
│
└─ TimeoutException(链接超时异常)
四、反射
1、Class类
除基本类型外,Java的其他类型全部都是class(包括interface)
JVM为每个加载的class及interface创建了对应的Class实例来保存class及interface的所有信息;
获取一个class对应的Class实例后,就可以获取该class的所有信息;
通过Class实例获取class信息的方法称为反射(Reflection);
JVM总是动态加载class,可以在运行期根据条件来控制加载class。
2、访问字段
Field getField(name):根据字段名获取某个public的field(包括父类)
Field getDeclaredField(name):根据字段名获取当前类的某个field(不包括父类)
Field[] getFields():获取所有public的field(包括父类)
Field[] getDeclaredFields():获取当前类的所有field(不包括父类)
总结:
Java的反射API提供的Field类封装了字段的所有信息:
1、通过Class实例的方法可以获取Field实例:getField(),getFields(),getDeclaredField(),getDeclaredFields();
2、通过Field实例可以获取字段信息:getName(),getType(),getModifiers();
3、通过Field实例可以读取或设置某个对象的字段,如果存在访问限制,要首先调用setAccessible(true)来访问非public字段。
4、通过反射读写字段是一种非常规方法,它会破坏对象的封装。
3、调用方法
Method getMethod(name, Class...):获取某个public的Method(包括父类)
Method getDeclaredMethod(name, Class...):获取当前类的某个Method(不包括父类)
Method[] getMethods():获取所有public的Method(包括父类)
Method[] getDeclaredMethods():获取当前类的所有Method(不包括父类)
总结:
Java的反射API提供的Method对象封装了方法的所有信息:
1、通过Class实例的方法可以获取Method实例:getMethod(),getMethods(),getDeclaredMethod(),getDeclaredMethods();
2、通过Method实例可以获取方法信息:getName(),getReturnType(),getParameterTypes(),getModifiers();
3、通过Method实例可以调用某个对象的方法:Object invoke(Object instance, Object... parameters);
4、通过设置setAccessible(true)来访问非public方法;
5、通过反射调用方法时,仍然遵循多态原则。
调用构造方法:
Constructor对象封装了构造方法的所有信息;
1、通过Class实例的方法可以获取Constructor实例:getConstructor(),getConstructors(),getDeclaredConstructor(),getDeclaredConstructors();
2、通过Constructor实例可以创建一个实例对象:newInstance(Object... parameters); 通过设置setAccessible(true)来访问非public构造方法。
4、动态继承
通过Class对象可以获取继承关系:
Class getSuperclass():获取父类类型;
Class[] getInterfaces():获取当前类实现的所有接口。
通过Class对象的isAssignableFrom()方法可以判断一个向上转型是否可以实现。
5、动态代理(Dynamic Proxy)
JDK动态代理:利用反射机制生成一个实现代理接口的匿名类,在调用具体方法前调用InvokeHandler来处理。
CGLIB动态代理:利用ASM(开源的Java字节码编辑库,操作字节码)开源包,将代理对象类的class文件加载进来,通过修改其字节码生成子类来处理。
区别:JDK代理只能对实现接口的类生成代理;CGlib是针对类实现代理,对指定的类生成一个子类,并覆盖其中的方法,这种通过继承类的实现方式,不能代理final修饰的类。
总结:
1、JDK代理使用的是反射机制实现aop的动态代理,CGLIB代理使用字节码处理框架asm,通过修改字节码生成子类。所以jdk动态代理的方式创建代理对象效率较高,执行效率较低,cglib创建效率较低,执行效率高;
2、JDK动态代理机制是委托机制,具体说动态实现接口类,在动态生成的实现类里面委托hanlder去调用原始实现类方法,CGLIB则使用的继承机制,具体说被代理类和代理类是继承关系,所以代理类是可以赋值给被代理类的,如果被代理类有接口,那么代理类也可以赋值给接口。
五、泛型
泛型就是定义一种模板,例如ArrayList<T>,然后在代码中为用到的类创建对应的ArrayList<类型>:
一、什么是泛型:
1、泛型就是编写模板代码来适应任意类型;
2、泛型的好处是使用时不必对类型进行强制转换,它通过编译器对类型进行检查;
3、注意泛型的继承关系:
可以把ArrayList<Integer>向上转型为List<Integer>(T不能变!),但不能把
ArrayList<Integer>向上转型为ArrayList<Number>(T不能变成父类)。
二、使用泛型
1、使用泛型时,把泛型参数<T>替换为需要的class类型,例:ArrayList<String>,ArrayList<Number>等;
2、可以省略编译器能自动推断出的类型,例如:List<String> list = new ArrayList<>();;
3、不指定泛型参数类型时,编译器会给出警告,且只能将<T>视为Object类型;
4、可以在接口中定义泛型类型,实现此接口的类必须实现正确的泛型类型。
三、编写泛型
public class Pair<T> {
private T first;
private T last;
public Pair(T first, T last) {
this.first = first;
this.last = last;
}
public T getFirst() {
return first;
}
public T getLast() {
return last;
}
}
1、编写泛型时,需要定义泛型类型<T>;
2、静态方法不能引用泛型类型<T>,必须定义其他类型(例如<K>)来实现静态泛型方法;
3、泛型可以同时定义多种类型,例如Map<K, V>。
四、擦拭法
泛型是一种类似”模板代码“的技术,不同语言的泛型实现方式不一定相同。Java语言的泛型实现方式是擦拭法(Type Erasure)。所谓擦拭法是指,虚拟机对泛型其实一无所知,所有的工作都是编译器做的,例如:
//上面写的泛型类在虚拟机执行的代码,Java使用擦拭法实现泛型导致了:
//编译器把类型<T>视为Object;编译器根据<T>实现安全的强制转型。
public class Pair {
private Object first;
private Object last;
public Pair(Object first, Object last) {
this.first = first;
this.last = last;
}
public Object getFirst() {
return first;
}
public Object getLast() {
return last;
}
}
五、extends通配符
1、使用类似<? extends Number>通配符作为方法参数时表示:
方法内部可以调用获取Number引用的方法,例如:Number n = obj.getFirst();;
方法内部无法调用传入Number引用的方法(null除外),例如:obj.setFirst(Number n);。
即一句话总结:使用extends通配符表示可以读,不能写。
2、使用类似<T extends Number>定义泛型类时表示:
泛型类型限定为Number以及Number的子类。
六、super通配符
1、使用类似<? super Integer>通配符作为方法参数时表示:
方法内部可以调用传入Integer引用的方法,例如:obj.setFirst(Integer n);;
方法内部无法调用获取Integer引用的方法(Object除外),例如:Integer n = obj.getFirst();。
即使用super通配符表示只能写不能读。
2、使用extends和super通配符要遵循PECS原则。
3、无限定通配符<?>很少使用,可以用<T>替换,同时它是所有<T>类型的超类。
六、泛型和反射
1、部分反射API是泛型,例如:Class<T>,Constructor<T>;
2、可以声明带泛型的数组,但不能直接创建带泛型的数组,必须强制转型;
3、可以通过Array.newInstance(Class<T>, int)创建T[]数组,需要强制转型;
4、同时使用泛型和可变参数时需要特别小心。
六、集合(Collection)
1、集合基本概念
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<String> list = new ArrayList<>(); // 只能放入String类型
Java访问集合总是通过统一的方式--迭代器(Iterator)来实现,它最明显的好处在于无需知道集合内部元素是按什么方式存储的。
2、使用List
| ArrayList(数组、推荐使用) | LinkedList(链表) |
---|
获取指定元素 | 速度很快 | 需要从头开始查找元素 |
添加元素到末尾 | 速度很快 | 速度很快 |
在指定位置添加/删除 | 需要移动元素 | 不需要移动元素 |
内存占用 | 少 | 较大 |
在集合类中,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 │ │ │ │ │ │
└───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┘
我们考察List<E>接口,可以看到几个主要的接口方法:
在末尾添加一个元素: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 │ │
└───┴───┘ └───┴───┘ └───┴───┘ └───┴───┘
总结:
1、数组
数组在某指定位置添加删除元素时候,需移动该位置后面的其他元素,且数组不可以扩容,只能手动复制数组进行扩容
2、ArryList(数组)
其内部实现机制依旧是数组,ArryList在内部的实际大小是:size+1(即当定义的一个拥有5个元素的ArryList数组时,其在内部大小为6,但是我们操作时候依然为5),当ArryList执行元素插入填满实际内部大小时,ArryList会在内部自动复制数组扩容
3、LinkedList(链表)
是一种链表形式的集合,内部每个元素都会指向下一个元素
4、List接口允许我们添加重复的元素
5、List还允许添加null
6、迭代器遍历数组
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);
}
}
}
7、List转换为Arry
1、调用toArray()方法直接返回一个Object[]数组:(这种方法会丢失类型信息,所以实际应用很少)
List<String> list = List.of("apple", "pear", "banana");
Object[] array = list.toArray();
for (Object s : array) {
System.out.println(s);
}
2、给toArray(T[])传入一个类型相同的Array,List内部自动把元素复制到传入的Array中:
List<Integer> list = List.of(12, 34, 56);
Integer[] array = list.toArray(new Integer[3]);
for (Integer n : array) {
System.out.println(n);
}
3、通过List接口定义的T[] toArray(IntFunction<T[]> generator)方法:
Integer[] array = list.toArray(Integer[]::new);
8、Arry转换成List
1、通过List.of(T...)方法最简单:
Integer[] array = { 1, 2, 3 };
List<Integer> list = List.of(array);
2、可以使用Arrays.asList(T...)方法把数组转换成List
9、给定一组连续或者的整数,例如:10,11,12,……,20,但其中缺失一个数字,试找出缺失的数字:
思路:1、(连续)把给定的整数便利相加得sum1,再把不缺失数字的数组相加得sum2,sum2-sum1=缺失的数字
2、(不连续)直接使用list.contains()方法,该方法可以直接判断集合中是否包含某个元素
static int findMissingNumber(int start, int end, List<Integer> list) {
int i = 0;
for ( i = start; i <= end; i++) {
if(!list.contains(i)) {
break;
}
}
3、使用equals方法
List是一种有序链表:List内部按照放入元素的先后顺序存放,并且每个元素都可以通过索引确定自己的位置。
List还提供了boolean contains(Object o)方法来判断List是否包含某个指定元素。此外,int indexOf(Object o)方法可以返回某个元素的索引,如果元素不存在,就返回-1。
equals()方法要求我们必须满足以下条件:
自反性(Reflexive):对于非null的x来说,x.equals(x)必须返回true;
对称性(Symmetric):对于非null的x和y来说,如果x.equals(y)为true,则y.equals(x)也必须为true;
传递性(Transitive):对于非null的x、y和z来说,如果x.equals(y)为true,y.equals(z)也为true,那么x.equals(z)也必须为true;
一致性(Consistent):对于非null的x和y来说,只要x和y状态不变,则x.equals(y)总是一致地返回true或者false;对null的比较:即x.equals(null)永远返回false。
总结:1、先确定实例“相等”的逻辑,即哪些字段相等,就认为实例相等;
2、用instanceof判断传入的待比较的Object是不是当前类型,如果是,继续比较,否则,返回false;
3、对引用类型用Objects.equals()比较,对基本类型直接用==比较。
4、使用Objects.equals()比较两个引用类型是否相等的目的是省去了判断null的麻烦。两个引用类型都是null时它们也是相等的。
5、如果不调用List的contains()、indexOf()这些方法,那么放入的元素就不需要实现equals()方法。
4、使用Map
Map<K, V>是一种键-值映射表,当我们调用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,因为放入相同的key,只会把原有的key-value对应的value给替换掉,虽然key不能重复,但value是可以重复的.
遍历Map:
总结:
1、Map是一种映射表,可以通过key快速查找value。
2、可以通过for each遍历keySet(),也可以通过for each遍历entrySet(),直接获取key-value。
3、最常用的一种Map实现是HashMap。
5、HashMao(重写equals和hashcode)
底层原理:https://blog.csdn.net/fengxi_tan/article/details/106629280
HashMap是基于哈希表的Map接口的非同步实现,元素以键值对的形式存放,并且允许null键和null值,因为key值唯一(不能重复),因此,null键只有一个。另外,hashmap不保证元素存储的顺序,是一种无序的,和放入的顺序并不相同(此类不保证映射的顺序,特别是它不保证该顺序恒久不变)。HashMap是线程不安全的。但是HashMap的查找效率非常高,其内部通过空间换时间的方法,用一个大数组存储所有value,并根据key直接计算出value存储在哪个索引。
Hashmap中依据key的hash值来确定value存储位置,所以一定要重写hashCode方法,而重写equals方法,是为了解决hash冲突,如果两个key的hash值相同,就会调用equals方法,比较key值是否相同,在存储时:如果equals结果相同就覆盖更新value值,如果不同就用List他们都存储起来。在取出来是:如果equals结果相同就返回当前value值,如果不同就遍历List中下一个元素。即要key与hash同时匹配才会认为是同一个key。
总结:
1、要正确使用HashMap,作为key的类必须正确覆写equals()和hashCode()方法
2、一个类如果覆写了equals(),就必须覆写hashCode(),并且覆写规则是:
1、如果equals()返回true,则hashCode()返回值必须相等;
2、如果equals()返回false,则hashCode()返回值尽量不要相等。
3、实现hashCode()方法可以通过Objects.hashCode()辅助方法实现。
6、使用EnumMap
Java集合库提供的EnumMap,它在内部以一个非常紧凑的数组存储value,并且根据enum类型的key直接定位到内部数组的索引,并不需要计算hashCode(),不但效率最高,而且没有额外的空间浪费。
代码示例:
import java.time.DayOfWeek;
import java.util.*;
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));
}
}
总结:
1、如果Map的key是enum类型,推荐使用EnumMap,既保证速度,也不浪费空间。
2、使用EnumMap的时候,根据面向抽象编程的原则,应持有Map接口。
7、使用TreeMap
HashMap是一种以空间换时间的映射表,它的实现原理决定了内部的Key是无序的,即遍历HashMap的Key时,其顺序是不可预测的(但每个Key都会遍历一次且仅遍历一次)。
还有一种Map,它在内部会对Key进行排序,这种Map就是SortedMap。SortedMap保证遍历时以Key的顺序来进行排序。注意:SortedMap是接口,它的实现类是TreeMap。
┌───┐
│Map│
└───┘
▲
┌────┴─────┐
│ │
┌───────┐ ┌─────────┐
│HashMap│ │SortedMap│
└───────┘ └─────────┘
▲
│
┌─────────┐
│ TreeMap │
└─────────┘
总结:
1、SortedMap在遍历时严格按照Key的顺序遍历,最常用的实现类是TreeMap;
2、作为SortedMap的Key必须实现Comparable接口(String、Integer已经默认实现),或者传入Comparator;
3、要严格按照compare()规范实现比较逻辑,否则,TreeMap将不能正常工作。
4、TreeMap不使用equals()和hashCode()。
5、注意到Comparator接口要求实现一个比较方法,它负责比较传入的两个元素a和b,如果a<b,则返回负数,通常 是-1,如果a==b,则返回0,如果a>b,则返回正数,通常是1
8、使用Set(元素去重)
如果我们只需要存储不重复的key,并不需要存储映射的value,那么就可以使用Set(相当于不储存Value的且没有索引的Map),经常用Set用于去除重复元素。它主要提供以下几个方法:
1、将元素添加进Set<E>:boolean add(E e)
2、将元素从Set<E>删除:boolean remove(Object e)
3、判断是否包含元素:boolean contains(Object e)
因为放入Set的元素和Map的key类似,都要正确实现equals()和hashCode()方法,否则该元素无法正确地放入Set。最常用的Set实现类是HashSet,实际上,HashSet仅仅是对HashMap的一个简单封装。
Set接口并不保证有序,而SortedSet接口则保证元素是有序的:
HashSet是无序的,因为它实现了Set接口,并没有实现SortedSet接口;
TreeSet是有序的,因为它实现了SortedSet接口。
用一张图表示:
┌───┐
│Set│ 无序
└───┘
▲
┌────┴─────┐
│无序 │有序
┌───────┐ ┌─────────┐
│HashSet│ │SortedSet│
└───────┘ └─────────┘
▲
│有序
┌─────────┐
│ TreeSet │ 添加的元素必须实现Comparable接口或创建TreeSet时传入Comparator对象
└─────────┘
在聊天软件中,发送方发送消息时,遇到网络超时后就会自动重发,因此,接收方可能会收到重复的消息,在显示给用户看的时候,需要首先去重,则可以使用Set进行消息去重
总结:
1、Set用于存储不重复的元素集合:
放入HashSet的元素与作为HashMap的key要求相同;
放入TreeSet的元素与作为TreeMap的Key要求相同;
2、利用Set可以去除重复元素;
3、遍历SortedSet按照元素的排序顺序遍历,也可以自定义排序算法。
9、队列(Queue,先进先出)
基本知识:
1、栈(stack):线性表,先进后出,单端队列(只有一个出入口,即增删都在一头)
2、队列(Queue):线性表,先进先出,单进单出的双端队列(一头进一头出,即一头用于增加一头用于删除)
3、数组:线性数据结构,在内存中开辟一端连续的存储空间,并在此空间放置元素
4、链表:线性表,不连续的储存空间;查询慢从头查找,增删快;单向链表元素无序,时间复杂度O(n);双向链表元 素有序,LinkedList就是典型的双向链表结构,即可当作队列使用,又可以当作栈来使用
Queue实际上是实现了一个先进先出(FIFO:First In First Out)的有序表。它和List的区别在于,List可以在任意位置添加和删除元素,而Queue只有两个操作:
1、把元素添加到队列末尾;
2、从队列头部取出元素。
队列接口Queue定义了以下几个方法:
1、int size():获取队列长度;
2、boolean add(E)/boolean offer(E):添加元素到队尾;
3、E remove()/E poll():获取队首元素并从队列中删除;
4、E element()/E peek():获取队首元素但并不从队列中删除。
throw Exception 返回false或null
添加元素到队尾 add(E e) boolean offer(E e)
取队首元素并删除 E remove() E poll()
取队首元素但不删除 E element() E peek()
LinkedList即实现了List接口,又实现了Queue接口,但是,在使用的时候,如果我们把它当作List,就获取List的引用,如果我们把它当作Queue,就获取Queue的引用:
// 这是一个List:
List<String> list = new LinkedList<>();
// 这是一个Queue:
Queue<String> queue = new LinkedList<>();
总结:
1、队列Queue实现了一个先进先出(FIFO)的数据结构:
通过add()/offer()方法将元素添加到队尾;
通过remove()/poll()从队首获取元素并删除;
通过element()/peek()从队首获取元素但不删除。
2、要避免把null添加到队列。
10、优先队列(PriorityQueue)
在银行柜台办业务时,我们假设只有一个柜台在办理业务,但是办理业务的人很多,怎么办?可以每个人先取一个号,例如:A1、A2、A3……然后,按照号码顺序依次办理,实际上这就是一个Queue。如果这时来了一个VIP客户,他的号码是V1,虽然当前排队的是A10、A11、A12……但是柜台下一个呼叫的客户号码却是V1。这个时候,我们发现,要实现“VIP插队”的业务,用Queue就不行了,因为Queue会严格按FIFO的原则取出队首元素。我们需要的是优先队列:PriorityQueue。
PriorityQueue和Queue的区别在于,它的出队顺序与元素的优先级有关,对PriorityQueue调用remove()或poll()方法,返回的总是优先级最高的元素。
总结:
1、PriorityQueue实现了一个优先队列:从队首获取元素时,总是获取优先级最高的元素。
2、PriorityQueue默认按元素比较的顺序排序(必须实现Comparable接口),也可以通过Comparator自定义排序算法(元素就不必实现Comparable接口)。
例如:
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;
}
}
11、双端队列(Deque)
允许两头都进,两头都出,这种队列叫双端队列(Double Ended Queue),学名Deque,Deque接口实际上扩展自Queue。Java集合提供了接口Deque来实现一个双端队列,它的功能是:
1、既可以添加到队尾,也可以添加到队首;
2、既可以从队首获取,又可以从队尾获取。
// 这是一个List:
List<String> list = new LinkedList<>();
// 这是一个Queue:
Queue<String> queue = new LinkedList<>();
//这是一个Deque
Deque<String> deque = new LinkedList<>();
| 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() |
12、栈(Stack,后进先出)
Stack只有入栈和出栈的操作:
1、把元素压栈:push(E);
2、把栈顶的元素“弹出”:pop();
3、取栈顶元素但不弹出:peek()。
在Java中,我们用Deque可以实现Stack的功能:
1、把元素压栈:push(E)/addFirst(E);
2、把栈顶的元素“弹出”:pop()/removeFirst();
3、取栈顶元素但不弹出:peek()/peekFirst()。
Stack的作用:
1、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;
}
2、JVM会创建方法调用栈,每调用一个方法时,先将参数压栈,然后执行对应的方法;当方法返回时,返回值压栈,调用方法通过出栈操作获得方法返回值。因为方法调用栈有容量限制,嵌套调用过多会造成栈溢出,即引发StackOverflowError:
// 测试无限递归调用
public class Main {
public static void main(String[] args) {
increase(1);
}
static int increase(int x) {
return increase(x) + 1;
}
}
13、使用Iterator
Java的集合类都可以使用for each循环,List、Set和Queue会迭代每个元素,Map会迭代每个key。
Iterator是一种抽象的数据访问模型。使用Iterator模式进行迭代的好处有:
1、对任何集合都采用同一种访问模型;
2、调用者对集合内部结构一无所知;
3、集合类返回的Iterator对象知道如何迭代。
4、Java提供了标准的迭代器模型,即集合类实现java.util.Iterable接口,返回java.util.Iterator实例。
14、使用Collections
Collections是JDK提供的工具类,位于java.util包中。它提供了一系列静态方法,能更方便地操作各种集合。
创建空集合:
1、创建空List:List<T> emptyList()
2、创建空Map:Map<K, V> emptyMap()
3、创建空Set:Set<T> emptySet()
创建单元素集合:(单元素集合也是不可变集合,无法向其中添加或删除元素)
1、创建一个元素的List:List<T> singletonList(T o)
2、创建一个元素的Map:Map<K, V> singletonMap(K key, V value)
3、创建一个元素的Set:Set<T> singleton(T o)
不可变集合:
封装成不可变List:List<T> unmodifiableList(List<? extends T> list)
封装成不可变Set:Set<T> unmodifiableSet(Set<? extends T> set)
封装成不可变Map:Map<K, V> unmodifiableMap(Map<? extends K, ? extends V> m)
线程安全集合:
变为线程安全的List:List<T> synchronizedList(List<T> list)
变为线程安全的Set:Set<T> synchronizedSet(Set<T> s)
变为线程安全的Map:Map<K,V> synchronizedMap(Map<K,V> m)
排序:
List<String> list = new ArrayList<>();
Collections.sort(list);
洗牌:
List<Integer> list = new ArrayList<>();
Collections.shuffle(list);
七、IO(IO包)
1、File对象
文件和目录:
1、boolean canRead():是否可读;
2、boolean canWrite():是否可写;
3、boolean canExecute():是否可执行;
4、long length():文件字节大小。
创建和删除文件:
1、File对象表示一个文件时,可以通过createNewFile()创建一个新文件,用delete()删除该文件
File file = new File("/path/to/file");
if (file.createNewFile()) {
// 文件创建成功:
if (file.delete()) {
// 删除文件成功:
}
}
2、File对象提供了createTempFile()来创建一个临时文件,以及deleteOnExit()在JVM退出时自动删除文件。
public class Main {
public static void main(String[] args) throws IOException {
File f = File.createTempFile("tmp-", ".txt"); // 提供临时文件的前缀和后缀
f.deleteOnExit(); // JVM退出时自动删除
System.out.println(f.isFile());
System.out.println(f.getAbsolutePath());
}
}
遍历文件和目录:
1、当File对象表示一个目录时,可以使用list()和listFiles()列出目录下的文件和子目录名。listFiles()提供了一系列重载方法,可以过滤不想要的文件和目录,详情见java.io.*包中的方法
创建和删除目录:
1、boolean mkdir():创建当前File对象表示的目录;
2、boolean mkdirs():创建当前File对象表示的目录,并在必要时将不存在的父目录也创建出来;
3、boolean delete():删除当前File对象表示的目录,当前目录必须为空才能删除成功。
Path(java.nio.file包内):如果需要对目录进行复杂的拼接、遍历等操作,使用Path对象更方便。
import java.io.*;
import java.nio.file.*;
public class Main {
public static void main(String[] args) throws IOException {
Path p1 = Paths.get(".", "project", "study"); // 构造一个Path对象
System.out.println(p1);
Path p2 = p1.toAbsolutePath(); // 转换为绝对路径
System.out.println(p2);
Path p3 = p2.normalize(); // 转换为规范路径
System.out.println(p3);
File f = p3.toFile(); // 转换为File对象
System.out.println(f);
for (Path p : Paths.get("..").toAbsolutePath()) { // 可以直接遍历Path
System.out.println(" " + p);
}
}
}
总结:
1、Java标准库的java.io.File对象表示一个文件或者目录;
2、创建File对象本身不涉及IO操作;
3、可以获取路径/绝对路径/规范路径:getPath()/getAbsolutePath()/getCanonicalPath();
4、可以获取目录的文件和子目录:list()/listFiles();
5、可以创建或删除文件和目录。
2、字节输入流(InputStream)
InputStream并不是一个接口,而是一个抽象类,它是所有输入流的超类。这个抽象类定义的一个最重要的方法就是int read(),签名如下:public abstract int read() throws IOException;
FileInputStream:
FileInputStream是InputStream的一个子类。顾名思义,FileInputStream就是从文件流中读取数据,例如:
⚠️:InputStream和OutputStream都是通过close()方法来关闭流。关闭流就会释放对应的底层资源。在读取或写入IO流的过程中,可能会发生错误,例如,文件不存在导致无法读取,没有写权限导致写入失败,等等,这些底层错误由Java虚拟机自动封装成IOException异常并抛出。因此所有与IO操作相关的代码都必须正确处理IOException。
可以利用Java 7引入的新的try(resource)的语法,只需要编写try语句,让编译器自动为我们关闭资源。推荐的写法如下:
public void readFile() throws IOException {
try (InputStream input = new FileInputStream("src/readme.txt")) {
int n;
while ((n = input.read()) != -1) {
System.out.println(n);
}
} // 编译器在此自动为我们写入finally并调用close()
}
缓冲:
在读取流的时候,一次读取一个字节并不是最高效的方法。很多流支持一次性读取多个字节到缓冲区,对于文件和网络流来说,利用缓冲区一次性读取多个字节效率往往要高很多。InputStream提供了两个重载方法来支持读取多个字节:
1、int read(byte[] b):读取若干字节并填充到byte[]数组,返回读取的字节数
2、int read(byte[] b, int off, int len):指定byte[]数组的偏移量和最大填充数
public void readFile() throws IOException {
try (InputStream input = new FileInputStream("src/readme.txt")) {
// 定义1000个字节大小的缓冲区:
byte[] buffer = new byte[1000];
int n;
while ((n = input.read(buffer)) != -1) { // 读取到缓冲区
System.out.println("read " + n + " bytes.");
}
}
}
阻塞:
在调用InputStream的read()方法读取数据时,我们说read()方法是阻塞(Blocking)的。它的意思是,对于下面的代码,执行到第二行代码时,必须等read()方法返回后才能继续。因为读取IO流相比执行普通代码,速度会慢很多,因此,无法确定read()方法调用到底要花费多长时间。
int n;
n = input.read(); // 必须等待read()方法返回才能执行下一行代码
int m = n;
InputStream实现类:
1、ByteArrayInputStream可以在内存中模拟一个InputStream:
2、ByteArrayInputStream实际上是把一个byte[]数组在内存中变成一个InputStream,虽然实际应用不多,但测试的时候,可以用它来构造一个InputStream。
总结:
1、Java标准库的java.io.InputStream定义了所有输入流的超类:
2、FileInputStream实现了文件流输入;
3、ByteArrayInputStream在内存中模拟一个字节流输入。
4、总是使用try(resource)来保证InputStream正确关闭。
3、字节输出流(OutputStream)
OutputStream也是抽象类,它是所有输出流的超类。这个抽象类定义的一个最重要的方法就是void write(int b),签名如下:public abstract void write(int b) throws IOException;
FileOutputStream:
1、将若干个字节写入文件流:
public void writeFile() throws IOException {
OutputStream output = new FileOutputStream("out/readme.txt");
output.write(72); // H
output.write(101); // e
output.write(108); // l
output.write(108); // l
output.write(111); // o
output.close();
}
2、每次写入一个字节非常麻烦,一次性写入若干字节可以用OutputStream提供的重载方法void write(byte[])来实现:
public void writeFile() throws IOException {
try (OutputStream output = new FileOutputStream("out/readme.txt")) {
output.write("Hello".getBytes("UTF-8")); // Hello
} // 编译器在此自动为我们写入finally并调用close()
}
阻塞:
和InputStream一样,OutputStream的write()方法也是阻塞的。
OutputStream实现类:
1、ByteArrayOutputStream可以在内存中模拟一个OutputStream
2、同时操作多个AutoCloseable资源时,在try(resource) { ... }语句中可以同时写出多个资源,用;隔开。例如,同时读写两个文件:
// 读取input.txt,写入output.txt:
try (InputStream input = new FileInputStream("input.txt");
OutputStream output = new FileOutputStream("output.txt"))
{
input.transferTo(output); // transferTo的作用是?
}
总结:
1、Java标准库的java.io.OutputStream定义了所有输出流的超类:
2、FileOutputStream实现了文件流输出;
3、ByteArrayOutputStream在内存中模拟一个字节流输出。
4、某些情况下需要手动调用OutputStream的flush()方法来强制输出缓冲区。
5、总是使用try(resource)来保证OutputStream正确关闭。
4、Filter模式
为了解决依赖继承会导致子类数量失控的问题,JDK首先将InputStream分为两大类:
1、一类是直接提供数据的基础InputStream,例如:
FileInputStream;ByteArrayInputStream;ServletInputStream;...
2、一类是提供额外附加功能的InputStream,例如:
BufferedInputStream;DigestInputStream;CipherInputStream;...
这种通过一个“基础”组件再叠加各种“附加”功能组件的模式,称之为Filter模式(或者装饰器模式:Decorator)。它可以让我们通过少量的类来实现各种功能的组合:
┌─────────────┐
│ InputStream │
└─────────────┘
▲ ▲
┌────────────────────┐ │ │ ┌─────────────────┐
│ FileInputStream │─┤ └─│FilterInputStream│
└────────────────────┘ │ └─────────────────┘
┌────────────────────┐ │ ▲ ┌───────────────────┐
│ByteArrayInputStream│─┤ ├─│BufferedInputStream│
└────────────────────┘ │ │ └───────────────────┘
┌────────────────────┐ │ │ ┌───────────────────┐
│ ServletInputStream │─┘ ├─│ DataInputStream │
└────────────────────┘ │ └───────────────────┘
│ ┌───────────────────┐
└─│CheckedInputStream │
└───────────────────┘
┌─────────────┐
│OutputStream │
└─────────────┘
▲ ▲
┌─────────────────────┐ │ │ ┌──────────────────┐
│ FileOutputStream │─┤ └─│FilterOutputStream│
└─────────────────────┘ │ └──────────────────┘
┌─────────────────────┐ │ ▲ ┌────────────────────┐
│ByteArrayOutputStream│─┤ ├─│BufferedOutputStream│
└─────────────────────┘ │ │ └────────────────────┘
┌─────────────────────┐ │ │ ┌────────────────────┐
│ ServletOutputStream │─┘ ├─│ DataOutputStream │
└─────────────────────┘ │ └────────────────────┘
│ ┌────────────────────┐
└─│CheckedOutputStream │
└────────────────────┘
总结:
1、Java的IO标准库使用Filter模式为InputStream和OutputStream增加功能:
2、可以把一个InputStream和任意个FilterInputStream组合;
3、可以把一个OutputStream和任意个FilterOutputStream组合。
4、Filter模式可以在运行期动态增加功能(又称Decorator模式)。
5、操作Zip(ZipInputStream)
ZipInputStream是一种FilterInputStream,它可以直接读取zip包的内容:(JarInputStream是从ZipInputStream派生,它增加的主要功能是直接读取jar文件里面的MANIFEST.MF文件。因为本质上jar包就是zip包,只是额外附加了一些固定的描述文件。)
┌───────────────────┐
│ InputStream │
└───────────────────┘
▲
│
┌───────────────────┐
│ FilterInputStream │
└───────────────────┘
▲
│
┌───────────────────┐
│InflaterInputStream│
└───────────────────┘
▲
│
┌───────────────────┐
│ ZipInputStream │
└───────────────────┘
▲
│
┌───────────────────┐
│ JarInputStream │
└───────────────────┘
读取zip包(read):
1、我们要创建一个ZipInputStream,通常是传入一个FileInputStream作为数据源,然后,循环调用getNextEntry(),直到返回null,表示zip流结束。一个ZipEntry表示一个压缩文件或目录,如果是压缩文件,我们就用read()方法不断读取,直到返回-1:
try (ZipInputStream zip = new ZipInputStream(new FileInputStream(...))) {
ZipEntry entry = null;
while ((entry = zip.getNextEntry()) != null) {
String name = entry.getName();
if (!entry.isDirectory()) {
int n;
while ((n = zip.read()) != -1) {
...
}
}
}
}
写入zip包(write):
1、ZipOutputStream是一种FilterOutputStream,它可以直接写入内容到zip包。我们要先创建一个ZipOutputStream,通常是包装一个FileOutputStream,然后,每写入一个文件前,先调用putNextEntry(),然后用write()写入byte[]数据,写入完毕后调用closeEntry()结束这个文件的打包。如果要实现目录层次结构,new ZipEntry(name)传入的name要用相对路径。
try (ZipOutputStream zip = new ZipOutputStream(new FileOutputStream(...))) {
File[] files = ...
for (File file : files) {
zip.putNextEntry(new ZipEntry(file.getName()));
zip.write(Files.readAllBytes(file.toPath()));
zip.closeEntry();
}
}
总结:
1、ZipInputStream可以读取zip格式的流,ZipOutputStream可以把多份数据写入zip包;
2、配合FileInputStream和FileOutputStream就可以读写zip文件。
6、序列化(Serializable)
序列化是指把一个Java对象变成二进制内容,本质上就是一个byte[]数组。为什么要把Java对象序列化呢?因为序列化后可以把byte[]保存到文件中,或者把byte[]通过网络传输到远程,这样,就相当于把Java对象存储到文件或者通过网络传输出去了。有序列化,就有反序列化,即把一个二进制内容(也就是byte[]数组)变回Java对象。有了反序列化,保存到文件中的byte[]数组又可以“变回”Java对象,或者从网络上读取byte[]并把它“变回”Java对象。
一个Java对象要能序列化,必须实现一个特殊的java.io.Serializable接口,它的定义如下:
public interface Serializable {}
序列化:
1、把一个Java对象变为byte[]数组,需要使用ObjectOutputStream
import java.io.*;
import java.util.Arrays;
public class Main {
public static void main(String[] args) throws IOException {
ByteArrayOutputStream buffer = new ByteArrayOutputStream();
try (ObjectOutputStream output = new ObjectOutputStream(buffer)) {
// 写入int:
output.writeInt(12345);
// 写入String:
output.writeUTF("Hello");
// 写入Object:
output.writeObject(Double.valueOf(123.456));
}
System.out.println(Arrays.toString(buffer.toByteArray()));
}
}
反序列化:
1、把一个二进制内容(也就是byte[]数组)变回Java对象需要使用ObjectInputStream
try (ObjectInputStream input = new ObjectInputStream(...)) {
int n = input.readInt();
String s = input.readUTF();
Double d = (Double) input.readObject();
}
2、为了避免这种class定义变动导致的不兼容(),Java的序列化允许class定义一个特殊的serialVersionUID静态变量,用于标识Java类的序列化“版本”,通常可以由IDE自动生成。如果增加或修改了字段,可以改变serialVersionUID的值,这样就能自动阻止不匹配的class版本:
public class Person implements Serializable {
private static final long serialVersionUID = 2709425275741743919L;
}
3、反序列化时,由JVM直接构造出Java对象,不调用构造方法,构造方法内部的代码,在反序列化时根本不可能执行。
安全性:
1、因为Java的序列化机制可以导致一个实例能直接从byte[]数组创建,而不经过构造方法,因此,它存在一定的安全隐患。一个精心构造的byte[]数组被反序列化后可以执行特定的Java代码,从而导致严重的安全漏洞。实际上,Java本身提供的基于对象的序列化和反序列化机制既存在安全性问题,也存在兼容性问题。更好的序列化方法是通过JSON这样的通用数据结构来实现,只输出基本类型(包括String)的内容,而不存储任何与代码相关的信息。
总结:
1、可序列化的Java对象必须实现java.io.Serializable接口,类似Serializable这样的空接口被称为“标记接口”(Marker Interface);
2、反序列化时不调用构造方法,可设置serialVersionUID作为版本号(非必需);
3、Java的序列化机制仅适用于Java,如果需要与其它语言交换数据,必须使用通用的序列化方法,例如JSON。
7、字符输入流(Reader)
InputStream | Reader |
---|
字节流,以byte 为单位 | 字符流,以char 为单位 |
读取字节(-1,0~255):int read() | 读取字符(-1,0~65535):int read() |
读到字节数组:int read(byte[] b) | 读到字符数组:int read(char[] c) |
1、java.io.Reader是所有字符输入流的超类,它最主要方法是:public int read() throws IOException;这个方法读取字符流的下一个字符,并返回字符表示的int,范围是0~65535。如果已读到末尾,返回-1。
2、Reader还提供了一次性读取若干字符并填充到char[]数组的方法:public int read(char[] c) throws IOException
FileReader:
1、FileReader是Reader的一个子类,它可以打开文件并获取Reader。且用try (resource)来保证Reader在无论有没有IO错误的时候都能够正确地关闭:
public void readFile() throws IOException {
//指定编码防止乱码
try (Reader reader = new FileReader("src/readme.txt", StandardCharsets.UTF_8)) {
char[] buffer = new char[1000];//缓冲区
int n;
while ((n = reader.read(buffer)) != -1) {
System.out.println("read " + n + " chars.");
}
}
}
CharArrayReader:
1、CharArrayReader可以在内存中模拟一个Reader,它的作用实际上是把一个char[]数组变成一个Reader,这和ByteArrayInputStream非常类似:
try (Reader reader = new CharArrayReader("Hello".toCharArray())) {}
StringReader:
1、StringReader可以直接把String作为数据源,它和CharArrayReader几乎一样:
try (Reader reader = new StringReader("Hello")) {}
InputStreamReader:
1、Reader本质上是一个基于InputStream的byte到char的转换器,那么,如果我们已经有一个InputStream,想把它转换为Reader,是完全可行的。InputStreamReader就是这样一个转换器,它可以把任何InputStream转换为Reader:
// 持有InputStream:
InputStream input = new FileInputStream("src/readme.txt");
// 变换为Reader:
Reader reader = new InputStreamReader(input, "UTF-8");
2、构造InputStreamReader时,我们需要传入InputStream,还需要指定编码,就可以得到一个Reader对象。上述代码可以通过try (resource)更简洁地改写如下:
try (Reader reader = new InputStreamReader(new FileInputStream("src/readme.txt"), "UTF-8")) {}
总结:
1、Reader定义了所有字符输入流的超类:
2、FileReader实现了文件字符流输入,使用时需要指定编码;
3、CharArrayReader和StringReader可以在内存中模拟一个字符流输入。
4、Reader是基于InputStream构造的:可以通过InputStreamReader在指定编码的同时将任何InputStream转换为Reader。
5、总是使用try (resource)保证Reader正确关闭。
8、字符输出流(Writer)
OutputStream | Writer |
---|
字节流,以byte 为单位 | 字符流,以char 为单位 |
写入字节(0~255):void write(int b) | 写入字符(0~65535):void write(int c) |
写入字节数组:void write(byte[] b) | 写入字符数组:void write(char[] c) |
无对应方法 | 写入String:void write(String s) |
Writer是所有字符输出流的超类,它提供的方法主要有:
1、写入一个字符(0~65535):void write(int c);
2、写入字符数组的所有字符:void write(char[] c);
3、写入String表示的所有字符:void write(String s)
FileWriter:
1、FileWriter就是向文件中写入字符流的Writer。它的使用方法和FileReader类似:
try (Writer writer = new FileWriter("readme.txt", StandardCharsets.UTF_8)) {
writer.write('H'); // 写入单个字符
writer.write("Hello".toCharArray()); // 写入char[]
writer.write("Hello"); // 写入String
}
CharArrayWriter:
1、CharArrayWriter可以在内存中创建一个Writer,它的作用实际上是构造一个缓冲区,可以写入char,最后得到写入的char[]数组,这和ByteArrayOutputStream非常类似:
try (CharArrayWriter writer = new CharArrayWriter()) {
writer.write(65);
writer.write(66);
writer.write(67);
char[] data = writer.toCharArray(); // { 'A', 'B', 'C' }
}
StringWriter:
1、StringWriter也是一个基于内存的Writer,它和CharArrayWriter类似。实际上,StringWriter在内部维护了一个StringBuffer,并对外提供了Writer接口。
OutputStreamWriter:
1、除了CharArrayWriter和StringWriter外,普通的Writer实际上是基于OutputStream构造的,它接收char,然后在内部自动转换成一个或多个byte,并写入OutputStream。因此,OutputStreamWriter就是一个将任意的OutputStream转换为Writer的转换器:
try (Writer writer = new OutputStreamWriter(new FileOutputStream("readme.txt"), "UTF-8")) {}//上述代码实际上就是FileWriter的一种实现方式。这和上一节的InputStreamReader是一样的。
总结:
1、Writer定义了所有字符输出流的超类:
2、FileWriter实现了文件字符流输出;
3、CharArrayWriter和StringWriter在内存中模拟一个字符流输出。
4、使用try (resource)保证Writer正确关闭。
5、Writer是基于OutputStream构造的,可以通过OutputStreamWriter将OutputStream转换为Writer,转换时需要指定编码。
9、PrintStream、PrintWriter
PrintStream:
1、PrintStream是一种FilterOutputStream,它在OutputStream的接口上,额外提供了一些写入各种数据类型的方法:
写入int:print(int)
写入boolean:print(boolean)
写入String:print(String)
写入Object:print(Object),实际上相当于print(object.toString())
以及对应的一组println()方法,它会自动加上换行符。
PrintWriter:
1、PrintStream最终输出的总是byte数据,而PrintWriter则是扩展了Writer接口,它的print()/println()方法最终输出的是char数据。两者的使用方法几乎是一模一样的:
import java.io.*;
public class Main {
public static void main(String[] args) {
StringWriter buffer = new StringWriter();
try (PrintWriter pw = new PrintWriter(buffer)) {
pw.println("Hello");
pw.println(12345);
pw.println(true);
}
System.out.println(buffer.toString());
}
}
总结:
1、PrintStream是一种能接收各种数据类型的输出,打印数据时比较方便:
2、System.out是标准输出;
3、System.err是标准错误输出。
4、PrintWriter是基于Writer的输出。
10、使用Files
Files是java.nio包里面的类,且Files提供的读写方法,受内存限制,只能读写小文件,例如配置文件等,不可一次读入几个G的大文件。读写大型文件仍然要使用文件流,每次只读写一部分文件内容。Files工具类还有copy()、delete()、exists()、move()等快捷方法操作文件和目录。
byte[] data = Files.readAllBytes(Path.of("/path/to/file.txt"));
// 默认使用UTF-8编码读取:
String content1 = Files.readString(Path.of("/path/to/file.txt"));
// 可指定编码:
String content2 = Files.readString(Path.of("/path", "to", "file.txt"), StandardCharsets.ISO_8859_1);
// 按行读取并返回每行内容:
List<String> lines = Files.readAllLines(Path.of("/path/to/file.txt"));
// 写入二进制文件:
byte[] data = Files.write(Path.of("/path/to/file.txt"), data);
// 写入文本并指定编码:
Files.writeString(Path.of("/path/to/file.txt"), "文本内容...", StandardCharsets.ISO_8859_1);
// 按行写入文本:
List<String> lines = Files.write(Path.of("/path/to/file.txt"), lines);
八、多线程
1、多线程基础
CPU执行代码都是一条一条顺序执行的,但是,即使是单核cpu,也可以同时运行多个任务。因为操作系统执行多任务实际上就是让CPU对多个任务轮流交替执行。即使是多核CPU,因为通常任务的数量远远多于CPU的核数,所以任务也是交替执行的。
例如,假设我们有语文、数学、英语3门作业要做,每个作业需要30分钟。我们把这3门作业看成是3个任务,可以做1分钟语文作业,再做1分钟数学作业,再做1分钟英语作业,这样轮流做下去,在某些人眼里看来,做作业的速度就非常快,看上去就像同时在做3门作业一样,类似的,操作系统轮流让多个任务交替执行,例如,让浏览器执行0.001秒,让QQ执行0.001秒,再让音乐播放器执行0.001秒,在人看来,CPU就是在同时执行多个任务。
进程:
1、在计算机中,我们把一个任务称为一个进程,浏览器就是一个进程,视频播放器是另一个进程,类似的,音乐播放器和Word都是进程。某些进程内部还需要同时执行多个子任务。例如,我们在使用Word时,Word可以让我们一边打字,一边进行拼写检查,同时还可以在后台进行打印,我们把子任务称为线程。
2、进程和线程的关系就是:一个进程可以包含一个或多个线程,但至少会有一个线程。
┌──────────┐
│Process │
│┌────────┐│
┌──────────┐││ Thread ││┌──────────┐
│Process ││└────────┘││Process │
│┌────────┐││┌────────┐││┌────────┐│
┌──────────┐││ Thread ││││ Thread ││││ Thread ││
│Process ││└────────┘││└────────┘││└────────┘│
│┌────────┐││┌────────┐││┌────────┐││┌────────┐│
││ Thread ││││ Thread ││││ Thread ││││ Thread ││
│└────────┘││└────────┘││└────────┘││└────────┘│
└──────────┘└──────────┘└──────────┘└──────────┘
┌──────────────────────────────────────────────┐
│ Operating System │
└──────────────────────────────────────────────┘
操作系统调度的最小任务单位其实不是进程,而是线程。常用的Windows、Linux等操作系统都采用抢占式多任务,如何调度线程完全由操作系统决定,程序自己不能决定什么时候执行,以及执行多长时间。
3、因为同一个应用程序,既可以有多个进程,也可以有多个线程,因此,实现多任务的方法,有以下几种:
1、多进程模式(每个进程只有一个线程):
┌──────────┐ ┌──────────┐ ┌──────────┐
│Process │ │Process │ │Process │
│┌────────┐│ │┌────────┐│ │┌────────┐│
││ Thread ││ ││ Thread ││ ││ Thread ││
│└────────┘│ │└────────┘│ │└────────┘│
└──────────┘ └──────────┘ └──────────┘
2、多线程模式(一个进程有多个线程):
┌────────────────────┐
│Process │
│┌────────┐┌────────┐│
││ Thread ││ Thread ││
│└────────┘└────────┘│
│┌────────┐┌────────┐│
││ Thread ││ Thread ││
│└────────┘└────────┘│
└────────────────────┘
3、多进程+多线程模式(复杂度最高):
┌──────────┐┌──────────┐┌──────────┐
│Process ││Process ││Process │
│┌────────┐││┌────────┐││┌────────┐│
││ Thread ││││ Thread ││││ Thread ││
│└────────┘││└────────┘││└────────┘│
│┌────────┐││┌────────┐││┌────────┐│
││ Thread ││││ Thread ││││ Thread ││
│└────────┘││└────────┘││└────────┘│
└──────────┘└──────────┘└──────────┘
进程 vs 线程:
1、进程和线程是包含关系,但是多任务既可以由多进程实现,也可以由单进程内的多线程实现,还可以混合多进程+多线程。具体采用哪种方式,要考虑到进程和线程的特点。
2、和多线程相比,多进程的缺点在于:
1、缺点:
创建进程比创建线程开销大,尤其是在Windows系统上;
进程间通信比线程间通信要慢,因为线程间通信就是读写同一个变量,速度很快。
2、优点:
多进程的优点在于:
多进程稳定性比多线程高,因为在多进程的情况下,一个进程崩溃不会影响其他进程,而在多线程的情况下,任何一个线程崩溃会直接导致整个进程崩溃。
多线程:
Java语言内置了多线程支持:一个Java程序实际上是一个JVM进程,JVM进程用一个主线程来执行main()方法,在main()方法内部,我们又可以启动多个线程。此外,JVM还有负责垃圾回收的其他工作线程等。因此,对于大多数Java程序来说,我们说多任务,实际上是说如何使用多线程实现多任务。和单线程相比,多线程编程的特点在于:多线程经常需要读写共享数据,并且需要同步。例如,播放电影时,就必须由一个线程播放视频,另一个线程播放音频,两个线程需要协调运行,否则画面和声音就不同步。因此,多线程编程的复杂度高,调试更困难。
Java多线程编程的特点又在于:
多线程模型是Java程序最基本的并发模型;
后续读写网络、数据库、Web开发等都依赖Java多线程模型。
2、创建新线程
当Java程序启动的时候,实际上是启动了一个JVM进程,然后,JVM启动主线程来执行main()方法。在main()方法中,我们又可以启动其他线程。
创建线程:
1、继承Thread类,重写该类的run()方法。
class MyThread extends Thread {
private int i = 0;
@Overrid
public void run() {
for (i = 0; i < 100; i++) {
System.out.println(Thread.currentThread().getName() + " " + i);
}
}
}
2、实现Runnable接口,重写run方法,实现Runnable接口的实现类的实例对象作为Thread构造函数的target
class MyRunnable implements Runnable {
private int i = 0;
@Overrid
public void run() {
for (i = 0; i < 100; i++) {
System.out.println(Thread.currentThread().getName() + " " + i);
}
}
}
3、通过Callable和FutureTask创建线程
a:创建Callable接口的实现类 ,并实现Call方法
b:创建Callable实现类的实现,使用FutureTask类包装Callable对象,该FutureTask对象封装了Callable对象的Call方法的返回值
c:使用FutureTask对象作为Thread对象的target创建并启动线程
d:调用FutureTask对象的get()来获取子线程执行结束的返回值
public class ThreadTest {
public static void main(String[] args) {
Callable<Integer> myCallable = new MyCallable(); // 创建MyCallable对象
FutureTask<Integer> ft = new FutureTask<Integer>(myCallable); //使用FutureTask来包装MyCallable对象
for (int i = 0; i < 100; i++) {
System.out.println(Thread.currentThread().getName() + " " + i);
if (i == 30) {
Thread thread = new Thread(ft); //FutureTask对象作为Thread对象的target创建新的线程
thread.start(); //线程进入到就绪状态
}
}
System.out.println("主线程for循环执行完毕..");
try {
int sum = ft.get(); //取得新创建的新线程中的call()方法返回的结果
System.out.println("sum = " + sum);
} catch (InterruptedException e) {
e.printStackTrace();
} catch (ExecutionException e) {
e.printStackTrace();
}
}
}
class MyCallable implements Callable<Integer> {
private int i = 0;
// 与run()方法不同的是,call()方法具有返回值
@Override
public Integer call() {
int sum = 0;
for (; i < 100; i++) {
System.out.println(Thread.currentThread().getName() + " " + i);
sum += i;
}
return sum;
}
}
4、通过线程池创建线程
public class ThreadDemo05{
private static int POOL_NUM = 10; //线程池数量
public static void main(String[] args) throws InterruptedException {
// TODO Auto-generated method stub
ExecutorService executorService = Executors.newFixedThreadPool(5);
for(int i = 0; i<POOL_NUM; i++) {
RunnableThread thread = new RunnableThread();
//Thread.sleep(1000);
executorService.execute(thread);
}
//关闭线程池
executorService.shutdown();
}
}
class RunnableThread implements Runnable {
@Override
public void run() {
System.out.println("线程池创建的线程:" + Thread.currentThread().getName() + " ");
}
线程的优先级:
Thread.setPriority(int n) // 1~10, 默认值5
总结:
1、Java用Thread对象表示一个线程,通过调用start()启动一个新线程;
2、一个线程对象只能调用一次start()方法;
3、线程的执行代码写在run()方法中;
4、线程调度由操作系统决定,程序本身无法决定调度顺序;
5、Thread.sleep()可以把当前线程暂停一段时间。
3、线程的状态
在Java程序中,一个线程对象只能调用一次start()方法启动新线程,并在新线程中执行run()方法。一旦run()方法执行完毕,线程就结束了。
1、因此,Java线程的状态有以下几种:
1、New:新创建的线程,尚未执行;
2、Runnable:运行中的线程,正在执行run()方法的Java代码;
4、Blocked:运行中的线程,因为某些操作被阻塞而挂起;
5、Waiting:运行中的线程,因为某些操作在等待中;
6、Timed Waiting:运行中的线程,因为执行sleep()方法正在计时等待;
7、Terminated:线程已终止,因为run()方法执行完毕。
┌─────────────┐
│ New │
└─────────────┘
│
▼
┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐
┌─────────────┐ ┌─────────────┐
││ Runnable │ │ Blocked ││
└─────────────┘ └─────────────┘
│┌─────────────┐ ┌─────────────┐│
│ Waiting │ │Timed Waiting│
│└─────────────┘ └─────────────┘│
─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
│
▼
┌─────────────┐
│ Terminated │
└─────────────┘
当线程启动后,它可以在Runnable、Blocked、Waiting和Timed Waiting这几个状态之间切换,直到最后变成Terminated状态,线程终止。
2、线程终止的原因有:
1、线程正常终止:run()方法执行到return语句返回;
2、线程意外终止:run()方法因为未捕获的异常导致线程终止;
3、对某个线程的Thread实例调用stop()方法强制终止(强烈不推荐使用)
3、一个线程还可以等待另一个线程直到其运行结束。例如,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)的重载方法也可以指定一个等待时间,超过等待时间后就不再继续等待。
总结:
1、Java线程对象Thread的状态包括:New、Runnable、Blocked、Waiting、Timed Waiting和Terminated;
2、通过对另一个线程对象调用join()方法可以等待其执行结束;
3、可以指定等待时间,超过等待时间线程仍然没有结束就不再等待;
4、对已经运行结束的线程调用join()方法会立刻返回。
4、中断线程(interrupt、running)
如果线程需要执行一个长时间任务,就可能需要能中断线程。中断线程就是其他线程给该线程发一个信号,该线程收到信号后结束执行run()方法,使得自身线程能立刻结束运行。
我们举个栗子:假设从网络下载一个100M的文件,如果网速很慢,用户等得不耐烦,就可能在下载过程中点“取消”,这时,程序就需要中断下载线程的执行。
中断一个线程非常简单,只需要在其他线程中对目标线程调用interrupt()方法,目标线程需要反复检测自身状态是否是interrupted状态,如果是,就立刻结束运行。
1、通过interrupt中断线程,但是如果线程处在等待状态,例如,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!");
}
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;
}
}
}
}
2、另一个常用的中断线程的方法是设置标志位。我们通常会用一个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虚拟机中,变量的值保存在主内存中,但是,当线程访问变量时,它会先获取一个副本,并保存在自己的工作内存中。
如果线程修改了变量的值,虚拟机会在某个时刻把修改后的值回写到主内存,但是,这个时间是不确定的!因此,volatile关键字的目的是告诉虚拟机:每次访问变量时,总是获取主内存的最新值;每次修改变量后,立刻回写到主内存。
volatile关键字解决的是可见性问题:当一个线程修改了某个共享变量的值,其他线程能够立刻看到修改后的值。
总结:
1、对目标线程调用interrupt()方法可以请求中断一个线程,目标线程通过检测isInterrupted()标志获取自身是否已中断。如果目标线程处于等待状态,该线程会捕获到InterruptedException;
2、目标线程检测到isInterrupted()为true或者捕获了InterruptedException都应该立刻结束自身线程;
3、通过标志位判断需要正确使用volatile关键字;
4、volatile关键字解决了共享变量在线程间的可见性问题。
5、守护线程(Daemon Thread)
1、Java程序入口就是由JVM启动main线程,main线程又可以启动其他线程。当所有线程都运行结束时,JVM退出,进程结束。如果有一个线程没有退出,JVM进程就不会退出。所以,必须保证所有线程都能及时结束。
2、守护线程是指为其他线程服务的线程。在JVM中,所有非守护线程都执行完毕后,无论有没有守护线程,虚拟机都会自动退出。因此,JVM退出时,不必关心守护线程是否已结束。
在守护线程中,编写代码要注意:守护线程不能持有任何需要关闭的资源,例如打开文件等,因为虚拟机退出时,守护线程没有任何机会来关闭文件,这会导致数据丢失。
3、创建守护线程:
Thread t = new MyThread();
t.setDaemon(true);
t.start();
总结:
1、守护线程是为其他线程服务的线程;
2、所有非守护线程都执行完毕后,虚拟机退出;
3、守护线程不能持有需要关闭的资源(如打开文件等)。
6、线程同步(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 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的值是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 lock1 = new Object();
//public static final Object lock2 = new Object();
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++) {
//锁住lock对象,假如此处锁住locak1对象,输出结果依旧不正确
//synchronized(Counter.lock1)
synchronized(Counter.lock) {
Counter.count += 1;
}
}
}
}
class DecThread extends Thread {
public void run() {
for (int i=0; i<10000; i++) {
//锁住lock对象,假如此处锁住locak2对象,输出结果依旧不正确
//synchronized(Counter.lock2)
synchronized(Counter.lock) {
Counter.count -= 1;
}
}
}
}
⚠️:synchronized(Counter.lock) { // 获取锁
...
} // 释放锁
它表示用 Counter.lock 实例作为锁,两个线程在执行各自的 synchronized(Counter.lock) { ... } 代码块时,必须先获得锁,才能进入代码块进行。执行结束后,在synchronized语句块结束会自动释放锁。这样一来,对Counter.count 变量进行读写就不可能同时进行。
上述代码无论运行多少次,最终结果都是0。使用synchronized解决了多线程同步访问共享变量的正确性问题。但是,它的缺点是带来了性能下降。
因为synchronized代码块无法并发执行。此外,加锁和解锁需要消耗一定的时间,所以,synchronized会降低程序的执行效率。
我们来概括一下如何使用synchronized:
1、找出修改共享变量的线程代码块;
2、选择一个共享实例作为锁;
3、使用synchronized(lockObject) { ... }。
4、在使用synchronized时,不必担心抛出异常。因为无论是否有异常,都会在synchronized结束处正确释放锁
5、在使用synchronized锁时,必须锁住的是同一个对象!这使得两个线程各自都可以同时获得锁:因为JVM只保证同一个锁在任意时刻只能被一个线程获取,但两个不同的锁在同一时刻可以被两个线程分别获取。
不需要synchronized的操作:
1、JVM规范定义了几种原子操作:
1、基本类型(long和double除外)赋值,例如:int n = m;
2、引用类型赋值,例如:List<String> list = anotherList。
3、long和double是64位数据,JVM没有明确规定64位赋值操作是不是一个原子操作,不过在x64平台的JVM是把long和double的赋值作为原子操作实现的。
2、单条原子操作的语句不需要同步。例如:
public void set(int m) {
synchronized(lock) {
this.value = m;
}
}
3、对引用也是类似。例如:
public void set(String s) {
this.value = s;
}
4、但是,如果是多行赋值语句,就必须保证是同步操作,例如:
class Pair {
int first;
int last;
public void set(int first, int last) {
synchronized(this) {
this.first = first;
this.last = last;
}
}
}
5、通过一些巧妙的转换,可以把非原子操作变为原子操作。例如,上述代码如果改造成:
class Pair {
int[] pair;
public void set(int first, int last) {
int[] ps = new int[] { first, last };
//this.pair = ps是引用赋值的原子操作。
this.pair = ps;
}
}
总结:
1、多线程同时读写共享变量时,会造成逻辑错误,因此需要通过synchronized同步;
2、同步的本质就是给指定对象加锁,加锁后才能继续执行后续代码;
3、注意加锁对象必须是同一个实例;
4、对JVM定义的单个原子操作不需要同步,每个线程都有各自的局部变量,互不影响,并且互不可见,并不需要同步。
7、同步方法(synchronized)
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();
如果一个类被设计为允许多线程正确访问,我们就说这个类就是“线程安全”的(thread-safe),上面的Counter类就是线程安全的。
Java标准库的 java.lang.StringBuffer 也是线程安全的。还有一些不变类,例如String,Integer,LocalDate,它们的所有成员变量都是final,多线程同时访问时只能读不能写,这些不变类也是线程安全的。
最后,类似Math这些只提供静态方法,没有成员变量的类,也是线程安全的。
除了上述几种少数情况,大部分类,例如ArrayList,都是非线程安全的类,我们不能在多线程中修改它们。但是,如果所有线程都只读取,不写入,那么ArrayList是可以安全地在线程间共享的。
等价改写上述代码,用synchronized修饰的方法就是同步方法,它表示整个方法都必须用this实例加锁。
public void add(int n) {
synchronized(this) { // 锁住this
count += n;
} // 解锁
}
public synchronized void add(int n) { // 锁住this
count += n;
} // 解锁
对于static方法,是没有this实例的,因为static方法是针对类而不是实例。但是我们注意到任何一个类都有一个由JVM自动创建的Class实例,因此,对static方法添加synchronized,锁住的是该类的Class实例。上述synchronized static方法实际上相当于:
public class Counter {
public static void test(int n) {
synchronized(Counter.class) {
.......
}
}
}
总结:
1、用synchronized修饰方法可以把整个方法变为同步代码块,synchronized方法加锁对象是this;
2、通过合理的设计和数据封装可以让一个类变为“线程安全”;
3、一个类没有特殊说明,默认不是thread-safe(没有特殊说明时,一个类默认是非线程安全的。);
4、多线程能否安全访问某个非线程安全的实例,需要具体问题具体分析。
8、死锁(锁互相等待)
Java的线程锁是可重入的锁(JVM允许同一个线程重复获取同一个锁,这种能被同一个线程反复获取的锁,就叫做可重入锁。)。什么是可重入的锁?例子:
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、在获取多个锁的时候,不同线程获取多个不同对象的锁可能导致死锁。对于上述代码,线程1和线程2如果分别执行add()和dec()方法时:
线程1:进入add(),获得lockA;
线程2:进入dec(),获得lockB。
2、随后:
线程1:准备获得lockB,失败,等待中;
线程2:准备获得lockA,失败,等待中。
此时,两个线程各自持有不同的锁,然后各自试图获取对方手里的锁,造成了双方无限等待下去,这就是死锁。死锁发生后,没有任何机制能解除死锁,只能强制结束JVM进程。
3、避免死锁
线程获取锁的顺序要一致。即严格按照先获取lockA,再获取lockB的顺序,改写dec()方法如下:
public void dec(int m) {
synchronized(lockA) { // 获得lockA的锁
this.value -= m;
synchronized(lockB) { // 获得lockB的锁
this.another -= m;
} // 释放lockB的锁
} // 释放lockA的锁
}
总结:
1、Java的synchronized锁是可重入锁;
2、死锁产生的条件是多线程各自持有不同的锁,并互相试图获取对方已持有的锁,导致无限等待;
3、避免死锁的方法是多线程获取锁的顺序要一致。
9、wait(等待)、notify(唤醒)
在Java程序中,synchronized解决了多线程竞争的问题。例如,对于一个任务管理器,多个线程同时往队列中添加任务,可以用synchronized加锁:
class TaskQueue {
Queue<String> queue = new LinkedList<>();
public synchronized void addTask(String s) {
this.queue.add(s);
}
}
多线程协调运行的原则:
1、当条件不满足时,线程进入等待状态;当条件满足时,线程被唤醒,继续执行任务。
public synchronized String getTask() {
while (queue.isEmpty()) {
//wait()方法必须在当前获取的锁对象上调用,这里获取的是this锁,因此调用this.wait()。调用wait()方法后,线程进入等待状态,wait()方法不会返回,直到将来某个时刻,线程从等待状态被其他线程唤醒后,wait()方法才会返回,然后,继续执行下一条语句。必须在synchronized块中才能调用wait()方法,因为wait()方法调用时,会释放线程获得的锁,wait()方法返回后,线程又会重新试图获得锁。
this.wait();
}
return queue.remove();
}
2、让等待的线程被重新唤醒:
public synchronized void addTask(String s) {
this.queue.add(s);
this.notify(); // 唤醒在this锁等待的线程
}
总结:
1、wait和notify用于多线程协调运行:
2、在synchronized内部可以调用wait()使线程进入等待状态;
3、必须在已获得的锁对象上调用wait()方法;
4、在synchronized内部可以调用notify()或notifyAll()唤醒其他等待线程;
5、必须在已获得的锁对象上调用notify()或notifyAll()方法;notifyAll()更安全
6、已唤醒的线程还需要重新获得锁后才能继续执行。
10、互斥锁(独占锁)(ReentrantLock >=s ynchronized)
java.util.concurrent 包,提供了大量更高级的并发功能,能大大简化多线程程序的编写
synchronized关键字用于加锁,但这种锁一是很重,二是获取时必须一直等待,没有额外的尝试机制。synchronized是Java语言层面提供的语法,所以我们不需要考虑异常,而ReentrantLock是Java代码实现的锁,我们就必须先获取锁,然后在finally中正确释放锁。顾名思义,ReentrantLock是可重入锁,它和synchronized一样,一个线程可以多次获取同一个锁。
和synchronized不同的是,ReentrantLock可以尝试获取锁:
if (lock.tryLock(1, TimeUnit.SECONDS)) {
try {
...
} finally {
lock.unlock();
}
}
上述代码在尝试获取锁的时候,最多等待1秒。如果1秒后仍未获取到锁,tryLock()返回false,程序就可以做一些额外处理,而不是无限等待下去。
总结:
1、ReentrantLock可以替代synchronized进行同步;
2、ReentrantLock获取锁更安全;
3、必须先获取到锁,再进入try {...}代码块,最后使用finally保证释放锁;
4、可以使用tryLock()尝试获取锁,线程在tryLock()失败的时候不会导致死锁。
11、Condition(>wait、notify)
实现线程在条件不满足时等待,条件满足时唤醒:
ReentrantLock + Condition >= synchronized + wait + notify
示例:
class TaskQueue {
private final Lock lock = new ReentrantLock();
private final Condition condition = lock.newCondition();
private Queue<String> queue = new LinkedList<>();
public void addTask(String s) {
lock.lock();
try {
queue.add(s);
condition.signalAll();
} finally {
lock.unlock();
}
}
public String getTask() {
lock.lock();
try {
while (queue.isEmpty()) {
condition.await();
}
return queue.remove();
} finally {
lock.unlock();
}
}
}
使用Condition时,引用的Condition对象必须从Lock实例的newCondition()返回,这样才能获得一个绑定了Lock实例的Condition实例。Condition提供的await()、signal()、signalAll()原理和synchronized锁对象的wait()、notify()、notifyAll()是一致的,并且其行为也是一样的:
1、await()会释放当前锁,进入等待状态;
2、signal()会唤醒某个等待线程;
3、signalAll()会唤醒所有等待线程;
4、唤醒线程从await()返回后需要重新获得锁。
5、和tryLock()类似,await()可以在等待指定时间后,如果还没有被其他线程通过signal()或signalAll()唤醒,可以自己醒来:
if (condition.await(1, TimeUnit.SECOND)) {
// 被其他线程唤醒
} else {
// 指定时间内没有被其他线程唤醒
}
总结:
1、Condition可以替代wait和notify;
2、Condition对象必须从Lock对象获取。
12、读写锁(ReadWriteLock)
ReentrantLock保证了只有一个线程可以执行临界区代码,但是有些时候,这种保护有点过头。因为我们发现,任何时刻,只允许一个线程修改,也就是调用inc()方法是必须获取锁,但是,get()方法只读取数据,不修改数据,它实际上允许多个线程同时调用。实际上我们想要的是:允许多个线程同时读,但只要有一个线程在写,其他线程就必须等待:
读 写
读 允许 不允许
写 不允许 不允许
ReadWriteLock可以解决这个问题,它保证:
1、只允许一个线程写入(其他线程既不能写入也不能读取);
2、没有写入时,多个线程允许同时读(提高性能)。
public class Counter {
private final ReadWriteLock rwlock = new ReentrantReadWriteLock();
private final Lock rlock = rwlock.readLock();
private final Lock wlock = rwlock.writeLock();
private int[] counts = new int[10];
public void inc(int index) {
wlock.lock(); // 加写锁
try {
counts[index] += 1;
} finally {
wlock.unlock(); // 释放写锁
}
}
public int[] get() {
rlock.lock(); // 加读锁
try {
return Arrays.copyOf(counts, counts.length);
} finally {
rlock.unlock(); // 释放读锁
}
}
}
把读写操作分别用读锁和写锁来加锁,在读取时,多个线程可以同时获得读锁,这样就大大提高了并发读的执行效率。使用ReadWriteLock时,适用条件是同一个数据,有大量线程读取,但仅有少数线程修改。例如,一个论坛的帖子,回复可以看做写入操作,它是不频繁的,但是,浏览可以看做读取操作,是非常频繁的,这种情况就可以使用ReadWriteLock。
总结:
1、使用ReadWriteLock可以提高读取效率:
2、ReadWriteLock只允许一个线程写入;
3、ReadWriteLock允许多个线程在没有写入时同时读取;
4、ReadWriteLock适合读多写少的场景。
13、新读写锁(StampedLock)
StampedLock是读写锁的实现,对比 ReentrantReadWriteLock 主要不同是该锁不允许重入,多了乐观读的功能,使用上会更加复杂一些,但是具有更好的性能表现。StampedLock 的状态由版本和读写锁持有计数组成。
StampedLock和ReadWriteLock相比,改进之处在于:读的过程中也允许获取写锁后写入!这样一来,我们读的数据
就可能不一致,所以,需要一点额外的代码来判断读的过程中是否有写入,这种读锁是一种乐观锁。乐观锁的意思就是乐观地估计读的过程中大概率不会有写入,因此被称为乐观锁。反过来,悲观锁则是读的过程中拒绝有写入,也就是写入必须等待。显然乐观锁的并发效率更高,但一旦有小概率的写入导致读取的数据不一致,需要能检测出来,再读一遍就行。
public class Point {
private final StampedLock stampedLock = new StampedLock();
private double x;
private double y;
public void move(double deltaX, double deltaY) {
long stamp = stampedLock.writeLock(); // 获取写锁
try {
x += deltaX;
y += deltaY;
} finally {
stampedLock.unlockWrite(stamp); // 释放写锁
}
}
public double distanceFromOrigin() {
long stamp = stampedLock.tryOptimisticRead(); // 获得一个乐观读锁
// 注意下面两行代码不是原子操作
// 假设x,y = (100,200)
double currentX = x;
// 此处已读取到x=100,但x,y可能被写线程修改为(300,400)
double currentY = y;
// 此处已读取到y,如果没有写入,读取是正确的(100,200)
// 如果有写入,读取是错误的(100,400)
if (!stampedLock.validate(stamp)) { // 检查乐观读锁后是否有其他写锁发生
stamp = stampedLock.readLock(); // 获取一个悲观读锁
try {
currentX = x;
currentY = y;
} finally {
stampedLock.unlockRead(stamp); // 释放悲观读锁
}
}
return Math.sqrt(currentX * currentX + currentY * currentY);
}
}
总结:
1、StampedLock提供了乐观读锁,可取代ReadWriteLock以进一步提升并发性能;
2、StampedLock是不可重入锁。
14、Concurrent集合
interface | non-thread-safe | thread-safe |
---|
List | ArrayList | CopyOnWriteArrayList |
Map | HashMap | ConcurrentHashMap |
Set | HashSet / TreeSet | CopyOnWriteArraySet |
Queue | ArrayDeque / LinkedList | ArrayBlockingQueue / LinkedBlockingQueue |
Deque | ArrayDeque / LinkedList | LinkedBlockingDeque |
通过ReentrantLock和Condition实现一个BlockingQueue(阻塞队列):
public class TaskQueue {
private final Lock lock = new ReentrantLock();
private final Condition condition = lock.newCondition();
private Queue<String> queue = new LinkedList<>();
public void addTask(String s) {
lock.lock();
try {
queue.add(s);
condition.signalAll();
} finally {
lock.unlock();
}
}
public String getTask() {
lock.lock();
try {
while (queue.isEmpty()) {
condition.await();
}
return queue.remove();
} finally {
lock.unlock();
}
}
}
BlockingQueue的意思就是说,当一个线程调用这个TaskQueue的getTask()方法时,该方法内部可能会让线程变成等待状态,直到队列条件满足不为空,线程被唤醒后,getTask()方法才会返回。因为BlockingQueue非常有用,所以我们不必自己编写,可以直接使用Java标准库的 java.util.concurrent包提供的线程安全的集合:ArrayBlockingQueue。 除了BlockingQueue外,针对List、Map、Set、Deque等,java.util.concurrent包也提供了对应的并发集合类。我们归纳一下:如上所示
总结:
1、使用 java.util.concurrent 包提供的线程安全的并发集合可以大大简化多线程编程:
2、多线程同时读写并发集合是安全的;
3、尽量使用Java标准库提供的并发集合,避免自己编写同步代码。
15、原子操作(Atomic)
Java的java.util.concurrent包除了提供底层锁、并发集合外,还提供了一组原子操作的封装类,它们位于java.util.concurrent.atomic包。我们以AtomicInteger为例,它提供的主要操作有:
1、增加值并返回新值:int addAndGet(int delta)
2、加1后返回新值:int incrementAndGet()
3、获取当前值:int get()
4、用CAS方式设置:int compareAndSet(int expect, int update)
public int incrementAndGet(AtomicInteger var) {
int prev, next;
do {
prev = var.get();
next = prev + 1;
} while ( ! var.compareAndSet(prev, next));
return next;
}
Atomic类是通过无锁(lock-free)的方式实现的线程安全(thread-safe)访问。它的主要原理是利用了CAS:Compare and Set。CAS是指,在这个操作中,如果AtomicInteger的当前值是prev,那么就更新为next,返回true。如果AtomicInteger的当前值不是prev,就什么也不干,返回false。通过CAS操作并配合do ... while循环,即使其他线程修改了AtomicInteger的值,最终的结果也是正确的。
总结:
1、使用java.util.concurrent.atomic提供的原子操作可以简化多线程编程:
2、原子操作实现了无锁的线程安全;
3、适用于计数器,累加器等。
16、使用线程池(ThreadPoolExecutor)
为什么要使用线程池?
答:使用线程池的好处是减少在创建和销毁线程上所花的时间以及系统资源的开销,解决资源不足的问题。如果不使用线程池,有可能造成系统创建大量同类线程而导致消耗完内存或者“过度切换”的问题。
1、提高性能;2、避免浪费资源;3、方便管理线程
Java语言虽然内置了多线程支持,启动一个新线程非常方便,但是,创建线程需要操作系统资源(线程资源,栈空间等),频繁创建和销毁大量线程需要消耗大量时间。如果可以复用一组线程,那么我们就可以把很多小任务让一组线程来执行,而不是一个任务对应一个新线程。
这种能接收大量小任务并进行分发处理的就是线程池。简单地说,线程池内部维护了若干个线程,没有任务的时候,这些线程都处于等待状态。如果有新任务,就分配一个空闲线程执行。如果所有线程都处于忙碌状态,新任务要么放入队列等待,要么增加一个新线程进行处理。
┌─────┐ execute ┌──────────────────┐
│Task1│─────────>│ThreadPool │
├─────┤ │┌───────┐┌───────┐│
│Task2│ ││Thread1││Thread2││
├─────┤ │└───────┘└───────┘│
│Task3│ │┌───────┐┌───────┐│
├─────┤ ││Thread3││Thread4││
│Task4│ │└───────┘└───────┘│
├─────┤ └──────────────────┘
│Task5│
├─────┤
│Task6│
└─────┘
默认的四种线程池:
1、newFixedThreadPool
创建固定大小的线程池。每次提交一个任务就创建一个线程,直到线程达到线程池的最大大小。线程池的大小一旦达到最大值就会保持不变,如果某个线程因为执行异常而结束,那么线程池会补充一个新线程。
2、newCachedThreadPool
创建一个可缓存的线程池。如果线程池的大小超过了处理任务所需要的线程,那么就会回收部分空闲(60秒不执行任务)的线程,当任务数增加时,此线程池又可以智能的添加新线程来处理任务。此线程池不会对线程池大小做限制,线程池大小完全依赖于操作系统(或者说JVM)能够创建的最大线程大小。
3、newSingleThreadExecutor
创建一个单线程的线程池。这个线程池只有一个线程在工作,也就是相当于单线程串行执行所有任务。如果这个唯一的线程因为异常结束,那么会有一个新的线程替代它。此线程池保证所有任务的执行顺序按照任务的提交顺序执行。
4、newScheduledThreadPool
创建一个大小无限的线程池。此线程池支持定时以及周期性执行任务的需求。
Java标准库提供了ExecutorService接口表示线程池,它的典型用法如下:
// 创建固定大小的线程池:
//线程池不允许使用Executors去创建,而要通过ThreadPoolExecutor方式,这一方面是由于jdk中Executor框架虽然提供了如newFixedThreadPool()、newSingleThreadExecutor()、newCachedThreadPool()等创建线程池的方法,但都有其局限性,不够灵活;
//另外由于前面几种方法内部也是通过ThreadPoolExecutor方式实现,使用ThreadPoolExecutor有助于大家明确线程池的运行规则,创建符合自己的业务场景需要的线程池,避免资源耗尽的风险。
ExecutorService executor = Executors.newFixedThreadPool(3);
// 提交任务:
executor.submit(task1);
executor.submit(task2);
executor.submit(task3);
executor.submit(task4);
executor.submit(task5);
FixedThreadPool:
import java.util.concurrent.*;
public class Main {
public static void main(String[] args) {
// 创建一个固定大小的线程池:
ExecutorService es = Executors.newFixedThreadPool(4);
for (int i = 0; i < 6; i++) {
es.submit(new Task("" + i));
}
// 关闭线程池:
es.shutdown();
}
}
class Task implements Runnable {
private final String name;
public Task(String name) {
this.name = name;
}
@Override
public void run() {
System.out.println("start task " + name);
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
}
System.out.println("end task " + name);
}
}
我们观察执行结果,一次性放入6个任务,由于线程池只有固定的4个线程,因此,前4个任务会同时执行,等到有线程空闲后,才会执行后面的两个任务。线程池在程序结束的时候要关闭。使用shutdown()方法关闭线程池的时候,它会等待正在执行的任务先完成,然后再关闭。
shutdownNow()会立刻停止正在执行的任务,awaitTermination()则会等待指定的时间让线程池关闭。如果我们把线程池改为CachedThreadPool,由于这个线程池的实现会根据任务数量动态调整线程池的大小,所以6个任务可一次性全部同时执行。如果我们想把线程池的大小限制在4~10个之间动态调整怎么办?我们查看Executors.newCachedThreadPool()方法的源码得知可以这么写:
int min = 4;
int max = 10;
ExecutorService es = new ThreadPoolExecutor(min, max,
60L, TimeUnit.SECONDS, new SynchronousQueue<Runnable>());
ScheduledThreadPool:
例如,每秒刷新证券价格。这种任务本身固定,需要反复执行的,可以使用ScheduledThreadPool。放入ScheduledThreadPool的任务可以定期反复执行。Java标准库还提供了一个 java.util.Timer 类,这个类也可以定期执行任务,但是,一个Timer会对应一个Thread,所以,一个Timer只能定期执行一个任务,多个定时任务必须启动多个Timer,而一个ScheduledThreadPool就可以调度多个定时任务,所以,我们完全可以用ScheduledThreadPool取代旧的Timer。
ScheduledExecutorService ses = Executors.newScheduledThreadPool(4);
// 1秒后执行一次性任务:
ses.schedule(new Task("one-time"), 1, TimeUnit.SECONDS);
// 2秒后开始执行定时任务,每3秒执行:
ses.scheduleAtFixedRate(new Task("fixed-rate"), 2, 3, TimeUnit.SECONDS);
// 2秒后开始执行定时任务,以3秒为间隔执行:
ses.scheduleWithFixedDelay(new Task("fixed-delay"), 2, 3, TimeUnit.SECONDS);
注意FixedRate和FixedDelay的区别。FixedRate是指任务总是以固定时间间隔触发,不管任务执行多长时间:
│░░░░ │░░░░░░ │░░░ │░░░░░ │░░░
├───────┼───────┼───────┼───────┼────>
│<─────>│<─────>│<─────>│<─────>│
而FixedDelay是指,上一次任务执行完毕后,等待固定的时间间隔,再执行下一次任务:
│░░░│ │░░░░░│ │░░│ │░
└───┼───────┼─────┼───────┼──┼───────┼──>
│<─────>│ │<─────>│ │<─────>│
因此,使用ScheduledThreadPool时,我们要根据需要选择执行一次、FixedRate执行还是FixedDelay执行。
总结:
1、JDK提供了ExecutorService实现了线程池功能;
2、线程池内部维护一组线程,可以高效执行大量小任务;
3、Executors提供了静态方法创建不同类型的ExecutorService;
4、必须调用shutdown()关闭ExecutorService;
5、ScheduledThreadPool可以定期调度多个任务。
17、使用Future(获得异步执行的结果)
在执行多个任务的时候,使用Java标准库提供的线程池是非常方便的。我们提交的任务只需要实现Runnable接口,就可以让线程池去执行:
class Task implements Runnable {
public String result;
public void run() {
this.result = longTimeCalculation();
}
}
Runnable接口有个问题,它的方法没有返回值。如果任务需要一个返回结果,那么只能保存到变量,还要提供额外的方法读取,非常不便。所以,Java标准库还提供了一个Callable接口,和Runnable接口比,它多了一个返回值,并且Callable接口是一个泛型接口,可以返回指定类型的结果:
class Task implements Callable<String> {
public String call() throws Exception {
return longTimeCalculation();
}
}
一个Future<V>接口表示一个未来可能会返回的结果,它定义的方法有:
1、get():获取结果(可能会等待)
2、get(long timeout, TimeUnit unit):获取结果,但只等待指定的时间;
3、cancel(boolean mayInterruptIfRunning):取消当前任务;
4、isDone():判断任务是否已完成。
总结:
1、对线程池提交一个Callable任务,可以获得一个Future对象;
2、可以用Future在将来某个时刻获取结果。
18、使用CompletableFuture
使用Future获得异步执行结果时,要么调用阻塞方法get(),要么轮询看isDone()是否为true,这两种方法都不是很好,因为主线程也会被迫等待。从Java 8开始引入了CompletableFuture,它针对Future做了改进,可以传入回调对象,当异步任务完成或者发生异常时,自动调用回调对象的回调方法。
1、创建一个CompletableFuture是通过CompletableFuture.supplyAsync()实现的,它需要一个实现了Supplier接口的对象:
public interface Supplier<T> {
T get();
}
这里我们用lambda语法简化了一下,直接传入Main::fetchPrice,因为Main.fetchPrice()静态方法的签名符合Supplier接口的定义(除了方法名外)。
2、紧接着,CompletableFuture已经被提交给默认的线程池执行了,我们需要定义的是CompletableFuture完成时和异常时需要回调的实例。完成时,CompletableFuture会调用Consumer对象:
public interface Consumer<T> {
void accept(T t);
}
异常时,CompletableFuture会调用Function对象:
public interface Function<T, R> {
R apply(T t);
}
CompletableFuture的优点:
1、异步任务结束时,会自动回调某个对象的方法;
2、异步任务出错时,会自动回调某个对象的方法;
3、主线程设置好回调后,不再关心异步任务的执行。
多个CompletableFuture可以串行执行:
CompletableFuture更强大的功能是,多个CompletableFuture可以串行执行,例如,定义两个CompletableFuture,第一个CompletableFuture根据证券名称查询证券代码,第二个CompletableFuture根据证券代码查询证券价格,这两个CompletableFuture实现串行操作如下:
public class Main {
public static void main(String[] args) throws Exception {
// 第一个任务:
CompletableFuture<String> cfQuery = CompletableFuture.supplyAsync(() -> {
return queryCode("中国石油");
});
// cfQuery成功后继续执行下一个任务:
CompletableFuture<Double> cfFetch = cfQuery.thenApplyAsync((code) -> {
return fetchPrice(code);
});
// cfFetch成功后打印结果:
cfFetch.thenAccept((result) -> {
System.out.println("price: " + result);
});
// 主线程不要立刻结束,否则CompletableFuture默认使用的线程池会立刻关闭:
Thread.sleep(2000);
}
static String queryCode(String name) {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
}
return "601857";
}
static Double fetchPrice(String code) {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
}
return 5 + Math.random() * 20;
}
}
4、多个CompletableFuture还可以并行执行:
例如,我们考虑这样的场景:同时从新浪和网易查询证券代码,只要任意一个返回结果,就进行下一步查询价格,查询价格也同时从新浪和网易查询,只要任意一个返回结果,就完成操作:
public class Main {
public static void main(String[] args) throws Exception {
// 两个CompletableFuture执行异步查询:
CompletableFuture<String> cfQueryFromSina = CompletableFuture.supplyAsync(() -> {
return queryCode("中国石油", "https://finance.sina.com.cn/code/");
});
CompletableFuture<String> cfQueryFrom163 = CompletableFuture.supplyAsync(() -> {
return queryCode("中国石油", "https://money.163.com/code/");
});
// 用anyOf合并为一个新的CompletableFuture:
CompletableFuture<Object> cfQuery = CompletableFuture.anyOf(cfQueryFromSina, cfQueryFrom163);
// 两个CompletableFuture执行异步查询:
CompletableFuture<Double> cfFetchFromSina = cfQuery.thenApplyAsync((code) -> {
return fetchPrice((String) code, "https://finance.sina.com.cn/price/");
});
CompletableFuture<Double> cfFetchFrom163 = cfQuery.thenApplyAsync((code) -> {
return fetchPrice((String) code, "https://money.163.com/price/");
});
// 用anyOf合并为一个新的CompletableFuture:
CompletableFuture<Object> cfFetch = CompletableFuture.anyOf(cfFetchFromSina, cfFetchFrom163);
// 最终结果:
cfFetch.thenAccept((result) -> {
System.out.println("price: " + result);
});
// 主线程不要立刻结束,否则CompletableFuture默认使用的线程池会立刻关闭:
Thread.sleep(200);
}
static String queryCode(String name, String url) {
System.out.println("query code from " + url + "...");
try {
Thread.sleep((long) (Math.random() * 100));
} catch (InterruptedException e) {
}
return "601857";
}
static Double fetchPrice(String code, String url) {
System.out.println("query price from " + url + "...");
try {
Thread.sleep((long) (Math.random() * 100));
} catch (InterruptedException e) {
}
return 5 + Math.random() * 20;
}
}
┌─────────────┐ ┌─────────────┐
│ Query Code │ │ Query Code │
│ from sina │ │ from 163 │
└─────────────┘ └─────────────┘
│ │
└───────┬───────┘
▼
┌─────────────┐
│ anyOf │
└─────────────┘
│
┌───────┴────────┐
▼ ▼
┌─────────────┐ ┌─────────────┐
│ Query Price │ │ Query Price │
│ from sina │ │ from 163 │
└─────────────┘ └─────────────┘
│ │
└────────┬───────┘
▼
┌─────────────┐
│ anyOf │
└─────────────┘
│
▼
┌─────────────┐
│Display Price│
└─────────────┘
总结:
1、CompletableFuture可以指定异步处理流程:
2、thenAccept()处理正常结果;
3、exceptional()处理异常结果;
4、thenApplyAsync()用于串行化另一个CompletableFuture;
5、anyOf()和allOf()用于并行化多个CompletableFuture。
19、新线程池(For/Join)
Fork/Join任务的原理:
判断一个任务是否足够小,如果是,直接计算,否则,就分拆成几个小任务分别计算。这个过程可以反复“裂变”成一系列小任务。即把一个大任务拆成多个小任务并行执行。
一个任务:
┌─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┐
└─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┘
拆分成两个任务并行执行:
┌─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┐
└─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┘
┌─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┐
└─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┘
继续拆,用4个线程并行执行:
┌─┬─┬─┬─┬─┬─┐
└─┴─┴─┴─┴─┴─┘
┌─┬─┬─┬─┬─┬─┐
└─┴─┴─┴─┴─┴─┘
┌─┬─┬─┬─┬─┬─┐
└─┴─┴─┴─┴─┴─┘
┌─┬─┬─┬─┬─┬─┐
└─┴─┴─┴─┴─┴─┘
总结:
1、Fork/Join是一种基于“分治”的算法:通过分解任务,并行执行,最后合并结果得到最终结果。
2、ForkJoinPool线程池可以把一个大任务分拆成小任务并行执行,任务类必须继承自RecursiveTask或RecursiveAction。
3、使用Fork/Join模式可以进行并行计算以提高效率。
4、Java标准库提供的java.util.Arrays.parallelSort(array)可以进行并行排序,它的原理就是内部通过Fork/Join对大数组分拆进行并行排序,在多核CPU上就可以大大提高排序的速度。
20、ThreadLocal
多线程是Java实现多任务的基础,Thread对象代表一个线程,我们可以在代码中调用Thread.currentThread()获取当前线程。例如,打印日志时,可以同时打印出当前线程的名字。
这种在一个线程中,横跨若干方法调用,需要传递的对象,我们通常称之为上下文(Context),它是一种状态,可以是用户身份、任务信息等。给每个方法增加一个context参数非常麻烦,而且有些时候,如果调用链有无法修改源码的第三方库,User对象就传不进去了。Java标准库提供了一个特殊的ThreadLocal,它可以在一个线程中传递同一个对象。
1、ThreadLocal实例通常总是以静态字段初始化如下:
static ThreadLocal<User> threadLocalUser = new ThreadLocal<>();
2、它的典型使用方式如下:
void processUser(user) {
try {
threadLocalUser.set(user);
step1();
step2();
} finally {
threadLocalUser.remove();
}
}
总结:
1、ThreadLocal表示线程的“局部变量”,它确保每个线程的ThreadLocal变量都是各自独立的;
2、ThreadLocal适合在一个线程的处理流程中保持上下文(避免了同一参数在所有方法中传递);
3、使用ThreadLocal要用try ... finally结构,并在finally中清除。
九、函数式编程
1、创建Stream(流)
要使用Stream,就必须先创建它。创建Stream有很多种方法:
1、用Stream.of()静态方法
public class Main {
public static void main(String[] args) {
Stream<String> stream = Stream.of("A", "B", "C", "D");
// forEach()方法相当于内部循环调用,
// 可传入符合Consumer接口的void accept(T t)的方法引用:
stream.forEach(System.out::println);
}
}
2、基于数组或Collection
public class Main {
public static void main(String[] args) {
Stream<String> stream1 = Arrays.stream(new String[] { "A", "B", "C" });
Stream<String> stream2 = List.of("X", "Y", "Z").stream();
stream1.forEach(System.out::println);
stream2.forEach(System.out::println);
}
}
把数组变成Stream使用Arrays.stream()方法。对于Collection(List、Set、Queue等),直接调用stream()方法就可以获得Stream。上述创建Stream的方法都是把一个现有的序列变为Stream,它的元素是固定的。
3、基于Supplier
创建Stream还可以通过Stream.generate()方法,它需要传入一个Supplier对象:
Stream<String> s = Stream.generate(Supplier<String> sp);
基于Supplier创建的Stream会不断调用Supplier.get()方法来不断产生下一个元素,这种Stream保存的不是元素,而是算法,它可以用来表示无限序列。例如,我们编写一个能不断生成自然数的Supplier,它的代码非常简单,每次调用get()方法,就生成下一个自然数:
public class Main {
public static void main(String[] args) {
Stream<Integer> natual = Stream.generate(new NatualSupplier());
// 注意:无限序列必须先变成有限序列再打印:
natual.limit(20).forEach(System.out::println);
}
}
class NatualSupplier implements Supplier<Integer> {
int n = 0;
public Integer get() {
n++;
return n;
}
}
4、其他方法
1、Files类的lines()方法可以把一个文件变成一个Stream,每个元素代表文件的一行内容,可用来遍历行文本:
try (Stream<String> lines = Files.lines(Paths.get("/path/to/file.txt"))) {}
2、正则表达式Pattern对象有一个splitAsStream()方法,可把一个长字符串分割成Stream序列而不是数组:
Pattern p = Pattern.compile("\\s+");
Stream<String> s = p.splitAsStream("The quick brown fox jumps over the lazy dog");
s.forEach(System.out::println);
基本类型:
因为Java的范型不支持基本类型,所以我们无法用Stream<int>这样的类型,会发生编译错误。为了保存int,只能使用Stream<Integer>,但这样会产生频繁的装箱、拆箱操作。为了提高效率,Java标准库提供了IntStream、LongStream和DoubleStream这三种使用基本类型的Stream,它们的使用方法和范型Stream没有大的区别,设计这三个Stream的目的是提高运行效率:
// 将int[]数组变为IntStream:
IntStream is = Arrays.stream(new int[] { 1, 2, 3 });
// 将Stream<String>转换为LongStream:
LongStream ls = List.of("1", "2", "3").stream().mapToLong(Long::parseLong);
总结:
1、通过指定元素、指定数组、指定Collection创建Stream;
2、通过Supplier创建Stream,可以是无限序列;
3、通过其他类的相关方法创建。
4、基本类型的Stream有IntStream、LongStream和DoubleStream。
2、使用Map
Stream.map()是Stream最常用的一个转换方法,它把一个Stream转换为另一个Stream。
所谓map操作,就是把一种操作运算,映射到一个序列的每一个元素上。例如,对x计算它的平方,可以使用函数f(x) = x * x。我们把这个函数映射到一个序列1,2,3,4,5上,就得到了另一个序列1,4,9,16,25:
f(x) = x * x
│
│
┌───┬───┬───┬───┼───┬───┬───┬───┐
│ │ │ │ │ │ │ │ │
▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼
[ 1 2 3 4 5 6 7 8 9 ]
│ │ │ │ │ │ │ │ │
│ │ │ │ │ │ │ │ │
▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼
[ 1 4 9 16 25 36 49 64 81 ]
Stream<Integer> s = Stream.of(1, 2, 3, 4, 5);
Stream<Integer> s2 = s.map(n -> n * n);
例如:
public class Main {
public static void main(String[] args) {
List.of(" Apple ", " pear ", " ORANGE", " BaNaNa ")
.stream()
.map(String::trim) // 去空格
.map(String::toLowerCase) // 变小写
.forEach(System.out::println); // 打印
}
}
总结:
1、map()方法用于将一个Stream的每个元素映射成另一个元素并转换成一个新的Stream;
2、可以将一种元素类型转换成另一种元素类型。
3、使用filter(过滤器)
Stream.filter()是Stream的另一个常用转换方法。所谓filter()操作,就是对一个Stream的所有元素一一进行测试,不满足条件的就被“滤掉”了,剩下的满足条件的元素就构成了一个新的Stream。
1、例如,我们对1,2,3,4,5这个Stream调用filter(),传入的测试函数f(x) = x % 2 != 0用来判断元素是否是奇数,这样就过滤掉偶数,只剩下奇数,因此我们得到了另一个序列1,3,5:
f(x) = x % 2 != 0
│
│
┌───┬───┬───┬───┼───┬───┬───┬───┐
│ │ │ │ │ │ │ │ │
▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼
[ 1 2 3 4 5 6 7 8 9 ]
│ X │ X │ X │ X │
│ │ │ │ │
▼ ▼ ▼ ▼ ▼
[ 1 3 5 7 9 ]
public class Main {
public static void main(String[] args) {
IntStream.of(1, 2, 3, 4, 5, 6, 7, 8, 9)
.filter(n -> n % 2 != 0)
.forEach(System.out::println);
}
}
2、例如,从一组给定的LocalDate中过滤掉工作日,以便得到休息日:
public class Main {
public static void main(String[] args) {
Stream.generate(new LocalDateSupplier())
.limit(31)
.filter(ldt -> ldt.getDayOfWeek() == DayOfWeek.SATURDAY || ldt.getDayOfWeek() == DayOfWeek.SUNDAY)
.forEach(System.out::println);
}
}
class LocalDateSupplier implements Supplier<LocalDate> {
LocalDate start = LocalDate.of(2020, 1, 1);
int n = -1;
public LocalDate get() {
n++;
return start.plusDays(n);
}
}
3、使用reduce(聚合函数)
map()和filter()都是Stream的转换方法,而Stream.reduce()则是Stream的一个聚合方法,它可以把一个Stream的所有元素按照聚合函数聚合成一个结果。
我们来看一个简单的聚合方法:
public class Main {
public static void main(String[] args) {
int sum = Stream.of(1, 2, 3, 4, 5, 6, 7, 8, 9).reduce(0, (acc, n) -> acc + n);
System.out.println(sum); // 45
}
}
reduce()操作首先初始化结果为指定值(这里是0),紧接着,reduce()对每个元素依次调用(acc, n) -> acc + n,其中,acc是上次计算的结果:
// 计算过程:
acc = 0 // 初始化为指定值 求和使用要求acc为1
acc = acc + n = 0 + 1 = 1 // n = 1
acc = acc + n = 1 + 2 = 3 // n = 2
acc = acc + n = 3 + 3 = 6 // n = 3
acc = acc + n = 6 + 4 = 10 // n = 4
acc = acc + n = 10 + 5 = 15 // n = 5
acc = acc + n = 15 + 6 = 21 // n = 6
acc = acc + n = 21 + 7 = 28 // n = 7
acc = acc + n = 28 + 8 = 36 // n = 8
acc = acc + n = 36 + 9 = 45 // n = 9
总结:
1、reduce()方法将一个Stream的每个元素依次作用于BinaryOperator,并将结果合并。
2、reduce()是聚合方法,聚合方法会立刻对Stream进行计算。
4、输出集合
Stream可以输出为集合:Stream通过collect()方法可以方便地输出为List、Set、Map,还可以分组输出。
1、输出为List
reduce()只是一种聚合操作,如果我们希望把Stream的元素保存到集合,例如List,因为List的元素是确定的Java对象,因此,把Stream变为List不是一个转换操作,而是一个聚合操作,它会强制Stream输出每个元素。下面的代码演示了如何将一组String先过滤掉空字符串,然后把非空字符串保存到List中:
public class Main {
public static void main(String[] args) {
Stream<String> stream = Stream.of("Apple", "", null, "Pear", " ", "Orange");
List<String> list = stream.filter(s -> s != null && !s.isBlank()).collect(Collectors.toList());
System.out.println(list);
}
}
把Stream的每个元素收集到List的方法是调用collect()并传入Collectors.toList()对象,它实际上是一个Collector实例,通过类似reduce()的操作,把每个元素添加到一个收集器中(实际上是ArrayList)。类似的,collect(Collectors.toSet())可以把Stream的每个元素收集到Set中。
2、输出为数组
把Stream的元素输出为数组和输出为List类似,我们只需要调用toArray()方法,并传入数组的“构造方法”:
List<String> list = List.of("Apple", "Banana", "Orange");
String[] array = list.stream().toArray(String[]::new);
3、输出为Map
如果我们要把Stream的元素收集到Map中,就稍微麻烦一点。因为对于每个元素,添加到Map时需要key和value,因此,我们要指定两个映射函数,分别把元素映射为key和value:
public class Main {
public static void main(String[] args) {
Stream<String> stream = Stream.of("APPL:Apple", "MSFT:Microsoft");
Map<String, String> map = stream
.collect(Collectors.toMap(
// 把元素s映射为key:
s -> s.substring(0, s.indexOf(':')),
// 把元素s映射为value:
s -> s.substring(s.indexOf(':') + 1)));
System.out.println(map);
}
}
4、分组输出
public class Main {
public static void main(String[] args) {
List<String> list = List.of("Apple", "Banana", "Blackberry", "Coconut", "Avocado", "Cherry", "Apricots");
Map<String, List<String>> groups = list.stream()
.collect(Collectors.groupingBy(s -> s.substring(0, 1), Collectors.toList()));
System.out.println(groups);
}
}
分组输出使用Collectors.groupingBy(),它需要提供两个函数:一个是分组的key,这里使用s -> s.substring(0, 1),表示只要首字母相同的String分到一组,第二个是分组的value,这里直接使用Collectors.toList(),表示输出为List,上述代码运行结果如下:
{
A=[Apple, Avocado, Apricots],
B=[Banana, Blackberry],
C=[Coconut, Cherry]
}
5、排序、去重、截取、合并等
1、排序:sorted()
public class Main {
public static void main(String[] args) {
List<String> list = List.of("Orange", "apple", "Banana")
.stream()
.sorted()
.collect(Collectors.toList());
System.out.println(list);
}
}此方法要求Stream的每个元素必须实现Comparable接口。如果要自定义排序,传入指定的Comparator即可:
List<String> list = List.of("Orange", "apple", "Banana")
.stream()
.sorted(String::compareToIgnoreCase)
.collect(Collectors.toList());
2、去重:distinct()
对一个Stream的元素进行去重,没必要先转换为Set,可以直接用distinct()
List.of("A", "B", "A", "C", "B", "D")
.stream()
.distinct()
.collect(Collectors.toList()); // [A, B, C, D]
3、截取:skip()、limit()
List.of("A", "B", "C", "D", "E", "F")
.stream()
.skip(2) // 跳过A, B
.limit(3) // 截取C, D, E
.collect(Collectors.toList()); // [C, D, E]
4、合并:concat()
Stream<String> s1 = List.of("A", "B", "C").stream();
Stream<String> s2 = List.of("D", "E").stream();
// 合并:
Stream<String> s = Stream.concat(s1, s2);
System.out.println(s.collect(Collectors.toList())); // [A, B, C, D, E]
5、flatMap
Stream<List<Integer>> s = Stream.of(
Arrays.asList(1, 2, 3),
Arrays.asList(4, 5, 6),
Arrays.asList(7, 8, 9));
而我们希望把上述Stream转换为Stream<Integer>,就可以使用flatMap():
Stream<Integer> i = s.flatMap(list -> list.stream());
因此,所谓flatMap(),是指把Stream的每个元素(这里是List)映射为Stream,然后合并成一个新的Stream:
┌─────────────┬─────────────┬─────────────┐
│┌───┬───┬───┐│┌───┬───┬───┐│┌───┬───┬───┐│
││ 1 │ 2 │ 3 │││ 4 │ 5 │ 6 │││ 7 │ 8 │ 9 ││
│└───┴───┴───┘│└───┴───┴───┘│└───┴───┴───┘│
└─────────────┴─────────────┴─────────────┘
│
│flatMap(List -> Stream)
│
│
▼
┌───┬───┬───┬───┬───┬───┬───┬───┬───┐
│ 1 │ 2 │ 3 │ 4 │ 5 │ 6 │ 7 │ 8 │ 9 │
└───┴───┴───┴───┴───┴───┴───┴───┴───┘
6、并行:parallel()
通常情况下,对Stream的元素进行处理是单线程的,即一个一个元素进行处理。但是很多时候,我们希望可以并行处理Stream的元素,因为在元素数量非常大的情况,并行处理可以大大加快处理速度。把一个普通Stream转换为可以并行处理的Stream非常简单,只需要用parallel()进行转换:
Stream<String> s = ...
String[] result = s.parallel() // 变成一个可以并行处理的Stream
.sorted() // 可以进行并行排序
.toArray(String[]::new);
7、其他聚合方法
1、除了reduce()和collect()外,Stream还有一些常用的聚合方法:
count():用于返回元素个数;
max(Comparator<? super T> cp):找出最大元素;
min(Comparator<? super T> cp):找出最小元素。
2、针对IntStream、LongStream和DoubleStream,还额外提供了以下聚合方法:
sum():对所有元素求和;
average():对所有元素求平均数。
3、还有一些方法,用来测试Stream的元素是否满足以下条件:
boolean allMatch(Predicate<? super T>):测试是否所有元素均满足测试条件;
boolean anyMatch(Predicate<? super T>):测试是否至少有一个元素满足测试条件。
最后一个常用的方法是forEach(),它可以循环处理Stream的每个元素,我们经常传入System.out::println来打印Stream的元素:
Stream<String> s = ...
s.forEach(str -> {
System.out.println("Hello, " + str);
});
总结:
1、转换操作:map(),filter(),sorted(),distinct();
2、合并操作:concat(),flatMap();
3、并行处理:parallel();
4、聚合操作:reduce(),collect(),count(),max(),min(),sum(),average();
5、其他操作:allMatch(), anyMatch(), forEach()。