On java

菜狗与某次面试中被问到不少并发问题,结果一问三不知,恰好cs186也快看到并发控制,遂开坑《on java》学习一下java进阶知识。
垃圾回收
相比于其他语言在堆上分配对象需要支付非常高昂的代价,Java的GC明显地提高对象的创建速度。这意味着Java从堆空间分配的速度可以和其他语言在栈上分配空间的速度媲美。
Java的堆的工作机制非常类似于传送带,每分配一个对象就向前移动一格,也就是说Java的堆指针可以轻易的移动到尚未分配的区域,虽然在簿记工作方面还有少量开销,但这部分开销和查找可用空间的开销相比可以忽略不计。
不过Java中堆的工作机制又不完全类似与传送带,否则就会因为“传送带”过长而导致非常频繁的页面调度。这时就需要GC介入了,它工作时,一边回收内存一边使堆中的对象紧凑排列,确保“传送带”的长度适中。
引用计数
在理解Java的GC之前,需要先理解一下其他系统中的垃圾回收机制。
最简单的GC方式就是引用计数。每个对象中都会维护一个引用计数器,每当有引用指向该对象时,计数器+1。当引用离开作用域或被置为null时,计数器-1。因此管理引用计数器是一个负担不大但是频繁发生的负担。垃圾回收器必须遍历含有全部对象的列表,如果引用计数为0就释放空间。不过这个机制存在一个非常严重的缺点:如果对象之间存在循环引用,那么他们的引用计数永远不会为0。而对GC而言定位循环引用工作量非常大。
更快的策略
根据引用计数的介绍,它显然不是个具备良好效率的GC策略。下面来介绍一种更快的策略。
在具体介绍之前,先讲一下这个策略的依据:对于任何一个活着的对象,必然能够追溯到其存活在栈或静态存储区中的引用。也就是说,如果一个对象还可以被判定为活着,就代表它存在于某个栈或静态存储区中对象的引用链上。在遍历完根源于栈和静态存储区的引用之后会形成一个网络,网络中的所有对象都可以被认为是活的对象。循环引用的对象如果不再被使用就不会被发现,因为他们属于网络之外的孤岛,会被正常回收掉。
自适应策略GC
基于上述的引用查找技术,Java使用一种自适应的GC技术来处理查找到的引用对象。其中一种方法叫停止-复制。
停止-复制
这需要程序先停止运行,然后将所有存活的对象从当前堆复制到另一个堆,没有复制的就代表他要被垃圾回收。同时,当对象被复制到新堆之后,他们是紧挨着排列的,这样就可以更加方便的分配空间了。
但当对象从一处复制到另一处,所有指向它的引用都必须修正。
这种复制回收器效率比较低下:
- 需要有两个堆,然后在这两个分离的堆之间来回复制,这就导致需要维护比实际多一倍的空间。JVM的处理方式:按需从堆中分配几块较大的内存,复制动作发生在这些大块内存之间
- 程序进入稳定状态之后,垃圾产量就会变少。这时进行频繁的来回复制就显得非常浪费。
为了避免程序在进入稳定状态之后还不断进行停止-复制,部分JVM会自动切换到另一种模式 标记-清扫模式。
标记-清扫
对一般用途而言,标记-清扫的效率非常感人,但如果程序只会产生少量垃圾,那么它的效率就会非常高了。
标记-清扫的思路仍然是从栈和静态存储区出发,遍历所有的引用,找出所有存活的对象。每找到一个存活对象就给对象设一个标记,在标记过程结束后清理所有没被标记的对象。期间不需要发生任何复制动作。标记-清扫后剩下的堆空间是不连续的,如果GC希望获得连续空间,就要重新整理剩下的对象。
分代
在JVM中,内存分配以较大的“块”为单位,如果对象较大,他就会占用单独的块。在GC时,就可以把对象复制到废弃的块。每个块都有年代数来记录自己是否存活,通常,如果块在某处被引用了,其年代数+1,因为被引用多的对象一般存活时间较长(对于某些GC来说,一个对象存活过的垃圾回收次数才是年代数,他们认为活的久的对象在未来也有很大可能活下去).
综合上面的介绍,我们就可以大致归纳出JVM垃圾回收的流程:大型对象不会被复制,而含有小型对象的那些块则被复制并整理。JVM还会监视,如果所有对象都很稳定,就会切换到“标记-清扫”模式。JVM还会跟踪“标记-清扫”的效果,如果堆空间出现很多碎片,就会切换回“停止-复制”
内部类
一个定义在另一个类中的类,叫做内部类。它可以把一些逻辑相关的类组织在一起,并控制位于内部的类的可见性。
比较典型的情况是,外部类有一个方法,这个方法返回一个指向内部类的引用
public class Parcel2 {
class Content {
private int i = 1;
public int value() {
return i;
}
}
public Content content() {
return new Content();
}
}
以上的实例体现了出内部类具备名字隐藏以及组织代码的能力。
然而内部类最强大的一点在于,当生成一个内部类对象时,这个对象会捕获一个指向对应外部类的引用。内部类可以通过捕获到的外部类引用直接访问外部类的所有成员。
如果希望在内部类中访问外部类的引用,可以使用OuterclassName.this的方式获取引用。
同样的,如果要在外部让某个对象创立一个内部类,需要用OuterclassObject.new InnerclassName()的方式创建。
向上转型
当需要将内部类向上转型为基类,尤其是转型为接口时,就可以将内部类的代码隐藏能力体现的淋漓尽致。如果我们将内部类的访问限制为private或protected,那么在完成向上转型之后,外界将无从知晓内部类的具体实现,因为连实现它的内部类都无法访问到,这也就在一定程度上确保了程序的安全性。通过这种方式可以完全阻止任何依赖于类型的代码(解耦),并且完全隐藏了实现。除此之外,由于不能访问任何原本不属于公共接口的方法,扩展接口就失去了价值,这就为Java编译器提供了生成高效代码的机会。
interface Contents {
int value();
}
class Parcel4 {
private class PContents implements Contents {
private int i = 1;
@Override
public int value() {
return i;
}
}
public Contents contents() {
return new PContents();
}
}
public class TestParcel {
public static void main(String[] args) {
Parcel4 parcel4 = new Parcel4();
Contents contents = parcel4.contents();
System.out.println(contents.value());
}
}
内部类方法和作用域
普通内部类
使用内部类一般有如下两个理由:
实现了某类型的接口,可以创建并返回其引用
要解决一个复杂的问题,想创建一个类来辅助解决方案,但又不希望这个类是公开的。
我们可以在方法中甚至作用域内定义内部类,这为解决问题提供了极大的灵活性。public class Parcel5 { public Destination destination(String s) { final class PDestination implements Destination { private String label; private PDestination(String whereTo) { label = whereTo; } @Override public String readLabel() {return label;} } return new PDestination(s); } }
上面的实例中,将一个内部类定义在了方法内部,这个方法最终会返回一个内部类引用并将它向上转型。需要注意的是,定义在方法中的内部类不代表它出了这个方法就消失了,而是这个内部类对方法之外的区域不可见。
匿名内部类
匿名内部类的基本语法在此不多赘述。
匿名内部类的语法表示的大致意思就是:创建一个继承自XXX的匿名类的对象,同时通过new表达式将其引用自动向上转型为对XXX的引用。
在大多数情况下,我们创建匿名内部类使用的都是默认构造器,但有些时候我们也希望使用指定的构造器。这里就还需要回到它的创建语法来看,匿名内部类创建的是一个继承自基类的对象,也就是说,基类的构造器可以被继承过来供内部类使用。下面就展现一下它的用法。
// 基类
public class Wrapping {
private int i;
public Wrapping(int x) {
i = x;
}
public int value() {
return i;
}
}
public class Parcel8 {
public Wrapping wrapping(int x) {
return new Wrapping(x) {
@Override
public int value() {
return super.value() * 47;
}
};
}
public static void main(String[] args) {
Parcel8 p = new Parcel8();
System.out.println(p.wrapping(10).value());
}
}
如果我们希望匿名内部类具备一些构造器的行为,那么就需要用到实例初始化了。
public class Parcel10 {
public Contents contents(final String ct, final int v) {
return new Contents() {
private String content;
// 实例初始化
{
content = ct;
}
@Override
public int value() {
return content.length();
}
};
}
}
对于每一个生成的内部类,都会调用实例初始化的代码段。需要注意的是,对于所有传递给匿名内部类(不包括通过构造器传递给基类的)的变量,都必须是final修饰或者在赋值之后不会改变的。即所有被匿名内部类直接使用的变量都必须是不可变的。
嵌套类
如果不需要内部类对象与其外部类对象之间有联系,就可以将内部类声明为static。
嵌套类具有的特征:
- 创建嵌套类的对象时,不需要其外部类的对象
- 不能从嵌套类的对象中访问非静态的外部类对象
- 嵌套类可以有static数据和static字段,嵌套类也可以包含嵌套类,这都是普通内部类做不到的。
接口内部的类
嵌套类可以作为接口的一部分,任何放到接口中的类都自动是public static的。如果我们希望创建一个公共方法,这个方法可以被接口的所有实现使用,那么使用嵌套类就会非常方便。
public interface ClassInInterface {
void howdy();
class Test implements ClassInInterface {
@Override
public void howdy() {
System.out.println("Howdy!");
}
}
public static void main(String[] args) {
new Test().howdy();
}
}
为什么使用内部类
内部类继承自某个类或实现某个接口,内部类的代码操作创建它的外部类对象。因此内部类可以说提供了某种进入其外部类的接口。
内部类最吸引人的原因在于:每个内部类都能独立的继承自一个实现,无论外部类是否已经继承了某个实现,对于内部类都没有影响。也就是说,内部类的存在让多重继承的存在成为可能,一个外部类可以借助内部类继承多个类的特征。
class D {}
abstract class E {}
class Z extends D {
E makeE() {
return new E() {};
}
}
public class MultiImplementation {
static void takesD(D d) {}
static void takesE(E e) {}
public static void main(String[] args) {
Z z = new Z();
takesD(z);
takesE(z.makeE());
}
}
在上面的示例中,Z继承了D同时使用内部类继承了E,这使得Z同时具备了两种基类的特质,实现多重继承。
初次之外,内部类还有其他的特性:
- 内部类可以有多个实例,每个实例都有自己的状态信息,并且与其外部类对象的信息相互独立。
- 在单个外部类中,可以让多个内部类以不同的方式实现同一个接口,或继承同一个类。
- 创建内部类对象的时刻并不依赖于外部类对象的创建
- 内部类并没有令人迷惑的”is-a”关系,它就是一个独立的实体。
对于那些有明显依赖关系的类来说,使用内部类显然比外部类要更为直观高效。
闭包与回调
闭包是指一个可调用的对象,它记录的信息中包含创建它的作用域中的信息,但创建它的作用域却无法完全掌控它的信息。由这个定义可以看出,Java的内部类就是一个OOP的闭包,它可以访问并操作外部类对象。
除此之外,Java实现回调的方式也与一般语言不同。我们先简单介绍一下回调:程序向某个对象传递一些信息,希望它能够处理,不过程序并不会等待结果的返回,而是将执行任务的内容与需要的信息都交给相关的对象,在之后的某一个时间点,对象会执行这个任务,并在完成处理之后通知程序。
package innerclass;
interface Incrementable {
void increment();
}
class MyIncrement {
public void increment() {
System.out.println("Other operation");
}
static void f(MyIncrement mi) {
mi.increment();
}
}
class Callee extends MyIncrement {
private int i = 0;
@Override
public void increment() {
super.increment();
i++;
System.out.println(i);
}
private class Closure implements Incrementable {
@Override
public void increment() {
Callee.this.increment();
}
}
Incrementable getCallbackRef() {
return new Closure();
}
}
class Caller {
private Incrementable callbackRef;
Caller(Incrementable cbh) {
callbackRef = cbh;
}
void go() {
callbackRef.increment();
}
}
public class Callbacks {
public static void main(String[] args) {
Callee callee = new Callee();
MyIncrement.f(callee);
Caller caller = new Caller(callee.getCallbackRef());
caller.go();
caller.go();
}
}
在上述示例中,Callee继承自MyIncrement,后者已经有了一个increment()
方法,但这个方法的实现与Incrementable的期望完全不同。因此Callee一旦继承了MyIncrement,就不能为了Incrementable的用途重写increment。这时就需要内部类来完成剩下的工作,让内部类去继承Incrementable,并实现需要的方法。最终Callee给外部暴露一个返回Incrementable的接口,一个非常安全的钩子,这个引用只能调用Incrementable的方法,而不像指针那样可以允许其他有破坏性的操作。
Caller之后会获取到一个回调引用,在之后需要的时候,他就可以调用这个回调函数。
函数式编程
通过合并现有代码来生成新功能而不是从头开始编写内容,我们就可以更快的获得更可靠的代码。OO的目的是抽象数据,而FP的目的是抽象行为。
纯粹的函数式语言,要求所有数据必须是不可变的:设置一次,永不改变。将值传递给函数,该函数然后生成新值但不修改自身外部的任何东西。这就是所谓的“不可变对象和无副作用”范式。它解决了并发编程中最棘手的问题之一 ———— 共享状态,因为函数永远不会去竞争修改内存中的值,只会生成新值。
Lambda表达式
Lambda表达式使用最小可能语法编写的函数定义:
- Lambda表达式产生函数,而不是类。
- 语法尽可能少
Lambda表达式的基本使用就不多介绍,这里简单讲讲用Lambda实现递归。
如果要使用Lambda表达式编写递归,首先要求递归方法必须是实例变量或静态变量
public class RecursiveFactorial {
static IntCall fact;
public static void main(String[] args) {
fact = n -> n == 0 ? 1 : n * fact.call(n - 1);
for (int i = 0; i <= 10; i++) {
System.out.println(fact.call(i));
}
}
}
流式编程
流是与任何特定存储机制无关的元素序列 ———— 或者说,流是没有存储的
使用流可以从管道中提取元素并对其进行操作。这些管道通常被串联在一起形成一整套的管线来对流进行操作。
流契合声明式编程,即只需要声明要做什么,而不必指明每一步要怎么做。
显式编写迭代过程的方式称为外部迭代。而流式编程使用的都是内部迭代,迭代过程对用户透明。内部迭代产生的代码可读性更强,而且能更简单的使用多核处理器。
除此之外,流是懒加载的,只在绝对必要时才会进行计算,可以将流看作延迟列表,并且正因为这个特性,流可以表示非常大的序列而无需考虑内存问题。
流操作的类型分为三种:创建流,修改流元素,消费流元素
创建流
generate
所有的集合都可以转变为流,也可以使用Stream.of()
获得流。
不过这里主要介绍的是Stream.generate(Supplier<T>)
。他接受一个Supplier函数接口,创建一个包含类型为T的流。使用他最简单的一种方式就是编写一个继承了对应函数接口的类。不过我们同样可以传递一个函数引用过去实现相同的效果。
public class Bubble {
public final int i;
public Bubble(int n) {
i = n;
}
public String toString() {
return "Bubble(" + i + ")";
}
private static int cnt = 0;
public static Bubble bubbler() {
return new Bubble(cnt++);
}
public static void main(String[] args) {
Stream.generate(Bubble::bubbler)
.limit(5)
.forEach(System.out::println);
}
}
上面的示例算是一个创建单独工厂类的方式,在很多方面他显得更加整洁。
iterate
Stream.iterate()
产生的流的第一个元素是种子,然后将种子传递给方法。方法的运行结果被添加到流并被存储起来作为下次调用iterate的第一次参数。
public class Fib {
int x = 1;
public Stream<Integer> numbers() {
return Stream.iterate(0, i -> {
int res = x + i;
x = i;
return res;
});
}
public static void main(String[] args) {
new Fib().numbers()
.limit(5)
.forEach(System.out::println);
}
}
中间操作
peek(Consumer)
peek操作的目的是帮助调试,他接受一个Consumer函数接口,可以帮助查看流中的元素,或者修改元素内部的信息。sorted()
sorted()可以将流中的元素排序,可以传入一个比较器实现自定义排序。distinct()
消除流中的重复元素,相比创建一个Set来说,这种操作需要的工作量小很多。filter(Predicate)
接收一个断言函数,保留流中所有满足断言函数的元素。map(Function)
将函数操作应用在输入流的元素中,并将返回值传递到输出流中flatMap()
将函数操作应用在输入流的元素中,并将返回值扁平化为一个元素。当我们对流中元素的操作会产生一个新的流时,使用map会导致我们最终获取到一个存储流的流,而使用flatMap则可以让我们将产生的流中的元素扁平化到原先的流中。
Optional类
在学习终端操作之前,首先得要意识到一点:如果流中存在一个null,就会导致流中断。因此我们需要一种对象,可以在持有流元素的同时,即使查找的元素不存在也可以友好的进行提示。
Optional类提供了这样的功能
ifPresent(Consumer)
:当值存在时调用 Consumer,否则什么也不做。orElse(otherObject)
:如果值存在则直接返回,否则生成 otherObject。orElseGet(Supplier)
:如果值存在则直接返回,否则使用 Supplier 函数生成一个可替代对象。orElseThrow(Supplier)
:如果值存在直接返回,否则使用 Supplier 函数生成一个异常。
当流中产生了Optional对象,下面的方法会对他们进行相对的处理:
- filter:对Optional中的内容应用Predicate并返回结果,如果条件不满足,将Optional转化为一个空Optional,而不是直接将元素删除
- map:如果Optional不为空,就将函数应用于它存储的内容,并返回结果。否则直接返回Optional.empty
- flatMap:效果同map,但不会主动将结果封装
如果流的生成器有可能产生null,那应该自然而然的想到用它创建流时要使用Optional来进行包装。
public class Signal {
private final String msg;
public Signal(String msg) {
this.msg = msg;
}
@Override
public String toString() {
return "Signal{" +
"msg='" + msg + '\'' +
'}';
}
static Random rand = new Random(47);
public static Signal morse() {
return switch (rand.nextInt(4)) {
case 1 -> new Signal("dot");
case 2 -> new Signal("dash");
default -> null;
};
}
public static Stream<Optional<Signal>> stream() {
return Stream.generate(Signal::morse)
.map(Optional::ofNullable);
}
}
终端操作
终端操作内容繁多,这里只介绍一部分内容
组合
reduce是流的一个聚合方法,它可以把流中的所有元素按照聚合函数聚合成一个结果。
reduce(BinaryOperator)
:使用 BinaryOperator 来组合所有流中的元素。因为流可能为空,其返回值为 Optional。reduce(identity, BinaryOperator)
:功能同上,但是使用 identity 作为其组合的初始值。因此如果流为空,identity 就是结果。
reduce接收到的两个参数,一个是reduce上次调用的结果,另一个是从流中传递过来的值。
下面的示例演示了,如何使用reduce将流中的元素聚合为一个mappublic class Aggregate { public static void main(String[] args) { List<String> props = List.of("profile=native", "debug=true"); Map<String, String> map = props.stream() .map(kv -> { String[] s = kv.split("\\=", 2); return Map.of(s[0], s[1]); }) .reduce(new HashMap<>(), (m, kv) -> { m.putAll(kv); return m; }); map.forEach((k, v) -> { System.out.println(k + "=" + v); }); } }
匹配
allMatch(Predicate)
:如果流的每个元素提供给 Predicate 都返回 true ,结果返回为 true。在第一个 false 时,则停止执行计算。anyMatch(Predicate)
:如果流的任意一个元素提供给 Predicate 返回 true ,结果返回为 true。在第一个 true 是停止执行计算。noneMatch(Predicate)
:如果流的每个元素提供给 Predicate 都返回 false 时,结果返回为 true。在第一个 true 时停止执行计算。
查找
findFirst()
:返回第一个流元素的 Optional,如果流为空返回 Optional.empty。findAny()
:返回含有任意流元素的 Optional,如果流为空返回 Optional.empty。但在默认情况下,它仍旧是返回第一个找到的流元素
异常
基本异常
异常情形是指阻止当前方法或作用域继续执行的问题。他与普通问题有着非常明显的区别,所谓普通问题是指在当前环境下能得到足够的信息,总能处理这个错误。而对于异常情形就不能继续进行下去了,因为在当前环境下无法获得必要的信息来解决问题,只能从当前环境跳出并把问题交给上一级环境。
当抛出异常之后,Java会在堆上构建异常对象,然后当前的执行路径终止,并从当前环境弹出对异常对象的引用。异常处理机制接管程序,并开始寻找异常处理程序来帮助程序从错误状态中恢复。
异常链
我们常常希望在捕获一个异常后抛出另一个异常,并且希望将原始异常的信息保存下来,这就称为异常链。在所有Throwable
的子类构造器中都可以接收一个cause对象作为参数,它就是用来表示原始异常的。
不过对于除Error
,Exception
,RuntimeException
以外的异常来说,想要添加异常链要调用他们的initCause()方法,而非构造器。
异常丢失
我们通常使用finally来处理发生异常后的恢复工作。但假如恢复工作过程中抛出了异常,那就代表在原有异常还未处理的情况下抛出了新的异常,这会将原来的异常覆盖掉,外界的catch语句就只会捕获到finally中抛出的异常。
将被检查的异常转换为不检查的异常
在编写方法时,我们可能会发现,这个方法会产生一个异常,但我们目前不清楚应该如何处理它。这时,异常链就可以体现出它的价值了,可以通过将一个被检查的异常传递给RuntimeException的构造器,这样就可以避免异常被吞掉,同时也可以保证不会丢失任何异常信息。而在考虑好如何处理异常之后,就可以用catch去捕获对应的异常。
异常指南
- 尽可能使用try-with-resource
- 在知道该如何处理的情况下才捕获异常
- 解决问题并且重新调用产生异常的方法
- 进行少许修补,然后绕过异常发生的地方继续执行
- 用其他数据进行计算,以代替方法预计会返回的值
- 将当前环境下能做的事尽量做完,剩下的抛给高层去做
- 终止程序
- 简化
类型信息
RTTI:运行时类型信息
类字面常量
除了Class.forName之外,Java还提供了另一种方法创建类对象的引用:类字面常量。其格式类似于FancyToy.class
。这样生成的类对象引用会更加安全,因为他在编译期就会被检查。同时不必进行方法调用,效率更高。
不过需要注意的一点是,当使用类字面常量创建Class对象引用时不会自动初始化Class对象。Java在使用类之前做的准备工作实际包含三个步骤:
- 加载:由类加载器执行,在classpath中根据类名查找字节码,并从这些字节码中创建一个Class对象。
- 链接:验证类中的字节码,为static字段分配存储空间,并且如果有需要的话,解析该类创建的对其他类的所有引用。
- 初始化:如果该类具有超类,则先初始化超类,执行static初始化器和static初始化块
除此之外,如果一个被static final修饰的值是编译期常量,那么这个值不需要对类进行初始化就能被读取。
注册工厂
从基类的层次结构生成对象的问题是,每当向层次结构中添加一种新类型,就必须将其硬编码到记录子类的条目当中,这相当于要求层次结构内部的对象要了解到整个层次结构。如果一个系统会定期添加类,这就很容易导致问题。
因此我们就需要将子类的生成与子类本身进行解耦(资源和操作资源的行为解耦)。这就需要工厂模式了,每当有子类添加进来,就将他注册到工厂当中。
反射
在不确定对象的类型时,如果处于编译期,RTTI可以告诉我们答案。但如果在运行时,RTTI便无能为力了。起初,这看起来并没有那么大的限制,但是假设你引用了一个不在程序空间中的对象。实际上,该对象的类在编译时甚至对程序都不可用。也许你从磁盘文件或网络连接中获得了大量的字节,并被告知这些字节代表一个类。
上面提到的问题可以使用反射来解决。但在使用反射前先要意识到反射并没有什么魔力,当我们使用反射与未知类型的对象进行交互时,JVM会查看该对象,并查找到它属于特定的类,在执行任何操作之前,仍旧需要加载Class对象。因此对应的.class文件必须在本地或网络上对JVM可用。
动态代理
一个对象封装真实对象,代替其提供其他或不同的操作—-这些操作通常涉及到与“真实”对象的通信,因此代理通常充当中间对象。
当我们希望将额外的操作与真实对象分离时,代理就能体现出它的价值。当我们想要衡量真实对象对此类调用的开销但又不想让这部分代码耦合到自己的程序中时,就可以使用代理来进行操作。
Java在此基础上还提供了动态代理的功能,不仅动态创建代理对象,而且动态处理对代理方法的调用。在动态代理上进行的所有调用都被重定向到单个调用处理程序,该代理程序发现调用的内容并决定如何处理。
interface Interface {
void doSomething();
void somethingElse(String arg);
}
class RealObject implements Interface {
@Override
public void doSomething() {
System.out.println("doSomething");
}
@Override
public void somethingElse(String arg) {
System.out.println("somethingElse " + arg);
}
}
class DynamicProxyHandler implements InvocationHandler {
private Object proxied;
DynamicProxyHandler(Object proxied) {
this.proxied = proxied;
}
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
System.out.println("proxy: " + proxy.getClass() + ", method: " + method + ", args: " + args);
if (args != null) {
for (Object arg : args) {
System.out.println(arg);
}
}
return method.invoke(proxied, args);
}
}
public class DynamicProxyDemo {
public static void consumer(Interface iface) {
iface.doSomething();
iface.somethingElse("bonobo");
}
public static void main(String[] args) {
RealObject real = new RealObject();
consumer(real);
Interface proxy = (Interface) Proxy.newProxyInstance(
Interface.class.getClassLoader(),
new Class[]{Interface.class},
new DynamicProxyHandler(real)
);
consumer(proxy);
}
}
上面是使用动态代理的一个简单示例,代理会打印调用方法的信息。Proxy.newInstance需要三个参数,第一个是一个类加载器,第二个是希望代理实现的接口列表,最后是调用处理器的一个实现。
在更复杂的场景中,比如RPC框架,动态代理在接收到本地的方法请求之后,会将需要调用的方法打包成一个请求,发送到服务端去执行。
文件
文件和目录路径
一个Path对象表示一个文件或者目录的路径,是一个跨操作系统和文件系统的抽象。让程序员在构造路径时可以不关注底层操作系统。
public class PathInfo {
static void show(String id, Object p) {
System.out.println(id + ": " + p);
}
static void info(Path p) {
show("toString", p);
show("Exists", Files.exists(p));
show("RegularFile", Files.isRegularFile(p));
show("Directory", Files.isDirectory(p));
show("Absolute", p.isAbsolute());
show("FileName", p.getFileName());
show("Parent", p.getParent());
show("Root", p.getRoot());
System.out.println("******************");
}
public static void main(String[] args) {
System.out.println(System.getProperty("os.name"));
info(Paths.get("C:", "path", "to", "nowhere", "NoFile.txt"));
Path p = Paths.get("PathInfo.java");
info(p);
Path ap = p.toAbsolutePath();
info(ap);
info(ap.getParent());
try {
info(p.toRealPath());
} catch(IOException e) {
System.out.println(e);
}
URI u = p.toUri();
System.out.println("URI: " + u);
Path puri = Paths.get(u);
System.out.println(Files.exists(puri));
File f = ap.toFile();
}
}
Path还可以非常简单的生成一个文件的某一部分,可以通过getName()
索引Path的各个部分,直至达到上限。Path也实现了Iterable
接口,因此可以用for-each遍历。
请注意,即使路径以 .java 结尾,使用 endsWith() 方法也会返回 false。这是因为使用 endsWith() 比较的是整个路径部分,而不会包含文件路径的后缀。同时,遍历的部分也不包括根路径,只有在使用startswith检测时才会返回true
public class PartsOfPaths {
public static void main(String[] args) {
System.out.println(System.getProperty("os.name"));
Path p = Paths.get("threadlearn", "src", "io", "Redirecting.java").toAbsolutePath();
for (int i = 0; i < p.getNameCount(); i++) {
System.out.println(p.getName(i));
}
System.out.println("ends with .java: " + p.endsWith(".java"));
for (Path pp : p) {
System.out.print(pp + ": ");
System.out.print(p.startsWith(pp) + " : ");
System.out.println(p.endsWith(pp));
}
System.out.println("Starts with " + p.getRoot() + " " + p.startsWith(p.getRoot()));
}
}
除此之外,我们还可以通过对Path对象增加或者删除一部分来构造一个新的Path对象。使用relativize()
移除Path的根路径,使用resolve()
添加Path的尾路径
public class AddAndSubtractPaths {
static Path base = Paths.get("..", "..", "..").toAbsolutePath().normalize();
static void show(int id, Path result) {
if (result.isAbsolute()) {
System.out.println("(" + id + ")r " + base.relativize(result));
} else {
System.out.println("(" + id + ") " + result);
}
try {
System.out.println("RealPath: " + result.toRealPath());
} catch (IOException e) {
throw new RuntimeException(e);
}
}
public static void main(String[] args) {
System.out.println(System.getProperty("os.name"));
System.out.println(base);
Path p = Paths.get("threadlearn", "src", "io" ).toAbsolutePath();
show(1, p);
Path convoluted = p
.resolve("AddAndSubtractPaths.java");
// .resolve(p.getParent().getFileName());
show(2, convoluted);
show(3, convoluted.normalize());
}
}
Java的Files
工具类中没有包含删除目录树的相关方法,因此我们需要自行实现一个。
public class RmDir {
public static void rmdir(Path dir) throws IOException {
Files.walkFileTree(dir, new SimpleFileVisitor<>() {
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
Files.delete(file);
return FileVisitResult.CONTINUE;
}
@Override
public FileVisitResult postVisitDirectory(Path dir, IOException exc) throws IOException {
Files.delete(dir);
return FileVisitResult.CONTINUE;
}
});
}
}
这个类的实现依赖于Files.walkFileTree
方法。他会遍历每一个子目录和文件。这里的底层实现运用到了visitor设计模式([[design-mode#Visitor 模式]])。每一个文件都是一个资源,我们使用FileVisitor
的实现类访问并操作文件资源。
FileVisitor有四个抽象方法:
- **preVisitDirectory()**:在访问目录中条目之前在目录上运行。
- **visitFile()**:运行目录中的每一个文件。
- **visitFileFailed()**:调用无法访问的文件。
- **postVisitDirectory()**:在访问目录中条目之后在目录上运行,包括所有的子目录。
下面是利用walk()方法遍历目录下的所有文件和子目录。注意Files.newDirectoryStream
只能列出指定文件夹下的文件夹和文件,无法获取子目录下的文件信息。
public class Directories {
static Path path = Paths.get("test");
static String sep = FileSystems.getDefault().getSeparator();
static List<String> parts = Arrays.asList("foo", "bar", "baz", "bag");
static Path makeVariant() {
Collections.rotate(parts, 1);
return Paths.get("test", String.join(sep, parts));
}
static void refreshTestDir() throws IOException {
if (Files.exists(path)) {
RmDir.rmdir(path);
}
if (!Files.exists(path))
Files.createDirectory(path);
}
static void populateTestDir() throws IOException {
for (int i = 0; i < parts.size(); i++) {
Path variant = makeVariant();
if (!Files.exists(variant)) {
Files.createDirectories(variant);
Files.copy(Paths.get("threadlearn", "src", "dir", "Directories.java"), variant.resolve("File.txt"));
Files.createTempFile(variant, null, null);
}
}
}
public static void main(String[] args) throws IOException {
refreshTestDir();
Files.createFile(path.resolve("Hello.txt"));
Path variant = makeVariant();
populateTestDir();
Path tempdir = Files.createTempDirectory(path, "DIR_");
Files.createTempFile(tempdir, "pre", ".non");
Files.newDirectoryStream(path).forEach(System.out::println);
System.out.println();
Files.walk(path).forEach(System.out::println);
}
}
如果希望获取文件系统的信息,可以使用FileSystems.getDefault()
方法。或者在Path对象上调用getFileSystem()获取创建该对象的文件系统。
文件查找
nio为文件查找提供了一种更好的解决方案,通过在FileSystem
上调用getPathMatcher
获取一个路径匹配器,然后传入需要的模式(glob/regex)。既可以匹配到需要的文件。
public class Find {
public static void main(String[] args) throws IOException {
Path test = Paths.get("test");
Directories.refreshTestDir();
Directories.populateTestDir();
Files.createDirectory(test.resolve("dir.tmp"));
PathMatcher pathMatcher = FileSystems.getDefault().getPathMatcher("glob:**/*.{tmp,txt}");
Files.walk(test)
.filter(pathMatcher::matches)
.forEach(System.out::println);
System.out.println("=================");
}
}
文件读写
当一个文件的比较小时,使用Files.readAllLines()
可以一次性读取整个文件,返回一个字符串列表。
public class ListOfLines {
public static void main(String[] args) throws IOException {
Files.readAllLines(Paths.get("threadlearn", "src", "dir", "RmDir.java"))
.stream()
.filter(line -> !line.startsWith("//"))
.forEach(System.out::println);
}
}
以上是一个非常简单的用例。如果希望将读取的内容写入到文件中,可以使用Files.write
方法,他接受字节数组或是任何可以迭代的对象。
在文件大小非常小时,上面的操作会有非常好的表现。但如果文件非常大,那么一次性读取会消耗大量的时间,同时也会占用大量内存。
要想解决这个问题,可以使用Files.lines
方法,它可以方便的将文件转为行的流,这样就可以快捷的处理大型文件。
标准I/O
标准IO指的是UNIX中程序所使用的单一信息流。程序的所有输入都可以来自标准输入,所有输出都可以发送到标准输出,所有错误都可以发送到标准错误。它的价值在于可以很容易地使多个程序串联起来,一个程序的标准输出可以称为另一个程序的标准输入。
标准I/O重定向
System
类可以通过静态方法重定向标准输入输出
setIn(InputSrream)
setOut(PrintStream)
setErr(PrintStream)
如果在屏幕上制造大量输出,并且滚动速度很快;或者需要用命令行程序反复测试特定的用户输入序列,就可以通过重定向输入做到。
public class Redirecting {
public static void main(String[] args) {
PrintStream console = System.out;
try ( BufferedInputStream in = new BufferedInputStream(new FileInputStream("Redirecting.java"));
PrintStream out = new PrintStream(new BufferedOutputStream(new FileOutputStream("Redirecting.txt"))))
{
System.setIn(in);
System.setOut(out);
System.setErr(out);
new BufferedReader(new InputStreamReader(System.in))
.lines()
.forEach(System.out::println);
} catch (IOException e) {
throw new RuntimeException(e);
} finally {
System.setOut(console);
}
}
}
新IO系统
在新I/O系统中,使用更接近操作系统的I/O实现方式提升速度:通道和缓冲区。发送方将数据打包到缓冲区中,然后将缓冲区中的数据推入通道当中,相比普通的I/O流,新I/O每次都会送入一整块数据,减少了交互次数,提升了效率。
字节缓冲区ByteBuffer
这是唯一直接和通道进行通信的类型,只需要告诉他分配多少内存,就可以创建出一块字节缓冲区。同时由于他是一种非常底层的处理方式,他可以做到在大多数OS中让内存映射更加高效。
public class GetChannel {
private static String name = "data.txt";
private static final int BSIZE = 1024;
public static void main(String[] args) {
try (FileChannel fc = new FileOutputStream(name).getChannel()) {
fc.write(ByteBuffer.wrap("Some text".getBytes(StandardCharsets.UTF_8)));
} catch (IOException e) {
throw new RuntimeException(e);
}
try (FileChannel fc = new RandomAccessFile(name, "rw").getChannel()) {
fc.position(fc.size());
fc.write(ByteBuffer.wrap("Some more".getBytes(StandardCharsets.UTF_8)));
} catch (IOException e) {
throw new RuntimeException(e);
}
try (FileChannel fc = new FileInputStream(name).getChannel()) {
ByteBuffer buffer = ByteBuffer.allocate(BSIZE);
fc.read(buffer);
buffer.flip();
while (buffer.hasRemaining()) {
System.out.write(buffer.get());
}
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}
上面的例子展现了如何使用通道和缓冲区。对于例子中出现的流,我们都使用getChannel()方法获取了一个FileChannel通道。通道的运行逻辑在这里先简单讲一下:向他传入一个用于读写的ByteBuffer,然后锁住文件区域保证独占式访问。
文件的写入操作非常容易理解,这里主要讲解一下只读操作。当我们需要对文件进行只读访问时,就需要使用allocate方法显式分配一个ByteBuffer。由于nio的目的就是快速移动大量数据,因此缓冲区的大小非常讲究,如果使用allocateDirect方法可以生成和OS结合度更高的直接缓冲区,不过这种方式开销较大。想找出合适的缓冲区大小还是要多次实验。
而一旦调用了read方法将字节写入到缓冲区,就必须调用flip方法让缓冲区做好提取字节的准备。
此外,文件的读写还要考虑编码问题,写入的编码与读取的解码必须保持一致才能输出正确的结果。这里可以使用 Charset工具类提供的一系列方法来实现。
缓冲区的细节
Buffer由数据和4个用于高效访问和操作数据的索引组成:mark,position,limit,capacity
内存映射文件
内存映射文件可以协助我们创建和修改那些因为过大而无法加载到内存中的文件。通过它,我们可以假定整个文件都加载到内存中了,将他当作一个非常大的数组来访问。
public class LargeMappedFiles {
static int length = 0x8000000;
public static void main(String[] args) {
try (RandomAccessFile tdat = new RandomAccessFile("test.dat", "rw")) {
MappedByteBuffer out = tdat.getChannel().map(FileChannel.MapMode.READ_WRITE, 0, length);
for (int i = 0; i < length; i++) {
out.put((byte) 'x');
}
System.out.println("Finish writing");
for (int i = length / 2; i < length / 2 + 6; i++) {
System.out.println(out.get(i));
}
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}
上面的例子先为文件生成管道,然后调用map()生成MappedByteBuffer(一种特殊类型的直接缓冲区),我们可以指定文件的起始位置和映射区域的长度,这可以让我们选择只映射大文件中的一小块区域。
枚举
所有的枚举类型都是由编译器通过继承Enum类来创建的。编译器会在生成枚举时,为它添加静态方法values()以及valueOf()。同时这个枚举会被限定为final类,因此枚举是无法继承的。由于生成的values()是对应枚举类的静态方法,因此它在向上转型之后就不可用了,不过我们可以通过Class中的getEnumConstants()
方法获取枚举。
枚举分组
虽然枚举无法继承,但他仍旧可以实现一个或多个接口。
接下来编写一个枚举工具类,让他可以随机选择某个枚举当中的值,T extends Enum<T> 表示传入的范型必然是个枚举类
public class Enums {
private static Random random = new Random(47);
public static <T extends Enum<T>> T random(Class<T> clazz) {
return random(clazz.getEnumConstants());
}
public static <T> T random(T[] values) {
return values[random.nextInt(values.length)];
}
}
由于枚举类型无法被继承,因此我们没办法通过常规的方式来扩充枚举中的元素,同时也不方便对枚举进行分组。
不过我们可以借助接口实现对元素的分组,然后基于该接口生成一个枚举。
在下面的实例中,每一个枚举元素都从属于Food这个接口,但同时他们又从属于不同的枚举
public interface Food {
enum Appetizer implements Food {
SALAD, SOUP, SPRING_ROLLS;
}
enum MainCourse implements Food {
LASAGNE, BURRITO, PAD_THAI,
LENTILS, HUMMUS, VINDALOO;
}
enum Dessert implements Food {
TIRAMISU, GELATO, BLACK_FOREST_CAKE,
FRUIT, CREME_CARAMEL;
}
enum Coffee implements Food {
BLACK_COFFEE, DECAF_COFFEE
}
public static void main(String[] args) {
Food food = Appetizer.SALAD;
food = Dessert.BLACK_FOREST_CAKE;
}
}
不过当我们希望处理一整组数据时,接口的效果往往不如枚举有用,我们可以通过在枚举内嵌套枚举来实现这样的功能。
public enum Meal {
APPETIZER(Food.Appetizer.class),
MAINCOURSE(Food.MainCourse.class),
DESSERT(Food.Dessert.class),
COFFEE(Food.Coffee.class);
private Food[] values;
private Meal(Class<? extends Food> clazz) {
values = clazz.getEnumConstants();
}
public interface Food {
enum Appetizer implements Food {
SALAD, SOUP, SPRING_ROLLS;
}
enum MainCourse implements Food {
LASAGNE, BURRITO, PAD_THAT
}
enum Dessert implements Food {
TIRAMISU, GELATO
}
enum Coffee implements Food {
BLACK_COFFEE, DECAF_COFFEE
}
}
public Food randomSelection() {
return Enums.random(values);
}
}
通过上面的示例,可以大致总结一下给接口分组的方法:使用接口将多组接口归类,然后再将每一组枚举作为一个新的枚举存储进另一个总的枚举当中方便对枚举分组的操作。
EnumSet
Set不允许有重复集合存在,enum要求每个内部成员都是唯一的,因此enum和Set之间有一定的共同点。但由于枚举无法添加或移除元素,它显得不如set那么好用。传统的枚举利用int来进行位标识,这种标识通常用于表示某种开关信息;但由于程序员操作的是与业务逻辑无关的对象,非常容易出错且不易读。
EnumSet的出现让我们不必再使用int来进行标识,他在内部使用一个long型变量当作位数组,因此他在效率上和位标识差距不大,但在代码上,他具备更好的表现能力,可以更好的与业务逻辑结合。
EnumSet内部使用long进行位标识,标志某一个枚举是否存在,如果枚举数量超过了64,他也会引入新的long型变量。
EnumMap
EnumMap要求自身所有的键都来自于某一个枚举,由于枚举本身具有的约束性,EnumMap的内部可以直接使用一个数组来实现,因此他的性能丝毫不用担心。
相比与普通的Map,EnumMap要求调用put传入的键必须是一个枚举,除此之外和普通的Map没什么区别。
下面是使用EnumMap实现的一个简单的Command模式
interface Command {
void action();
}
public class EnumMaps {
public static void main(String[] args) {
EnumMap<AlarmPoints, Command> map = new EnumMap<>(AlarmPoints.class);
map.put(AlarmPoints.LOBBY, () -> System.out.println("lobby"));
map.put(AlarmPoints.STAIR1, () -> System.out.println("stair1"));
map.put(AlarmPoints.STAIR2, () -> System.out.println("stair2"));
for (Map.Entry<AlarmPoints, Command> entry : map.entrySet()) {
entry.getValue().action();
}
}
}
如果有部分枚举没有配置值,就需要在提取时做一定处理,因为EnumMap会在初始化时将所有枚举对应的值初始化为null。
常量特定方法
Java的枚举机制可以通过为每个枚举实例编写不同的方法,来赋予他们不同的行为。要想实现这一点,只需要在枚举类型中定义一个或多个抽象方法,然后为每个枚举定义不同的实现。
public enum ConstantSpecificMethod {
DATE_TIME {
@Override
String getInfo() {
return DateFormat.getDateInstance().format(new Date());
}
},
CLASSPATH {
@Override
String getInfo() {
return System.getenv("CLASSPATH");
}
};
abstract String getInfo();
public static void main(String[] args) {
for (ConstantSpecificMethod csm : values()) {
System.out.println(csm.getInfo());
}
}
}
这样我们就实现了通过枚举实例来查找和调用方法,这通常叫表驱动模式。通过常量特定方法,枚举的各种实例可以拥有各自的行为。
不过编译器不会允许将枚举实例作为类类型来使用,因为在编译器完成编译后,每个枚举实例都是被static final修饰的。
枚举实现状态机
enum Category {
MONEY(Input.NICKEL, Input.DIME, Input.QUARTER, Input.DOLLAR),
ITEM_SELECTION(Input.TOOTHPASTE, Input.CHIPS, Input.SODA, Input.SOAP),
QUIT_TRANSACTION(Input.ABORT_TRANSACTION),
SHUT_DOWN(Input.STOP);
private Input[] values;
Category(Input... values) {
this.values = values;
}
private static EnumMap<Input, Category> categories = new EnumMap<>(Input.class);
static {
for (Category constant : Category.class.getEnumConstants()) {
for (Input value : constant.values) {
categories.put(value, constant);
}
}
}
public static Category categorize(Input input) {
return categories.get(input);
}
}
public class VendingMachine {
private static State state = State.RESTING;
private static int amount = 0;
private static Input selection = null;
enum StateDuration {
TRANSIENT
}
enum State {
RESTING {
@Override
void next(Input input) {
switch (Category.categorize(input)) {
case MONEY:
amount += input.amount();
state = ADDING_MONEY;
break;
case SHUT_DOWN:
state = TERMINAL;
default:
}
}
},
ADDING_MONEY {
@Override
void next(Input input) {
switch (Category.categorize(input)) {
case MONEY:
amount += input.amount();
break;
case ITEM_SELECTION:
selection = input;
if (amount < selection.amount()) {
System.out.println("Insufficient money for " + selection);
} else {
state = DISPENSING;
}
break;
case QUIT_TRANSACTION:
state = GIVING_CHANGE;
default:
}
}
},
DISPENSING(StateDuration.TRANSIENT) {
@Override
void next() {
System.out.println("here is your " + selection);
amount -= selection.amount();
state = GIVING_CHANGE;
}
},
GIVING_CHANGE(StateDuration.TRANSIENT) {
@Override
void next() {
if (amount > 0) {
System.out.println("Your change: " + amount);
}
state = RESTING;
}
},
TERMINAL {
@Override
void output() {
System.out.println("Halted");
}
};
private boolean isTransient = false;
State() {}
State(StateDuration trans) {
isTransient = true;
}
void next(Input input) {
throw new RuntimeException("Only call next(Input) for non-transient states" );
}
void next() {
throw new RuntimeException("Only call next() for StateDuration.TRANSIENT states");
}
void output() {
System.out.println(amount);
}
}
static void run(Supplier<Input> gen) {
while (state != State.TERMINAL) {
state.next(gen.get());
while (state.isTransient) {
state.next();
}
state.output();
}
}
public static void main(String[] args) {
Supplier<Input> gen = new RandomInputSupplier();
run(gen);
}
}
class RandomInputSupplier implements Supplier<Input> {
@Override
public Input get() {
return Input.randomSelection();
}
}
在上面的例子中,我们使用switch在枚举实例中进行选择操作。通常在组织多个枚举类型时,最常见的问题之一是“需要以什么粒度进行switch”。在例子中,我们根据输入的类型以及当前的状态进行选择操作。
多路分发
在讲解多路分发之前,先了解一下他的应用场景:假设我们现在要执行Number.plus(Number)这样一个方法,由于Number是数值家族的基类,当他被调用时,我们在不知道调用者和参数具体类型的情况下,如何保证他们的相互作用正确。
这种情景下很容易就会想到Java的动态绑定,但事实上Java只支持单路分发,也就是说只有调用者的具体类型会被动态绑定,参数的具体类型仍就是基类。因此如果我们想要实现多路分发就必须执行多次方法调用。
// item.java
public interface Item {
Outcome compete(Item item);
Outcome eval(Paper p);
Outcome eval(Scissors s);
Outcome eval(Rock r);
}
// Rock.java
public class Rock implements Item {
@Override
public Outcome compete(Item item) {
return item.eval(this);
}
@Override
public Outcome eval(Paper p) {
return Outcome.WIN;
}
@Override
public Outcome eval(Scissors s) {
return Outcome.LOSE;
}
@Override
public Outcome eval(Rock r) {
return Outcome.DRAW;
}
}
// Paper.java
public class Paper implements Item {
@Override
public Outcome compete(Item item) {
return item.compete(this);
}
@Override
public Outcome eval(Paper p) {
return Outcome.DRAW;
}
@Override
public Outcome eval(Scissors s) {
return Outcome.WIN;
}
@Override
public Outcome eval(Rock r) {
return Outcome.LOSE;
}
}
// Scissors.java
public class Scissors implements Item{
@Override
public Outcome compete(Item item) {
return item.compete(this);
}
@Override
public Outcome eval(Paper p) {
return Outcome.LOSE;
}
@Override
public Outcome eval(Scissors s) {
return Outcome.DRAW;
}
@Override
public Outcome eval(Rock r) {
return Outcome.WIN;
}
}
上面的示例中,为每一个Item类定义了一个compete方法,当他被调用时可以获取到调用者的具体类型。然后将参数作为调用者继续调用,并将自身作为参数传入比较方法,这样就完成了双路分发。
上面的实现在本质上其实是一种表驱动模式,让每一个实体去实现自己与其他所有类型实体比较的方法。因此,我们也可以使用EnumMap来实现这个功能。
interface Competitor <T extends Competitor<T>> {
Outcome compete(T competitor);
}
public enum RoShamBo implements Competitor<RoShamBo> {
PAPER, SCISSORS, ROCK;
static EnumMap<RoShamBo, EnumMap<RoShamBo, Outcome>> table = new EnumMap<>(RoShamBo.class);
static {
for (RoShamBo it : RoShamBo.values()) {
table.put(it, new EnumMap<>(RoShamBo.class));
}
initRow(PAPER, Outcome.DRAW, Outcome.LOSE, Outcome.WIN);
initRow(SCISSORS, Outcome.WIN, Outcome.DRAW, Outcome.LOSE);
initRow(ROCK, Outcome.LOSE, Outcome.WIN, Outcome.DRAW);
}
private static void initRow(RoShamBo it, Outcome vPaper, Outcome vScissors, Outcome vRock) {
EnumMap<RoShamBo, Outcome> row = RoShamBo.table.get(it);
row.put(PAPER, vPaper);
row.put(SCISSORS, vScissors);
row.put(ROCK, vRock);
}
@Override
public Outcome compete(RoShamBo competitor) {
return table.get(this).get(competitor);
}
}
并发编程
并发:同时完成多个任务,无需等待当前任务完成就可以执行其他的任务。解决了程序因为外部控制导致的阻塞,例如IO。因此并发问题常见于IO密集型任务。
并行:同时完成在多个位置,完成多个任务。即让多个CPU同时执行程序的不同部分来提升效率。
并发通过对共享资源的有效控制,提升程序效率。而并行则是通过使用更多的资源,来提升效率。
trick:抽象泄露,抽象可以通过屏蔽对任务不重要的部分,让人更加容易地理解并设计程序。但抽象如果有所遗漏,即使这些细节被隐藏,也难以掩盖它带来的影响。而支持并发的语言和库似乎多少都有这个问题。
并发的使用条件
并发操作需要CPU切换上下文,这会消耗CPU一定的性能。因此,如果程序是CPU密集的,即CPU一般都处于忙碌状态,此时使用并发是没有意义的,应当确保程序开启的线程数和CPU的核心数相等。
但如果CPU会因某些原因陷入阻塞状态,那么此时使用并发绝对是值得的。
并行流
在Java 8中,流可以通过使用分流器(流内部的一种特殊迭代器)来进行自动分割,从而更加轻松的实现并行化。在很多情况下,都可以通过将问题转换为流,然后插入parallel()
来提升速度。
例如,寻找素数这一类相当耗时的操作,在使用并行流之后,可以大大提升效率
public class Prime {
static final int COUNT = 100_000;
public static boolean isPrime(long n) {
return LongStream.rangeClosed(2, (long)Math.sqrt(n)).noneMatch(i -> n % i == 0);
}
public static void main(String[] args) {
Long start = System.currentTimeMillis();
List<String> primes = LongStream.iterate(2, i -> i + 1).parallel().filter(Prime::isPrime).limit(COUNT).mapToObj(Long::toString).collect(Collectors.toList());
Long end = System.currentTimeMillis();
System.out.println(end - start); // 最后结果为1101
}
}
流的并行运算初步总结:
- 流的并行化可以将输入的数据拆分成多个片段,然后针对这些独立的片段运用相应的算法。
- 数组的切分非常轻量,均匀,并且可以完全掌握分片的大小。
- 对于链表的切分则十分鸡肋,只会拆分成第一个元素和剩下的元素。因链表在内存中并非连续分布,难以把握分割的大小。
并行流的局限性:
- 当内存受限时,并行化操作的效率会大幅降低,此时,并行线程能够使用的辅助空间减少,可以开启的线程也会收到限制。
- 如果数组中存储的是对象引用,其速度也会下降。虽然该数组会被保存在缓存中,但其指向的对象几乎永远会在缓存之外。若存储的是基本类型,则缓存命中率会大大提升,程序执行也会更加高效。
- 如果
parallel()
和limit()
同时调用,会导致大量线程尽可能去获取输出,最后显示的结果可能是对应方法被大量调用,产生随机输出。
CompletableFuture
CompletableFuture
的类型为它包含的对象,并且任务的执行可以不依赖于ExecutorService
,由它自己管理。同时,我们可以通过在CompletableFuture
上增加操作,来控制其包含的对象。他们会自行对携带的对象进行拆包,包装操作。
除次之外,CompletableFuture
可以促使编程人员使用自私儿童原则(不共享),thenApply()
进行的操作不会产生任何通信,确保安全性。
基本用法
/**
* 一个用于测试的有限状态机
*/
public class Machine {
public enum State {
START, ONE, TWO, THREE, END;
State step() {
if (equals(END)) return END;
return values()[ordinal() + 1];
}
}
private State state = State.START;
private final int id;
public Machine(int id) {
this.id = id;
}
public static Machine work(Machine m) {
if (!m.state.equals(State.END)) {
try {
TimeUnit.MILLISECONDS.sleep(100);
m.state = m.state.step();
} catch (InterruptedException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
}
System.out.println(m);
return m;
}
@Override
public String toString() {
return "Machine: " + id + ":" + state;
}
}
public class applyAsync {
public static void main(String[] args) {
// 创建一个已完成的cf
CompletableFuture<Machine> cf = CompletableFuture.completedFuture(new Machine(0))
// 对包装的对象调用指定的方法
.thenApplyAsync(Machine::work)
.thenApplyAsync(Machine::work)
.thenApplyAsync(Machine::work)
.thenApplyAsync(Machine::work);
// 阻塞main线程,等待cf完成
cf.join();
}
}
在上述实例中用异步调用work
方法,让cf来替我们管理回调,cf库会将我们的请求操作链保存为一组回调,第一个后台操作完成后,第二个操作接受对应的Machine并开始工作。
其他的API
static void runAsync() // 调用run方法,不会产生返回值
void thenRunAsync() // 对象方法
static CompletableFuture supplyAsync(Supplier) // 传入一个supplier并异步执行,返回一个新的,执行了Supplier的cf
void thenAcceptAsync(Consumer) // 传入一个Consumer,执行指定的任务
void cancel(boolean) // 取消指定cf
void obtrudeValue(T) // 将对应cf的结果修改为指定值
Integer getNumberOfDependents() // 获取当前cf的依赖项数量 依赖项为正在等待该cf完成的cf的预估数量。
CompletableFuture 使用案例
public class Batter {
static class Egg {
}
static class Milk {
}
static class Sugar {
}
static class Flour {
}
static <T> T prepare(T ingredient) {
try {
TimeUnit.MILLISECONDS.sleep(1);
} catch (InterruptedException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
return ingredient;
}
static <T> CompletableFuture<T> prep(T ingredient) {
return CompletableFuture
.completedFuture(ingredient)
.thenApplyAsync(Batter::prepare);
}
public static CompletableFuture<Batter> mix() {
CompletableFuture<Egg> eggs = prep(new Egg());
CompletableFuture<Milk> milk = prep(new Milk());
CompletableFuture<Sugar> sugar = prep(new Sugar());
CompletableFuture<Flour> flour = prep(new Flour());
// 等待所有的材料都完成后再继续执行
CompletableFuture
.allOf(eggs, milk, sugar, flour)
.join();
try {
TimeUnit.MILLISECONDS.sleep(1);
} catch (InterruptedException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
return CompletableFuture.completedFuture(new Batter());
}
}
public class Baked {
static class Pan {
}
static Pan pan(Batter b) {
try {
TimeUnit.MILLISECONDS.sleep(1);
} catch (InterruptedException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
return new Pan();
}
static Baked heat(Pan d) {
try {
TimeUnit.MILLISECONDS.sleep(1);
} catch (InterruptedException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
return new Baked();
}
static CompletableFuture<Baked> bake(CompletableFuture<Batter> cfb) {
return cfb
.thenApplyAsync(Baked::pan)
.thenApplyAsync(Baked::heat);
}
public static Stream<CompletableFuture<Baked>> batch() {
CompletableFuture<Batter> batter = Batter.mix();
return Stream.of(bake(batter), bake(batter), bake(batter), bake(batter));
}
}
final class Frosting {
private Frosting() {
}
static CompletableFuture<Frosting> make() {
try {
TimeUnit.MILLISECONDS.sleep(1);
} catch (InterruptedException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
return CompletableFuture.completedFuture(new Frosting());
}
}
public class FrostedCake {
public FrostedCake(Baked baked, Frosting frosting) {
try {
TimeUnit.MILLISECONDS.sleep(1);
} catch (InterruptedException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
}
@Override
public String toString() {
return "FrostedCake";
}
public static void main(String[] args) {
// combineAsync() 当baked和frosting的cf完成后,再执行传入的函数
Baked.batch().forEach(baked -> baked
.thenCombineAsync(Frosting.make(),
(cake, frosting) -> new FrostedCake(cake, frosting))
.thenAcceptAsync(System.out::println)
.join());
}
}
CompletableFuture大体的设计思想应该是将某一个对象按照流水线的方式进行一系列处理,并且程序在整个过程中异步执行。
异常
CompletableFuture
在执行过程中产生的异常并不会立即抛出,而是会暂时缓存起来,只有当调用get()
获取结果时才会将异常抛出。也可以使用isCompletedExceptionally()
来检查是否正常完成,但是对于在最后一次执行时抛出异常的任务,该方法也会算作完成,它只能检查任务是否被异常中断。
对异常的处理方法:
- 使用
exceptionally()
,只有在出现异常时,该方法才会被调用,该方法的限制是,Function返回值的类型必须与传入类型相同。将一个正确的对象插回到流,可以使流恢复到正常状态。 handle()
和whenComplete()
每次都会被调用,因此必须检查fail是否为true,来确定是否有异常发生。但handle()
可以生成新的类型,允许程序员执行对应的处理(可以修改它接收到的结果对象)。而whenComplete()
只能做一定的逻辑处理,无法修改结果对象。
构造器的线程安全
对象的构造在绝大多数的情况下是不存在线程安全问题的,因为在对象构造出来之前,根本不可能被获取,也就不存在对它的竞争。因此,将构造器设为同步没有实际意义,反而会阻塞正在构造的对象,导致在对象的所有构造器完成之前,其他线程无法使用该对象。
但要强调的一点是,构造器能够避免的是线程对当前正在构造的对象的竞争。如果构造器本身要去竞争一个共享资源,便会导致线程安全问题。例如,构造器竞争一个生成id的对象,可能会导致有许多对象出现重复id。
由于在语言层面并不支持synchronized修饰构造器,但我们可以通过在构造器中添加synchronized
修饰的同步代码块来实现。
// concurrent/SynchronizedConstructor.java
import java.util.concurrent.atomic.*;
class SyncConstructor implements HasID{
private final int id;
private static Object constructorLock =
new Object();
SyncConstructor(SharedArg sa){
synchronized (constructorLock){
id = sa.get();
}
}
@Override
public int getID(){
return id;
}
}
此外,也可以将构造器设为私有,并实现一个静态工厂类来实现
// concurrent/SynchronizedFactory.java
import java.util.concurrent.atomic.*;
final class SyncFactory implements HasID{
private final int id;
private SyncFactory(SharedArg sa){
id = sa.get();
}
@Override
public int getID(){
return id;
}
public static synchronized SyncFactory factory(SharedArg sa){
return new SyncFactory(sa);
}
}
Stream和CompletableFuture的比较
并行流方案更适合解决可以无脑并行的问题(容易将数据拆分成无差别、易处理片段的问题)。它更多的面向数据的处理。而CompletableFuture则面向任务,某种程度上来说,是以高效的流水线模式去完成某一特定的任务。
底层并发
并发将程序分割成为多个独立运行的任务,每个任务都由执行线程所驱动,通常称为线程。
线程是操作系统进程内按单一顺序执行的控制流,由此一个进程可以包含多个并发执行的任务。
Thread
是一种将任务和处理器关联起来的软件结构,在创建Thread时,JVM会在一块专为Thread保留的内存区域中分配一大块空间,从而为任务的运行提供所需的一切。
- 一个程序计数器,指示要执行的下一条JVM字节码指令
- 一个支持Java代码执行的栈,包含该线程到达当前执行节点前调用过的方法的相关信息。它同时还包含正在执行的方法的所有本地变量。在每个线程中,该栈的大小通常在64KB和1MB之间。
- 一个用于本地代码的栈
- 本地线程变量存储
- 控制线程的状态维护变量
最佳线程数
线程通常的最佳数量就是可用处理器的数量。因为Java在进行上下文切换时有较大的开销
上下文切换涉及的操作:
- 保存要挂起的线程的当前状态
- 读取要执行的线程在进入挂起状态时的实时状态
工作窃取线程池
WorkStealingPool 一种能基于所有可用处理器自动创建线程池的ExecutorService
工作窃取:已完成自身输入队列中所有任务的线程可以窃取其他线程队列中的工作项。
该算法的目的是在执行计算密集型任务时能够跨处理器分发工作项,最大化所有可用处理器的利用率。
捕获异常
在线程中抛出的异常用常规方法是无法捕获的,异常一旦逃逸出线程的run()方法,就会扩散到控制台,驱动线程的代码段中的try-catch不会起作用。正确的做法是为指定的线程设置对应的异常处理器。
class ExceptionThread implements Runnable {
@Override
public void run() {
throw new RuntimeException("error");
}
}
class MyUncaughtExceptionHandler implements Thread.UncaughtExceptionHandler {
@Override
public void uncaughtException(Thread t, Throwable e) {
System.out.println("caught " + e);
}
}
class HandlerThreadFactory implements ThreadFactory {
@Override
public Thread newThread(Runnable r) {
System.out.println(this + " create a new thread");
Thread t = new Thread(r);
t.setUncaughtExceptionHandler(new MyUncaughtExceptionHandler());
return t;
}
}
public class CaptureExceptionInThread {
public static void main(String[] args) throws InterruptedException {
ExecutorService exec = Executors.newCachedThreadPool(new HandlerThreadFactory());
exec.execute(new ExceptionThread());
exec.shutdown();
}
}
如果要在所有地方都应用同一套异常处理,最简单的方法就是设置线程的默认异常处理器Thread.setDefaultUncaughtExceptionHandler(new MyUncaughtExceptionHandler())
共享资源
当要启动一项任务来执行某些操作时,操作的结果可以通过两种不同的方式捕获:副作用或是返回值。
tip:副作用的方式就是操作环境中的某个东西。
对于副作用这种方法来说,它存在的问题就是资源竞争,解决这种问题的最简单方式,就是使用能够处理资源竞争的对象来作为共享资源。
接下来的演示基于一个偶数检查器的实现,我们约定当检查器遇到了非偶数值时,就停止生成器的运作。
在多线程的环境下,有多个线程需要使用整数生成器,当检查器发现了非偶数值出现,但在做出修改之前,又恰好有一个线程启动了生成器。就会导致检查器在结束了生成器之后依旧会收到值。多个任务竞争的去响应生成器这个条件,就是所谓的竞态条件
竞态条件:两个以上的任务竞争响应某个条件,并因此发生冲突/产生不一致的结果。
因此,我们选择使用原子布尔型来表示生成器的状态,这样就不会竞争得去修改生成器的状态,同时所有的任务状态也不会依赖于其他的任务。
public class EvenChecker implements Runnable {
private IntGenerator generator;
private final int id;
public EvenChecker(IntGenerator generator, int id) {
this.generator = generator;
this.id = id;
}
@Override
public void run() {
while (!generator.isCanceled()) {
int val = generator.next();
if (val % 2 != 0) {
System.out.println(val + "not even!");
generator.cancel();
}
}
}
public static void test(IntGenerator gp, int count) {
List<CompletableFuture<Void>> checkers =
IntStream.range(0, count)
.mapToObj(i -> new EvenChecker(gp, i))
.map(CompletableFuture::runAsync)
.collect(Collectors.toList());
checkers.forEach(CompletableFuture::join);
}
public static void test(IntGenerator gp) {
new TimeAbort(4, "No odd numbers discovered");
test(gp, 10);
}
}
通常情况下,我们都会假定test()会失败,不过要确保自动化构建不会卡死,就需要手动编写一个负责超时处理的类,也就是接下来的TimeAbort。这里使用runAsync
是因为它可以立即返回调用,因此不会阻塞任何其他的任务
public class TimeAbort {
private volatile boolean restart = true;
public TimeAbort(double t, String msg) {
CompletableFuture.runAsync(() -> {
try {
while (restart) {
restart = false;
TimeUnit.MILLISECONDS.sleep((int) (1000 * t));
}
} catch (InterruptedException e) {
throw new RuntimeException();
}
System.out.println(msg);
System.exit(0);
});
}
public TimeAbort(double t) {
this(t, "TimeAbort " + t);
}
public void restart() {
restart = true;
}
}
接下来让我们看看IntGenerator
的第一种实现
public class EvenProducer extends IntGenerator {
private int currentEvenValue = 0;
@Override
public int next() {
++currentEvenValue;
++currentEvenValue;
return currentEvenValue;
}
public static void main(String[] args) {
EvenChecker.test(new EvenProducer());
}
}
它的共享变量currentEvenValue没有任何的保护,因此在多线程环境下,无法确保输出的值正确。
[!tip]
Java的自增操作并不是原子操作,某个任务也可能在自增操作中途被挂起。
上面的示例代码中有存在明显的线程冲突问题,而解决的方案之一就是将共享资源的访问操作序列化。Java实现了synchronized
关键字这种形式上的内建支持,当一个任务想要执行一段由该关键字保护的代码段时,编译器会生成代码来确认锁是否可用。
共享资源一般只是以对象形式存在的一段内存,但他也可以是一个文件、IO端口等等。要控制对贡献资源的访问,首先要将资源放入一个对象中
[!note] Brian同步法则
如果你在对一个可能接下来会被另一个线程读取的变量进行写操作,或者读取一个可能刚被另一个线程完成写操作的变量,就必须使用同步,并且读操作和一个写操作都必须用同一个监视器同步
volatile关键字
使用volatile的主要三个原因
字分裂
字分裂出现在数据类型足够大,对某个变量的写操作过程分为两个步骤的时候。*JVM允许将对64位数的读写操作分为两次对32位数的读写操作(但这不会在64位系统上发生)*。这就增加了在读写过程中发生上下文切换的可能性,其他任务可能会看到某数仅完成了部分变更时的值。
使用volatile可以避免字分裂的错误结果被读取到,但该功能同样可以被synchronized
或对应的atomic类型实现。
可见性
Java并发定律第二条:“一切都不可信,一切都很重要”。必须假定每个任务都有自己的处理器,每个处理器都有自己的本地缓存。但在并发环境下,有时处理器的本地缓存会与主存中的数据不一致,也就是缓存一致性问题。
如果将一个变量定义为volatile,那么每次都会从内存中读取。如果对他进行写入操作,也会被立刻写入主存。
对于使用同步操作保护的变量则可以不用volatile修饰,因为同步会触发刷新到主存的操作。
指令重排序和先行发生
Java可能会通过对指令进行重排序来优化性能,只要结果不会造成程序行为上的改变。但是重排序可能会影响逻辑处理器缓存和主存的交互方式。现在的volatile关键字通过先行发生保证来避免错误的重排序。
先行发生:
- 在对volatile变量的读写操作之前出现的指令,保证会在该读写操作之前执行,因此volatile操作通常又被称为内存栅栏,确保volatile变量的读写指令无法穿过内存栅栏被重排序。
- 先行发生的另一个特性就是,在线程对某一个volatile变量执行写操作时,所有在该写操作之前被线程修改的其他变量——包括非volatile变量也会被刷新到主存当中。
原子性
原子操作:不会被线程调度器中断的操作,一旦操作开始,直到完成之前,中途都不可能发生上下文切换。
我们常常会错误的认为原子操作不需要同步。然而在多核系统中,可见性是比原子性重要的多的问题,一个任务做出的修改,即使是原子操作,也可能对其他操作是不可见的(修改可能被临时保存在本地处理器缓存中)。
Lock对象
Java的并发库提供了显式的互斥机制,Lock对象必须显式的创建、加锁以及解锁。在使用lock时,必须在调用lock()之后放置一个try-finally语句,并在finally语句中unlock()。同时return必须包含在try子句中,避免数据被过早暴露给下一个任务。
虽然Lock显式调用的特点比使用synchronized需要的代码多,但相对的,我们也获得了对异常处理的权限,可以有机会执行清理操作,让系统维持在正常的状态。
一般来说,如果我们希望获取锁后就立刻主动放弃或等待一段时间后放弃,就需要使用lock
使用实例
public class AttemptLocking {
private ReentrantLock lock = new ReentrantLock();
public void untimed() {
boolean captured = lock.tryLock();
try {
System.out.println("tryLock(): " + captured);
} finally {
if (captured) {
lock.unlock();
}
}
}
public void timed() {
boolean captured = false;
try {
captured = lock.tryLock(2, TimeUnit.SECONDS);
System.out.println("tryLock(2, TimeUnit.SECONDS): " + captured);
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
if (captured) {
lock.unlock();
}
}
}
public static void main(String[] args) throws InterruptedException {
final AttemptLocking al = new AttemptLocking();
al.untimed();
al.timed();
CompletableFuture.runAsync(() -> {
al.lock.lock();
System.out.println("acquired");
});
TimeUnit.MILLISECONDS.sleep(1);
al.untimed();
al.timed();
}
}
DelayQueue
这是一种实现了Delayed接口的对象组成的无边界BlockingQueue
。一个对象只有在延迟时间到期后才能从队列中取出。队列是有序的,所以头部的延迟时间最短,如果没有到达延迟时间,那么头部元素就相当于不存在。
下面是使用示例。在延迟队列中,所有的任务会按照延迟时间的大小进行排序
class DelayedTask implements Runnable, Delayed {
private static int counter = 0;
private final int id = counter++;
private final int delta;
private final long trigger;
protected static List<DelayedTask> sequence = new ArrayList<>();
DelayedTask(int deltaInMilliseconds) {
delta = deltaInMilliseconds;
trigger = System.nanoTime() + TimeUnit.NANOSECONDS.convert(delta, TimeUnit.MILLISECONDS);
sequence.add(this);
}
@Override
public int compareTo(Delayed o) {
DelayedTask task = (DelayedTask) o;
return Long.compare(trigger, task.trigger);
}
@Override
public void run() {
System.out.print(this + " ");
}
@Override
public String toString() {
return String.format("[%d] Task %d", delta, id);
}
@Override
public long getDelay(TimeUnit unit) {
return unit.convert(trigger - System.nanoTime(), TimeUnit.NANOSECONDS);
}
public String summary() {
return String.format("(%d:%d)", id, delta);
}
public static class EndTask extends DelayedTask {
EndTask(int deltaInMilliseconds) {
super(deltaInMilliseconds);
}
@Override
public void run() {
sequence.forEach(delayedTask -> System.out.println(delayedTask.summary()));
}
}
}
public class DelayQueueDemo {
public static void main(String[] args) throws InterruptedException {
DelayQueue<DelayedTask> tasks = Stream.concat(
new Random(47).ints(20, 0, 4000)
.mapToObj(DelayedTask::new),
Stream.of(new DelayedTask.EndTask(4000))
)
.collect(Collectors.toCollection(DelayQueue::new));
while (tasks.size() > 0) {
tasks.take().run();
}
}
}
无锁集合
无锁集合具备一项特性:集合可以在读取的同时进行修改,只要读取方只能看见已完成的修改结果。接下来介绍几个相关的策略
复制策略
利用复制策略,修改是在部分数据结构的一个单独副本上进行的,该副本在修改过程中不可见,只有在完成修改后,修改后的结构才会安全的与主数据结构交换,然后读取方才能看到修改。
在CopyOnWriteArrayList
中,写操作会复制整个底层数组。原始数组被留在原地,修改完成后,会通过原子操作将数组交换进去。
CAS操作
比较交换(Compare-And-Swap)操作中,从内存中取出一个值之后,在计算新值的同时继续使用原始值(会保存取出的值)。然后通过CAS指令,将保存的原始值和内存中的值进行比较,如果两者相等,就将旧的值替换为新的计算结果。如果比较失败,就代表有其他的线程对内存中的值进行了修改,在这种情况下就必须进行重试。
从上面的描述上我们可以清晰地看出,CAS本质上是采用的乐观锁,因此他在内存竞争不激烈的情况下速度非常快,但在竞争激烈时,冲突次数就会急剧上升,效率大幅下降。
- 标题: On java
- 作者: Zephyr
- 创建于 : 2022-10-09 10:00:50
- 更新于 : 2023-03-06 10:05:27
- 链接: https://faustpromaxpx.github.io/2022/10/09/on java/
- 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。