开发者博客 – IT技术 尽在开发者博客

开发者博客 – 科技是第一生产力


  • 首页

  • 归档

  • 搜索

finalize 方法重写对 GC 的影响分析

发表于 2021-11-02

这是我参与11月更文挑战的第 2 天,活动详情查看:2021最后一次更文挑战

关于 Object 的 finalize 方法,在日常开发中可能有超过 99% 的人都没有关注过,因为业务开发很少有重写 finalize 方法的场景;开发者对于 finalize 的认知大多在是“面试八股文”中,而且也不乏见到将 finalize、finally 以及 final 放在一块比较的 case,面试官可能是出于对初学者 java 基本语言知识的考量,但是这真的有意义吗?

本文将用一个非常简单的 case 来直观的看下,finalize 方法重写带来的影响。

finalize 方法是什么

下面我们直接来看下 finalize 的代码注释。

1
2
3
4
vbnet复制代码* Called by the garbage collector on an object when garbage collection
* determines that there are no more references to the object.
* A subclass overrides the {@code finalize} method to dispose of
* system resources or to perform other cleanup.

finalize 是 java 的顶级父类 Object 中的一些方法,默认情况下 finalize 方法是空实现;其调时机是:当前对象没有任何引用时,执行 GC 时被调用。子类可以重写了 finalize 方法去释放系统资源或执行其他清理。

1
2
3
4
5
6
7
8
9
10
11
12
13
kotlin复制代码* The general contract of {@code finalize} is that it is invoked
* if and when the Java™ virtual
* machine has determined that there is no longer any
* means by which this object can be accessed by any thread that has
* not yet died, except as a result of an action taken by the
* finalization of some other object or class which is ready to be
* finalized. The {@code finalize} method may take any action, including
* making this object available again to other threads; the usual purpose
* of {@code finalize}, however, is to perform cleanup actions before
* the object is irrevocably discarded. For example, the finalize method
* for an object that represents an input/output connection might perform
* explicit I/O transactions to break the connection before the object is
* permanently discarded.

finalizer 方法的调用时机由 JavaTM 开发商决定:简单说就是要确定对象的任何方法都不(再)会被调用时,再调用其 finalize 方法。除非一些其他的已经准备好被终止的对象或类将调用 finalize 方法,包括在其终止动作之中(即调用对象的 finalize 方法,此时该对象的 finalize 方法将是最后被调用的方法,在这之后,对象的任何方法都不(再)会被调用。finalize 方法中可以执行任何操作,包括再次使该对象可用于其它线程(重新初始化);但是 finalize 的通常目的是在对象(一定)不再被需要时(对象将被丢弃)之前执行清除操作。例如,表示input/output 连接的对象的 finalize 方法可能会在对象被永久丢弃之前执行显式 I/O 事务来中断连接。

1
2
3
4
5
6
kotlin复制代码* The Java programming language does not guarantee which thread will
* invoke the {@code finalize} method for any given object. It is
* guaranteed, however, that the thread that invokes finalize will not
* be holding any user-visible synchronization locks when finalize is
* invoked. If an uncaught exception is thrown by the finalize method,
* the exception is ignored and finalization of that object terminates.

java 语言不对任何对象的 finalize 方法调用发生的线程做限制,即任何线程都可以调用对象的 finalize 方法,然而,调用 finalize 方法的线程将不能持有任何用户可见的线程同步锁。当 finalize 方法被调用时,如果 finalize 方法抛出异常,且异常未被捕获时,异常将被忽略,finalize 方法将中止。

1
2
3
4
5
6
kotlin复制代码* After the {@code finalize} method has been invoked for an object, no
* further action is taken until the Java virtual machine has again
* determined that there is no longer any means by which this object can
* be accessed by any thread that has not yet died, including possible
* actions by other objects or classes which are ready to be finalized,
* at which point the object may be discarded.

当对象的 finalize 方法被调用后,不会再有基于该对象的方法调用,直到 JVM 再次进行回收动作时该对象将被释放,占用的内存将被回收。

另外,任何对象的 finalize 方法只会被 JVM 调用一次。finalize()方法引发的任何异常都会导致该对象的终止被暂停,否则被忽略。

finalize 方法重写对 GC 的影响

这里丢一个简单的例子,TestMain 类重写了 finalize 方法,并且在 finalize 方法中已创建的对象总数 COUNT 做减操作,并且没隔 100000 次输出下当前 COUNT。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
java复制代码public class TestMain {

private static AtomicInteger COUNT = new AtomicInteger(0);

public TestMain() {
COUNT.incrementAndGet();
}

/**
* 重写 finalize 方法测试
* @throws Throwable
*/
@Override
protected void finalize() throws Throwable {
COUNT.decrementAndGet();
}

public static void main(String args[]) {
for (int i = 0 ;; i++) {
TestMain item = new TestMain();
if ((i % 100000) == 0) {
System.out.format("creating %d objects, current %d are alive.%n", new Object[] {i, COUNT.get() });
}
}
}
}

运行环境:MacOS 10.14.6

JVM 参数:-XX:+PrintGCDetails -Xms200M -Xmx200M -Xmn100M

执行这段代码,可以在控制台观察,会出现以下几个阶段:

阶段一:第一次执行 GC 的时候

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
sql复制代码creating 0 objects, current 1 are alive.
creating 100000 objects, current 100001 are alive.
creating 200000 objects, current 200001 are alive.
creating 300000 objects, current 300001 are alive.
creating 400000 objects, current 400001 are alive.
creating 500000 objects, current 500001 are alive.
creating 600000 objects, current 600001 are alive.
creating 700000 objects, current 700001 are alive.
creating 800000 objects, current 800001 are alive.
creating 900000 objects, current 900001 are alive.
creating 1000000 objects, current 1000001 are alive.
creating 1100000 objects, current 1100001 are alive.
creating 1200000 objects, current 1200001 are alive.
// ygc 失败,下面直接进行 fgc 了
[GC (Allocation Failure) [PSYoungGen: 76800K->12800K(89600K)] 76800K->71066K(192000K), 0.3839994 secs] [Times: user=1.87 sys=0.07, real=0.38 secs]
// 执行 fgc
[Full GC (Ergonomics) [PSYoungGen: 12800K->0K(89600K)] [ParOldGen: 58266K->70801K(102400K)] 71066K->70801K(192000K), [Metaspace: 3696K->3696K(1056768K)], 1.3266229 secs] [Times: user=4.89 sys=0.17, real=1.33 secs]
creating 1300000 objects, current 1296221 are alive.

看下 GC 之后,COUNT 中统计的存活的对象数还是有很多。

阶段二:频繁 fgc

1
2
3
4
5
6
7
8
9
10
11
12
13
scss复制代码creating 3200000 objects, current 3132405 are alive.
creating 3300000 objects, current 3230808 are alive.
[Full GC (Ergonomics) [PSYoungGen: 76800K->75818K(89600K)] [ParOldGen: 102171K->102028K(102400K)] 178971K->177846K(192000K), [Metaspace: 3716K->3716K(1056768K)], 0.8383374 secs] [Times: user=4.93 sys=0.09, real=0.84 secs]
[Full GC (Ergonomics) [PSYoungGen: 76800K->76768K(89600K)] [ParOldGen: 102028K->102028K(102400K)] 178828K->178797K(192000K), [Metaspace: 3716K->3716K(1056768K)], 0.3276008 secs] [Times: user=2.24 sys=0.01, real=0.33 secs]
[Full GC (Ergonomics) [PSYoungGen: 76800K->76784K(89600K)] [ParOldGen: 102028K->102028K(102400K)] 178828K->178813K(192000K), [Metaspace: 3716K->3716K(1056768K)], 0.2876044 secs] [Times: user=1.74 sys=0.03, real=0.29 secs]
[Full GC (Ergonomics) [PSYoungGen: 76800K->76775K(89600K)] [ParOldGen: 102028K->102028K(102400K)] 178828K->178803K(192000K), [Metaspace: 3716K->3716K(1056768K)], 0.2761930 secs] [Times: user=1.74 sys=0.02, real=0.28 secs]
[Full GC (Ergonomics) [PSYoungGen: 76800K->76778K(89600K)] [ParOldGen: 102028K->102028K(102400K)] 178828K->178806K(192000K), [Metaspace: 3716K->3716K(1056768K)], 0.3522859 secs] [Times: user=1.67 sys=0.02, real=0.35 secs]
[Full GC (Ergonomics) [PSYoungGen: 76800K->76786K(89600K)] [ParOldGen: 102028K->102028K(102400K)] 178828K->178814K(192000K), [Metaspace: 3716K->3716K(1056768K)], 0.2609472 secs] [Times: user=1.36 sys=0.02, real=0.26 secs]
[Full GC (Ergonomics) [PSYoungGen: 76800K->76793K(89600K)] [ParOldGen: 102028K->102028K(102400K)] 178828K->178821K(192000K), [Metaspace: 3716K->3716K(1056768K)], 0.2691448 secs] [Times: user=1.28 sys=0.01, real=0.27 secs]
[Full GC (Ergonomics) [PSYoungGen: 76800K->76787K(89600K)] [ParOldGen: 102028K->102028K(102400K)] 178828K->178816K(192000K), [Metaspace: 3716K->3716K(1056768K)], 0.2254074 secs] [Times: user=1.37 sys=0.01, real=0.22 secs]
[Full GC (Ergonomics) [PSYoungGen: 76800K->76779K(89600K)] [ParOldGen: 102028K->102028K(102400K)] 178828K->178808K(192000K), [Metaspace: 3716K->3716K(1056768K)], 0.2670959 secs] [Times: user=1.77 sys=0.02, real=0.27 secs]
[Full GC (Ergonomics) [PSYoungGen: 76800K->76778K(89600K)] [ParOldGen: 102028K->102028K(102400K)] 178828K->178807K(192000K), [Metaspace: 3716K->3716K(1056768K)], 0.2704234 secs] [Times: user=1.71 sys=0.02, real=0.27 secs]
[Full GC (Ergonomics) [PSYoungGen: 76800K->76785K(89600K)] [ParOldGen: 102028K->102028K(102400K)] 178828K->178813K(192000K), [Metaspace: 3716K->3716K(1056768K)], 0.3302090 secs] [Times: user=1.84 sys=0.02, real=0.33 secs]

差不多在 330 万次时,就开始持续有 fgc 的情况了;可以看到各个数据区都被占满了。

阶段三:OOM

1
2
3
4
scss复制代码[Full GC (Ergonomics) [PSYoungGen: 76800K->76784K(89600K)] [ParOldGen: 102028K->101994K(102400K)] 178828K->178778K(192000K), [Metaspace: 3716K->3716K(1056768K)], 0.4323213 secs] [Times: user=2.13 sys=0.04, real=0.43 secs] 
[Full GC (Ergonomics) Exception in thread "main" java.lang.OutOfMemoryError: GC overhead limit exceeded[PSYoungGen: 76799K->76697K(89600K)] [ParOldGen: 101994K->101994K(102400K)] 178793K->178691K(192000K), [Metaspace: 3720K->3720K(1056768K)], 0.3325707 secs] [Times: user=1.70 sys=0.02, real=0.33 secs]
Heap
PSYoungGen total 89600K, used 76800K [0x00000007b9c00000, 0x00000007c0000000, 0x00000007c0000000) eden space 76800K, 100% used [0x00000007b9c00000,0x00000007be700000,0x00000007be700000) from space 12800K, 0% used [0x00000007be700000,0x00000007be700000,0x00000007bf380000) to space 12800K, 0% used [0x00000007bf380000,0x00000007bf380000,0x00000007c0000000) ParOldGen total 102400K, used 101994K [0x00000007b3800000, 0x00000007b9c00000, 0x00000007b9c00000) object space 102400K, 99% used [0x00000007b3800000,0x00000007b9b9a8e0,0x00000007b9c00000) Metaspace used 3751K, capacity 4670K, committed 4864K, reserved 1056768K class space used 404K, capacity 434K, committed 512K, reserved 1048576K at java.lang.ref.Finalizer.register(Finalizer.java:87) at java.lang.Object.<init>(Object.java:37) at com.glmapper.bridge.boot.finalize.TestMain.<init>(TestMain.java:13) at com.glmapper.bridge.boot.finalize.TestMain.main(TestMain.java:28)

在创建了 330 万个对象后就抛出 java.lang.OutOfMemoryError: GC overhead limitt exceeded 异常退出了。从我工程测试日志中看到,基本全都都是 fgc(只有一次 ygc),从代码看,这些对象并没有什么特殊,代码层面也没有引用,但是 JVM 就直接使用代价更高的 Full GC 来清理老生代和持久代的空间了。所以 why ?

那作为对比,我们把代码中重写 finalize 的代码逻辑去掉再跑一次:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
scss复制代码creating 111000000 objects 
[GC (Allocation Failure) [PSYoungGen: 99328K->0K(100864K)] 100161K->833K(203264K), 0.0008235 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
creating 112000000 objects
creating 113000000 objects
creating 114000000 objects
creating 115000000 objects
creating 116000000 objects
creating 117000000 objects
[GC (Allocation Failure) [PSYoungGen: 99328K->0K(100864K)] 100161K->833K(203264K), 0.0005181 secs] [Times: user=0.00 sys=0.00, real=0.01 secs]
creating 118000000 objects
creating 119000000 objects
creating 120000000 objects
creating 121000000 objects
creating 122000000 objects
creating 123000000 objects
creating 124000000 objects
[GC (Allocation Failure) [PSYoungGen: 99328K->0K(100864K)] 100161K->833K(203264K), 0.0004161 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
creating 125000000 objects
creating 126000000 objects
creating 127000000 objects
creating 128000000 objects
creating 129000000 objects
creating 130000000 objects
[GC (Allocation Failure) [PSYoungGen: 99328K->32K(101376K)] 100161K->865K(203776K), 0.0004908 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
creating 131000000 objects
creating 132000000 objects
creating 133000000 objects
creating 134000000 objects
creating 135000000 objects
creating 136000000 objects

可以看到,在创建 13600 万对象时仍然可以继续跑,并且通过 GC 日志看,也只有 ygc , 没有一次 fgc。所以这个和重写 finalize 时的差距还是非常大的。那么下面就来分析下具体原因。

GC 影响分析

这里思路很简单,首先我们要知道是什么对象导致了 OOM,要找出来。

找到占用空间的元凶

在启动参数中加上 -XX:+HeapDumpOnOutOfMemoryError 参数,重新执行一次,在出现 OOM 时会执行一次 heap dump

1
2
3
bash复制代码java.lang.OutOfMemoryError: GC overhead limit exceeded
Dumping heap to java_pid88685.hprof ...
Heap dump file created [320504328 bytes in 3.050 secs]

通过分析工具(我使用的是 Jprofile 2),看到都是 Finalizer 这个类的对象实例

在我的测试代码中,非常明确没有创建 Finalizer 对象的逻辑,那为什么会有这么多 Finalizer 对象实例呢?其实从上面 OOM 堆栈那里已经可以看出些端倪了:

1
2
3
4
5
kotlin复制代码class space    used 404K, capacity 434K, committed 512K, reserved 1048576K
at java.lang.ref.Finalizer.register(Finalizer.java:87)
at java.lang.Object.<init>(Object.java:37)
at com.glmapper.bridge.boot.finalize.TestMain.<init>(TestMain.java:13)
at com.glmapper.bridge.boot.finalize.TestMain.main(TestMain.java:28)

在堆栈中看到了 Finalizer.register 这样一个方法执行,把断点打在这里:

对于重写 finalize 方法的类,在创建其实例时,会同时创建一个 Finalizer 实例,这些所有的 Finalizer 实例又会为 Finalizer 类所引用,由于存在这么一个引用链关系存在,所以整个的这些对象都是存活的;所以当 Eden 区满了之后,此时所有的对象还是存活的,所以并不会被回收掉,继而只能将他们进一步放到 Suvivor 区去,但是由于这些对象不会被释放,引用一直存在,所以 Suvivor 区也很快被占满,既然这些对象被放到老年代,直到存入元数据空间,最后 OOM;所以前面提到的,不是 JVM 不使用 ygc ,而是基于既定规则下,ygc 并不能将这些存活的对象回收掉。关于引用链通过 Jprofile 也可以直观的得到结论

如何被回收的?

那是不是就一直没法被回收呢?其实也不是,我们看到在执行了 fgc 之后,还是有一些对象被回收掉的。那就是说,这些被引用的对象,还是有可能被释放的;那其实就看这个对象什么时候从下面这个队列中被弹出。

1
arduino复制代码private static ReferenceQueue<Object> queue = new ReferenceQueue<>();

被弹出的对象在下一次 GC 的时候就会被认为已经没有任何引用从而被回收掉。

Finalizer 线程: FinalizerThread

FinalizerThread 的职责非常简单,就是不停的循环等待 ReferenceQueue 中的新增对象,然后弹出这个对象,调用它的 finalize() 方法,将该引用从 Finalizer 类中移除,因此下次 GC 再执行的时候,这个 Finalizer 实例以及它引用的那个对象就可以回垃圾回收掉了。

finalize() 方法的调用会比你创建新对象要早得多,因此大多数时候,Finalizer 线程能够赶在下次 GC 带来更多的 Finalizer 对象前清空这个队列。

既然如此,那为什么会出现 OOM 呢?因为 Finalizer 线程和主线程相比它的优先级要低。这意味着分配给它的CPU 时间更少,因此它的处理速度没法赶上新对象创建的速度。这就是问题的根源——对象创建的速度要比Finalizer 线程调用 finalize() 结束它们的速度要快,这导致最后堆中所有可用的空间都被耗尽了,结果就出现了 OOM 这种情况。(PS: 案例代码在一直循环创建新的对象)

总结

通过上面的 case 和分析,可以知道,对于重写了 finalize 的类,其对象的生命周期和普通对象的生命周期是完全不一样的。对于重写了 finalize 的类,其生命周期大致如下:

  • JVM 创建 TestMain 对象
  • JVM 创建一个 Finalizer 对象,指向 TestMain 对象
  • Finalizer 类持有新创建的 Finalizer 的实例,使得下一次新生代 GC 无法回收这些对象
  • 新生代 GC 无法清空 Eden 区(引用被持有了),因此会将这些对象移到 Survivor 区或者老生代
  • 垃圾回收器发现这些对象实现了finalize() 方法,因为会把它们添加到 ReferenceQueue 队列中
  • FinalizerThread 处理 ReferenceQueue 队列,将里面的对象逐个弹出,并调用它们的 finalize() 方法
  • finalize() 方法调用完后,FinalizerThread 会将引用从 Finalizer 类中去掉,因此在下一轮 GC 中,这些对象就可以被回收了

所以,如果你有使用 finalize() 方法的情况,如果不是使用常规的方式来清理对象的话,最好是多考虑一下。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

Java集合之Set

发表于 2021-11-02

这是我参与11月更文挑战的第2天,活动详情查看:2021最后一次更文挑战

Set 是 Java 中常用集合之一,其重点实现类包括 HashSet、LinkedHashSet 和 TreeSet。

Java集合-Set.png

HashSet

HashSet 通过 hash 表存储元素 ,其底层是 HashMap。HashSet 的数据就是 HashMap 的键,HashMap 的值为空对象。

  1. HashSet 的底层是什么?

由其构造方法可以发现,当我们创建一个 HashSet 对象时,其实就是创建了一个键为指定数据,值为空 Object 类型的 HashMap。

1
2
3
4
5
6
7
8
9
10
11
java复制代码 // 用来存储 HashSet 数据的变量
 private transient HashMap<E,Object> map;

 public HashSet() {
     map = new HashMap<>();
 }

 public boolean add(E e) {
     // PRESENT 为 Object 类型的空对象
     return map.put(e, PRESENT)==null;
 }
  1. HashSet 的初始容量是多少,负载因子是多少?

因为 HashSet 的底层是 HashMap,所以其初始容量为 16,负载因子为 0.75。同样也可以根据其构造方法推测出该值。

1
2
3
4
java复制代码 public HashSet(Collection<? extends E> c) {
     map = new HashMap<>(Math.max((int) (c.size()/.75f) + 1, 16));
    ...
 }
  1. HashSet 能保证保证元素的顺序吗?

HashSet 通过 hash 表存储元素,因此一般认为 HashSet 插入的元素是无序的。但是也可以通过指定构造方法(该方法的权限为 default)来保持插入数据的顺序,此时其底层为 LinkedHashMap。而这也就是为什么 LinkedHashSet 能维持元素有序的原因。

1
2
3
java复制代码 HashSet(int initialCapacity, float loadFactor, boolean dummy) {
     map = new LinkedHashMap<>(initialCapacity, loadFactor);
 }

LinkedHashSet

LinkedHashSet 的底层为 LinkedHashMap,其链表保证了元素的插入与遍历顺序,哈希表保证了元素的唯一性。LinkedHashSet 的初始容量同样为 16,负载因子为 0.75。

1
2
3
csharp复制代码 public LinkedHashSet() {
  super(16, .75f, true);
 }

TreeSet

TreeSet 通过二叉树存储数据,其底层是 TreeMap。二叉树结构保证了元素的顺序,默认为自然排序,可以通过实现 Compareable 接口,并重写里面的compareTo() 方法来进行自定义排序。

1
2
3
java复制代码 public TreeSet() {
     this(new TreeMap<E,Object>());
 }

HashSet 与 LinkedHashSet 的区别

HashSet 不能保证元素的顺序,而 LinkedHashSet 可以保证元素的顺序。

TreeSet 与 LinkedHashSet 的区别

LinkedHashSet 保证的是元素的插入与遍历顺序一致,而 TreeSet 是插入时根据规则排好序,其有序性的意义是不同的。

总结

HashSet、LinkedHashSet 和 TreeSet 都是线程不安全的,在多线程环境中,可以使用Collections.synchronizedSet(new HashSet<>())或者new CopyOnWriteArraySet<>()。

Java集合-Set应用场景.png

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

学习设计模式——模板模式

发表于 2021-11-02

今天,我们来学习行为型设计模式中的第二个:模板模式。

模板模式主要是用来解决复用和扩展 两个问题。

话不多说,开始今天的学习。

介绍

模板模式(Template Method Design Pattern): 模板方法模式在一个方法中定义一个算法骨架,并将某些步骤推迟到子类中实现。模板方法模式可以让子类在不改变算法整体结构的情况下,重新定义算法中的某些步骤。

这里的算法骨架就是“模板”,通用的代码实现很简单。

原理与实现

AbstracTemplate.java

1
2
3
4
5
6
7
8
9
10
11
java复制代码public abstract class AbstracTemplate {

public final void templateMethod() {
methodA();
methodB();
}

protected abstract void methodA();
protected abstract void methodB();

}

ConcreteClassA.java、ConcreteClassB.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
java复制代码public class ConcreteClassA extends AbstracTemplate {
@Override
protected void methodA() {
System.out.println("执行了 ConcreteClassA 的方法 methodA");
}

@Override
protected void methodB() {
System.out.println("执行了 ConcreateClassA 的方法 methodB");
}
}


public class ConcreteClassB extends AbstracTemplate {

@Override
protected void methodA() {
System.out.println("执行了 ConcreteClassB 的方法 methodA");
}

@Override
protected void methodB() {
System.out.println("执行了 ConcreteClassB 的方法 methodB");
}
}

Test.java

1
2
3
4
5
6
7
8
9
10
11
12
13
java复制代码public class Test {

public static void main(String[] args) {

AbstracTemplate classA = new ConcreteClassA();
classA.templateMethod();
System.out.println("===============================");
AbstracTemplate classB = new ConcreteClassB();
classB.templateMethod();

}

}

结果:

image.png

作用一:复用

模板模式的作用之一:复用

模板模式把一个算法中不变的流程抽象到父类的模板方法 templateMethod()中,将可变的部分 methodA()、methodB() 留给子类 ConcreteClassA 和 ConcreteClassB 来实现。所有的子类都可以复用父类中模板方法定义的流程代码。

Java AbstractList

在 Java AbstractList 类中,addAll() 函数可以看作模板方法,add() 是子类需要重写的方法,尽管没有声明为 abstract 的,但函数实现直接抛出了 UnsupportedOperationException 异常。前提是,如果子类不重写是不能使用的。

1
2
3
4
5
6
7
8
9
10
11
12
13
java复制代码public boolean addAll(int index, Collection<? extends E> c) {
rangeCheckForAdd(index);
boolean modified = false;
for (E e : c) {
add(index++, e);
modified = true;
}
return modified;
}

public void add(int index, E element) {
throw new UnsupportedOperationException();
}

还有在Java IO 类库汇总,有很多的类的设计也用到了模板模式,比如 InputStream、OutputStream、Reader、Writer。

作用二:扩展

模板模式的作用之二:扩展。

基于这个作用,模板模式常用在框架的开发中,让框架用户可以在不修改框架源码的情况下,定制化框架的功能。
还记得当初学 Java Web 开发时,必然会学到 Servlet。使用 Servlet 来开发 Web 项目时,我们需要定义一个继承 HttpServlet 的类,并且重写其中的 doGet() 或 doPost() 方法,来分别处理 get 和 post 请求。具体代码示例如下所示:

1
2
3
4
5
6
7
8
9
10
11
java复制代码public class TestServlet extends HttpServlet {
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
super.doGet(req, resp);
}

@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
resp.getWriter().write("Hello World!");
}
}

当请求到这个 Servlet 的时候会执行 它的 service() 方法。service() 方法定义在父类 HttpServlet 中,它会调用 doGet() 或 doPost() 方法,然后输出数据 “Hello world” 到网页。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
java复制代码protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
String method = req.getMethod();
long lastModified;
if (method.equals("GET")) {
lastModified = this.getLastModified(req);
if (lastModified == -1L) {
this.doGet(req, resp);
} else {
long ifModifiedSince;
try {
ifModifiedSince = req.getDateHeader("If-Modified-Since");
} catch (IllegalArgumentException var9) {
ifModifiedSince = -1L;
}

if (ifModifiedSince < lastModified / 1000L * 1000L) {
this.maybeSetLastModified(resp, lastModified);
this.doGet(req, resp);
} else {
resp.setStatus(304);
}
}
} else if (method.equals("HEAD")) {
lastModified = this.getLastModified(req);
this.maybeSetLastModified(resp, lastModified);
this.doHead(req, resp);
} else if (method.equals("POST")) {
this.doPost(req, resp);
} else if (method.equals("PUT")) {
this.doPut(req, resp);
} else if (method.equals("DELETE")) {
this.doDelete(req, resp);
} else if (method.equals("OPTIONS")) {
this.doOptions(req, resp);
} else if (method.equals("TRACE")) {
this.doTrace(req, resp);
} else {
String errMsg = lStrings.getString("http.method_not_implemented");
Object[] errArgs = new Object[]{method};
errMsg = MessageFormat.format(errMsg, errArgs);
resp.sendError(501, errMsg);
}

}

可以看到,service() 方法就是一个模板方法,它实现了整个 HTTP 请求的执行流程,doGet()、doPost() 是模板中可以由子类来定制的部分。实际上,这就相当于 Servlet 框架提供了一个扩展点(doGet()、doPost() 方法),让框架用户在不用修改 Servlet 框架源码的情况下,将业务代码通过扩展点镶嵌到框架中执行。

总结

模板模式的优点:

  • 具体细节步骤实现定义在子类中,子类定义详细处理算法是不会改变算法整体结构。
  • 代码复用的基本技术,在数据库设计中尤为重要。
  • 存在一种反向的控制结构,通过一个父类调用其子类的操作,通过子类对父类进行扩展增加新的行为,符合“开闭原则”。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

【译】 怎样使用GraphQL

发表于 2021-11-02

原文地址:www.howtographql.com/

1.介绍

GraphQL是一个新的API标准,具有高效,强大,灵活的特性,旨在替代REST模式。GraphQL由Facebook公司开发并开源,目前由来自世界各地的公司和开发者共同维护。

API已经在软件基础设施中变得无处不在。总之, 一个API定义了客户端如何从服务器加载数据。

作为GraphQL的核心点,它支持声明式数据获取,客户端可以从接口中,精确指定需要的数据。不同于多个后端接口返回固定的数据结构,一个GraphQL服务仅仅暴露一个接口,并精确的响应客户端想要的数据。

GraphQL - 一种API查询语言

今天,大多数应用都需要从服务器获取存在数据库中的数据,API需要提供获取数据的接口,以满足应用的需要。

GraphQL往往被误认为是一个数据库技术。这是不正确的,GraphQL是一种API查询语言,而不是数据库的查询语言。所以说,它是数据库无关的,可以用在任何使用API的场景中。

一个更有效的REST模式替代者

REST模式是目前流行的从服务端获取数据的方式。 当REST模式提出时,客户端程序都相对简单,并且发展速度也远落后于目前。REST模式也能很好的适用于诸多应用。然而,过去几年里,API领域有了根本性的变化。特别是,以下三个因素,对API的设计方式发出了挑战:

1.日益增多的移动端使用,需要数据加载过程更有效率

在移动端使用量变多的场景下,低功耗的设备和较差的网络环境是Facebook开发GraphQL最初的原因。GraphQL通过最小化网络传输时所需要的数据量,改善了这种环境中的应用使用情况。

2.各种不同的前端框架和平台

面对各种各样的前端框架和不同终端里的应用,开发并维护一套通用的API,用来满足左右需求,变得越来越难。但是通过GraphQL,客户端可以精确获取所需数据。

3.快速开发&快速添加特性

持续部署已成为许多公司标准,快速迭代和频繁的产品更新也变的不可或缺。如果使用REST模式API,服务端往往需要修改对外暴露的数据接口,来满足客户端在需求和设计上的变动。这阻碍了快速开发和产品迭代。

历史,环境和采用

GraphQL并不是React开发者专用的

Facebook在2012年开始,在他们的原生移动应用中使用GraphQL。但有趣的是,GraphQL目前主要被采用在Web相关技术环境里,反而在移动应用中获得的影响力很小。

Facebook的第一次公开谈论GraphQL是在React.js Conf 2015,并在不久之后宣布了他们的开源计划。因为Facebook总是在结合React谈论GraphQL,所以对于非React开发人员来说,需要花费一段时间才能明白GraphQL绝不是一个限于使用React的技术。

youtube截图
Dan Schafer & Jing Chen在React.JS Conf 2015公开介绍GraphQL。

一个迅速发展的社区

事实上,GraphQL可以在所有客户端与API通信的地方使用。有趣的是,包括Netflix和Coursera的一些公司也曾试探过相似的方案,使API交互更有效率。Coursera设想了一种类似的技术,让客户端指定其数据要求,Netflix甚至已经开源了他们的Falcor解决方案。但在GraphQL开源后,Coursera取消了计划,并搭上了GraphQL的车。

今天,GraphQL被许多不同的公司(如GitHub,Twitter,Yelp和Shopify)用于生产。

[cdn.hicous.com/pic/bism5.p…]

尽管如此年轻的技术,GraphQL已经被广泛采用。点击了解还有谁使用GraphQL。

列出几个专注于GraphQL的组织,有GraphQL Summit、GraphQL Europe、GraphQL Radio、GraphQL Weekly。

2.GraphQL是更好的REST

在过去十年中,REST已经成为设计Web API的标准。它提供了一些伟大的想法,如无状态服务和结构化资源访问。然而,基于REST的API太死板,不能满足客户端快速变化的调用需求。

GraphQL的出现是为了应对更多的灵活性和效率的需要!它解决了与REST API打交道时,开发人员遇到的许多REST的缺点和低效性。

为了说明REST和GraphQL在从API获取数据时的主要区别,我们来考虑一个简单的示例场景:在博客应用中,应用需要展示指定用户的帖子标题。同一页面里还会显示该用户的最新3个关注者的名字。 REST和GraphQL是如何解决这个业务的呢?

REST vs GraphQL之数据获取

使用REST API时,通常可以调用多个接口来获取数据。针对上述场景,首先可以调用/user/<id>接口来获取用户数据。紧接着,调用/users/<id>/posts返回该用户的所有帖子。最后,第三个接口可以是/users/<id>/followers,返回该用户的关注者列表。

REST示意图
使用REST,必须向三个不同的接口发出三个请求以获取所需的数据。同时你也拿到了额外的数据,因为接口会返回你不需要的其他信息。

另一方面,在GraphQL中,你只需向拥有数据的GraphQL服务端发送一条查询语句。服务端就会响应并返回带有查询结果的JSON对象。

GraphQL示意图
使用GraphQL,客户端可以精确地指定查询中需要的数据。 请注意,服务器响应的结构会精确地匹配查询中定义的结构。

不再获取多余或不足的数据

REST最常见的缺点之一是不能精准的获取数据。这是因为客户端获取数据的唯一方法是调用接口,而接口总是返回固定数据结构。如果想设计出完美的API,刚好能为客户端提供精确的数据,是非常困难的。

“用图形的方式思考,而不是端点”。GraphQL共同发明人Lee Byron的Lessons From 4 Years of GraphQL。

过多获取:下载了多余的数据

过多获取,意味着客户端拿到了超出真正需要额外的数据。想象一下,在一个页面中,只需要展示一组用户名称列表。但在REST API中,通常会调用/users接口,来获取包含用户数据在内的JSON数组。然而,这个返回结果中可能也包含了用户的其他信息,例如。用户的生日,地址等。而我们仅仅是想展示用户名。

获取不足和n+1问题

另外一个问题是获取的数据不足,然后多发一次请求的问题。获取不足通常是指接口不能返回所需信息。客户端将不得不再次发起请求来获取数据。可能的场景是,客户端需要先得到列表数据,然后为列表中的每个元素再次发起请求来获取元素相关数据。

例如,假设刚才的应用中,需要展示出每个用户的最后三个关注者。 API也提供了接口/users/<user-id>/followers。为了能够展示所需的信息,应用需要向/users接口发起请求,然后针对每个用户再去调用/users/<user-id>/followers接口。

前端的快速产品迭代

使用REST API的常见模式是,根据应用页面的需要来设计接口。这样做很便捷,只要简单地在客户端调用对应接口,就能获取页面中所有需要的数据。

但是这种模式最主要的缺点是,不允许在前端快速迭代。随着UI层面的每一个改动,接口数据是否匹配,这一点就暴露了很高的风险。因此,后端就需要进行调整,以解决新的数据需求。这会导致生产力下降,特别是降低了将用户建议快速整合进产品的能力。

如果使用GraphQL,这个问题迎刃而解。得益于GraphQL的灵活性,客户端的更改不需要服务端有任何额外的工作。客户端可以指定其确切的数据要求,因此前端的设计和数据方面的需求变化时,后端工程师不需要进行调整。

后端深度分析

GraphQL可以对请求的数据进行细粒度的分析。由于每个客户端都准确地指定了它感兴趣的数据,因此,我们可以深入了解关于数据的使用方式。有了这些信息,就可以用来优化API,和去除不需要的字段。

使用GraphQL,还可以对服务器处理请求时,进行低级别的性能监控。 GraphQL使用resolver函数的概念来收集客户端请求的数据。通过resolver提供的测量和性能数据,可以为解决系统中的瓶颈问题提供重要的见解。

Schema和类型系统的好处

GraphQL使用丰富的类型系统来定义API的功能。 API中暴露的所有类型都使用GraphQL模式定义语言(SDL)在Schema中记录下来。Schema是客户端和服务端之间的协定,以定义客户端如何访问数据。

一旦定义了Schema,在前端和后端工作的团队可以在不进行多余沟通的情况下开展工作,因为他们都知道经由网络传输的数据的确定结构。

前端工程师可以通过mock所需的数据结构轻松的进行测试。一旦服务端准备就绪,客户端可以快速切换到真正的服务,接着从真正的API加载数据。

3.核心概念

在本章中,你将了解GraphQL的一些基本语言结构。 内容包括,初次认识,定义类型,查询和mutation的语法。我们还为你准备了一个基于graphql-up的沙箱环境,你可以使用它来实验学到的内容。

视图定义语言(SDL)

GraphQL有自己的类型系统,用于定义API的Schema。编写Schema的语法称为SDL。

以下是使用SDL定义简单类型Person的示例:

1
2
3
4
typescript复制代码type Person {
name: String!
age: Int!
}

这个Person类型有两个字段,name和age,是String和Int类型的,紧跟着的 ! 表示该字段是必需的。

可以在类型之间相互使用。在博客应用的例子中,一个Person可以与一个Post相关联:

1
2
3
4
typescript复制代码type Post {
title: String!
author: Person!
}

同时,另一方面,Person类型中添加相应类型。

1
2
3
4
5
yaml复制代码type Person {
name: String!
age: Int!
posts: [Post!]!
}

请注意,我们刚刚创建了Person和Post之间的一对多关系,因为Person上的posts字段实际上是一个帖子数组。

使用查询语句获取数据

使用REST API时,会从指定接口加载数据。每个接口都明确定义了返回的数据结构。这意味着客户端需要的数据,已经在URL中制定好了。

GraphQL中采用的方式截然不同。GraphQL的API通常只暴露一个接口,而不是返回固定数据结构的多个接口。 这是因为返回的数据结构不是一成不变的,而是灵活的,让客户端来决定真正需要的是什么数据。

这意味着客户端需要向服务端发送更多信息来告知所需求的数据 - 这个额外信息称为查询。

基本查询

我们来看看客户端发送给服务端的简单查询示例:

1
2
3
4
5
markdown复制代码{
allPersons {
name
}
}

在这查询中,allPersons字段称为查询的根字段。根字段之后的所有内容称为查询的payload。此查询中,指定的唯一payload字段是name。

此查询将返回当前存储在数据库中的所有的人员列表。返回的示例如下:

1
2
3
4
5
6
7
json复制代码{
"allPersons": [
{ "name": "Johnny" },
{ "name": "Sarah" },
{ "name": "Alice" }
]
}

请注意,每个Person在响应结果中只有name一个属性,服务端并没有返回age属性。 这正是因为name是在查询中指定的唯一字段。

如果客户端也想要获取年龄属性,那么要做的,仅仅是调整一下查询语句,在查询的payload中加入新的字段:

1
2
3
4
5
6
markdown复制代码{
allPersons {
name
age
}
}

GraphQL的一个重要优势是,允许直接通过嵌套的方式查询信息。例如,如果想获取一个Person写的所有posts,可以简单地按照类型的结构来获取数据:

1
2
3
4
5
6
7
8
9
markdown复制代码{
allPersons {
name
age
posts {
title
}
}
}

查询时带上参数

在GraphQL中,每个在视图中定义的字段都可以拥有参数(零或多个)。例如,allPersons字段可以包含last参数,用来返回特定数量的Persons。相应的查询如下:

1
2
3
4
5
javascript复制代码{
allPersons(last: 2) {
name
}
}

通过Mutation操作数据

在从服务端获取到数据之后,主流的应用程序总是要更改存储在后端的数据。使用GraphQL,这些更改是使用Mutation来完成的。 Mutation一般有这三种:

  • 创建新数据
  • 更新现有数据
  • 删除现有数据

Mutation遵循与查询相同的语法结构,但是它们始终需要从Mutation关键字开始。 以下是我们如何创建一个新的Person的例子:

1
2
3
4
5
6
typescript复制代码mutation {
createPerson(name: "Bob", age: 36) {
name
age
}
}

请注意,与之前写的查询类似,mutation也有一个根字段,上面的例子中,这个字段是createPerson。 我们之前已经讲到过关于查询参数的内容。例子中,createPerson接受name和age两个参数。

像查询一样,我们还可以为mutation指定一个payload,我们可以在其中请求新建的Person对象的其他属性。 在我们的例子中,我们指定的是name和age,虽然我们的例子并不是很有意义,因为我们获取的是我们已知的数据。但我们已经清晰的了解到,当请求是mutation时,也能够指定查询信息,这体现了mutation的强大之处,允许在一次请求来回中,从服务器获取新数据!

上面的mutation的响应数据是这样的:

1
2
3
4
json复制代码"createPerson": {
"name": "Bob",
"age": "36",
}

我们总是能了解到,当新增数据时,GraphQL会在服务端生成一个唯一的ID类型。扩展一下我们之前定义的Person类型,我们可以添加一个id字段,如下所示:

1
2
3
4
5
typescript复制代码type Person {
id: ID!
name: String!
age: Int!
}

现在,当创建一个新的Person时,可以直接在mutation的payload中查询id,这是事先在客户端不知道的信息:

1
2
3
4
5
typescript复制代码mutation {
createPerson(name: "Alice", age: 36) {
id
}
}

使用Subscription进行实时更新

今天,许多应用的另一个重要需求是与服务端进行实时连接,以便实时响应重要事件。针对这种场景,GraphQL提供了订阅的概念。

当客户端订阅了事件,它将与服务器建立一个稳定连接并保持住这个链接。任何时刻,当有事件触发时,服务端都会把相关信息推送到客户端。不同于查询和mutation,订阅代表的是将数据推送出去的流的模式,而不是经典的“请求 - 响应”的模式。

订阅使用的语法和查询和mutation相同。我们用来订阅Person类型上的事件,如下所示:

1
2
3
4
5
6
typescript复制代码subscription {
newPerson {
name
age
}
}

在客户端将此订阅发送到服务端之后,会建立起一个链接。之后,每当mutation被执,用来创建新的Person时,服务端就会把有关此人的信息发送给客户端:

1
2
3
4
5
6
json复制代码{
"newPerson": {
"name": "Jane",
"age": 23
}
}

定义视图(Schema)

现在,你已经对查询,mutation和订阅有一个基本的了解,让我们将这些放在一起,学习如何编写一个视图,让你自己执行目前为止所有的示例。

视图是使用GraphQL API时最重要的概念之一。它指定了API的功能,并定义了客户端如何请求数据。它通常被视为服务器和客户端之间的协议。

通常,视图只是GraphQL类型的集合。 但是,在为API编写视图时,有一些特殊的根类型:

1
2
3
typescript复制代码type Query { ... }
type Mutation { ... }
type Subscription { ... }

查询,mutation和订阅是客户端发送请求的入口点。要启用我们前面看到的allPersons查询,查询类型必须如下定义:

1
2
3
typescript复制代码type Query {
allPersons: [Person!]!
}

allPersons称为API的根字段。再考虑到,我们将last参数添加到allPersons的示例,我们必须如下定义Query:

1
2
3
typescript复制代码type Query {
allPersons(last: Int): [Person!]!
}

类似地,对于createPersonmutation,我们必须向Mutation类型添加一个根字段:

1
2
3
tsx复制代码type Mutation {
createPerson(name: String!, age: String!): Person!
}

请注意,根字段有两个参数,Person的name和age。

最后,对于订阅,我们必须添加newPerson根字段:

1
2
3
tsx复制代码type Subscription {
newPerson: Person!
}

将所有整合在一起,这是你在本章中看到的所有查询和mutation的完整模式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
tsx复制代码type Query {
allPersons(last: Int): [Person!]!
}

type Mutation {
createPerson(name: String!, age: String!): Person!
}

type Subscription {
newPerson: Person!
}

type Person {
name: String!
age: Int!
posts: [Post!]!
}

type Post {
title: String!
author: Person!
}

4.大局(架构)

GraphQL仅仅作为规范发布。 这意味着GraphQL实际上,不会是一个详细描述GraphQL服务端行为的完整文档。

如果你想使用GraphQL,你必须自己去搭建GraphQL服务。你可以选择任何编程语言来实现(例如这些可参考的实现方式)或通过选用像Graphcool这样的服务,它提供了一个功能强大的GraphQL API。

用例

在本节中,我们将介绍使用GraphQL服务的3种不同类型的架构:

1.连接数据库的GraphQL服务

2.GraphQL服务作为轻量的一层,在第三方接口,或一些现有系统之上。通过一个GraphQL API集成起来。

3.混合以上两种方式,通过相同的GraphQL API,同时可以访问数据库和第三方接口或现有旧系统。

上述这三种架构,代表了GraphQL的主要用例,并展现出了使用时的灵活性。

1.连接数据库的GraphQL服务

这种架构是greenfield项目中最常见的。在设置中,您有一个(Web)服务器实现GraphQL规范。 当查询请求到GraphQL服务器时,服务器读取查询的payload并从数据库中读取所需的信息。 这被称为查询的解析。然后,它按照官方规范中的描述构造响应对象,并将其返回给客户端。

需要注意的是,GraphQL实际上是传输层无关的。这意味着它可以和任意的网络协议一起使用。 因此,GraphQL服务器可能是基于TCP,Websockets或者其他网络协议的。

GraphQL也不关心是什么数据库以及用于存储数据的格式。你可以使用像AWS Aurora这样的SQL数据库或者像MongoDB这样的NoSQL数据库。

图片描述

一个标准的连接到单个数据库的,GraphQL服务器的greenfield架构。

2.集成现有系统的GraphQL层

GraphQL的另一个主要用例是将多个现有系统集成在一个统一的GraphQL API后面。这对有着遗留基础设施和许多不同API的公司尤其引人注目,这些API已经维护了多年,现在却造成了很重的维护负担。这些传统系统的一个主要问题是,人们很难做出需要访问多个旧系统的创新产品。

在这种情况下,GraphQL可以用于统一这些系统,并将这些复杂的事情隐藏在一个友好的GraphQL API之后。这样,就可以去开发新的客户端应用,只需与GraphQL服务器通信即可获取所需的数据。然后,GraphQL服务器负责从现有系统中提取数据,并以GraphQL格式进行响应。

就像在以前的架构中,GraphQL服务器不关心正在使用的数据库的类型,这一次它不关心用来获取查询结果所需的数据源。

图片描述

GraphQL允许你隐藏现有系统的复杂性,在统一的GraphQL接口后面,集成微服务,传统基础架构和第三方API。

3.连接数据库与现有系统集成的混合方式

最后,可以组合这两种方法,构建一个连接数据库,同时又与旧有或第三方系统集成的GraphQL服务器。

当服务器接收到查询时,将解析它,并从连接的数据库或某些集成的API中检索出所需的数据。

图片描述

这两种方法也可以组合起来,GraphQL服务器可以从单个数据库或者现有系统获取数据,从而达到灵活性,并将所有数据管理的复杂性交给到服务器。

Resolver方法

但是,如何通过GraphQL获得这种灵活性?它是如何适配好这些差别明显的不同类型的用例?

如上一章所了解,GraphQL查询(或mutation)的payload由一组字段组成。在GraphQL服务器实现中,这些字段中的每一个实际上都对应于一个称为resolver的函数。resolver的作用就是获取对应字段的数据。

当服务端收到查询时,会调用查询中payload里字段对应的函数,检索出每个字段的数据。当所有resolver都返回了结果,服务端将按照查询里描述的数据格式打包好数据,将结果返回给客户端。

图片描述

查询中的每个字段对应一个resolver函数。当需要查询指定数据时,GraphQL将调用所有需要的resolver方法。

GraphQL客户端库

GraphQL对于前端开发人员来说特别好,因为它完全消除了REST API的许多不便和缺点,例如过度和欠缺的数据加载。复杂性被推到服务端,强大的服务器可以处理繁重的计算工作。客户端不必知道获取的数据实际来自哪里,只需要使用单一,一致且灵活的API就可以。

让我们考虑使用GraphQL引入的重大变化,从一个相当迫切的数据获取方法转变为一个纯粹的声明式方法。从REST API获取数据时,大多数应用必须执行以下步骤:

1.构造和发送HTTP请求(例如,在Javascript中fetch)
2.接收并解析服务器响应
3.在本地存储数据(简单地存在内存或持久存储)
4.在UI中显示数据

使用理想化的声明式数据获取方法,客户端不应该做以下两个步骤:

1.描述数据要求
2.在UI中显示数据

所有底层网络任务以及数据存储都应该被抽象出来,数据依赖关系的声明才应该是主要部分。

这恰恰是GraphQL的客户端库,如Relay或Apollo能做到的。它们提供了重要部分的抽象,让你不必去重复执行基础方法。从而让你能够专注于应用本身。

进阶 - 1.客户端

在前端使用GraphQL API,对于抽象和实现基础功能,是一个好机会。让我们考虑你在应用中可能想要的一些“基础”功能:

  • 直接发送查询和mutation而不用构建HTTP请求
  • 视图层集成
  • 缓存
  • 基于schema去校验和优化查询

当然,没有什么可以阻止你仅使用HTTP来获取你的数据,然后自己逐个处理,直到正确的信息最终显示在UI上。 但是GraphQL提供了避免大量手动劳动的能力,让你专注于应用的核心部分! 在下文中,我们将更详细地讨论这些能力。

目前有两个主要的GraphQL客户端库。 第一个是Apollo客户端,这是一个社区支持的项目,旨在为所有主要开发平台构建强大而灵活的GraphQL客户端。 第二个被称为Relay,它是Facebook官方的GraphQL客户端,它大大优化了性能,但只能在web上可用。

直接发送查询和mutation

GraphQL的主要优点是它允许您以声明的方式获取和更新数据。换句话说,我们在API抽象层面更进一步,不必再自己处理低级网络任务。

以前你使用纯HTTP(如JavaScript中的fetch或iOS中的NSURLSession)从API加载数据。使用GraphQL后,所有操作都将写入一个查询,声明了数据需求后,让系统负责发送请求并处理响应。这正是GraphQL客户端要做的。

视图层集成和UI更新

在服务端的响应,被GraphQL客户端接收到之后,数据需要最终展现在UI中。根据开发的平台和选用的框架不同,UI更新也将会有不同的方式。

以React为例,GraphQL客户端使用高阶组件的理念来获取所需的数据,并使其在组件的porps中可用。一般来说,GraphQL的声明式特性与函数式反应型编程(FRP)结合的很好。两者可以形成强大的组合,其中视图只是声明其数据依赖,UI则与选择的FRP层连接。

缓存查询结果:概念和策略

在大多数应用中,你需要缓存之前从服务器获取的数据。这对于提供流畅的用户体验和维护好用户数据至关重要。

通常,当缓存数据时,直觉是将远程获取的信息放入本地存储中,以便稍后检索。使用GraphQL,直觉上的办法就是将GraphQL查询的结果简单地放入存储,并且只要再次执行完全相同的查询,就返回先前存储的数据。事实证明,这种方式对于大多数应用来说是非常低效的。

更有效的方式是提前规范化数据。这意味着(可能是)嵌套的查询结果会变得平坦,并且存储的是,可以使用全局唯一ID来查询的单个记录内容。如果想了解更多关于这一点的信息,Apollo博客对这个内容做了很好的介绍。

构建时的验证和优化

由于视图(schema)包含有关客户端可以使用GraphQL API的所有信息,因此,客户端在构建时验证查询就变得很方便。

当构建环境可以访问schema时,它可以基本解析位于项目中的所有GraphQL代码,并将其与schema中的信息进行比较。这样就可以捕获打字错误和其他错误,避免一些严重后果。

结合视图层和数据层

GraphQL的一个强大的理念是,它允许你并行地处理UI代码和数据需求。界面和数据的紧密结合,大大提高了开发人员的体验。不用再去考虑,如何在UI中恰当的填充数据。

结合带来的优势大小,取决于您正在开发的平台。例如在Javascript应用中,可以将数据依赖和UI代码放在同一个文件中。在Xcode中,Assistant Editor可用于同时处理视图控制器和graphql代码。

进阶 - 2.服务端

GraphQL通常被认为是前端的API技术,因为它使客户端能够以更好的方式获取数据。 但是既然是API,当然是在服务器端实现的。 因为GraphQL使服务器开发人员能够专注于描述数据,而不是实现和优化特定的接口,所以在服务器上也有很多好处。

GraphQL执行

GraphQL通过定义Schema和查询语言,来从Schema中检索数据,但却不仅仅是这一种方式。更是一种实际的执行算法,用于将这些查询转换为结果。 该算法的核心非常简单:查询逐字段遍历,为每个字段执行“解析器”。 让假设我们有以下模式:

1
2
3
4
5
6
7
8
9
10
11
12
rust复制代码type Query {
author(id: ID!): [Author]
}

type Author {
posts: [Post]
}

type Post {
title: String
content: String
}

下面是我们使用该Schema发送到服务器的查询:

1
2
3
4
5
6
7
8
css复制代码query {
author(id: "abc") {
posts {
title
content
}
}
}

首先要关注的是查询中的每个字段都可以与一个类型相对应:

1
2
3
4
5
6
7
8
css复制代码query: Query {
author(id: "abc"): Author {
posts: [Post] {
title: String
content: String
}
}
}

现在,我们可以轻松地找到并运行服务器中每个字段对应的解析器。从查询类型开始执行,并以外层为先。这意味着我们先运行Query.author的解析器。然后,我们将该解析器的结果传递给它的子解析器,Author.posts的解析器。在下一级,结果是一个列表,在这种情况下,算法会依次在每个元素上执行一次。所以最终执行工作如下:

1
2
3
4
5
rust复制代码Query.author(root, { id: 'abc' }, context) -> author
Author.posts(author, null, context) -> posts
for each post in posts
Post.title(post, null, context) -> title
Post.content(post, null, context) -> content

最终,执行算法将所有结果数据正确的放在定义好的结构中,并返回。
需要注意的是,大多数GraphQL服务器实现将提供“默认解析器” - 因此您不必为每个单个字段指定解析器函数。例如,在GraphQL.js中,当解析器的父对象包含具有正确名称的字段时,不需要指定解析器。
在Apollo博客上的“GraphQL Explained“文章中,可更深入的了解GraphQL执行情况。

批量解析

你可能会注意到上述执行策略的一件事是,它有点幼稚。例如,如果你有从后端API或数据库提取的解析器,则在执行一个查询期间可能会多次调用该后端。让我们假设,我们想获取几个帖子的作者,就像这样:

1
2
3
4
5
6
7
8
9
markdown复制代码query {
posts {
title
author {
name
avatar
}
}
}

如果这些是博客上的帖子,很可能很多帖子将有相同的作者。所以如果我们需要一个API调用来获取每个作者对象,我们可能会意外地为同一个对象发出多个请求。例如:

1
2
3
4
5
6
scss复制代码fetch('/authors/1')
fetch('/authors/2')
fetch('/authors/1')
fetch('/authors/2')
fetch('/authors/1')
fetch('/authors/2')

我们如何解决这个问题?让我们聪明一点。我们可以将fetch函数封装在一个工具函数中,该实函数将等待所有的解析器运行后,再确保只fetch每个元素一次:

1
2
3
4
5
6
7
8
9
10
11
scss复制代码authorLoader = new AuthorLoader()

// Queue up a bunch of fetches
authorLoader.load(1);
authorLoader.load(2);
authorLoader.load(1);
authorLoader.load(2);

// Then, the loader only does the minimal amount of work
fetch('/authors/1');
fetch('/authors/2');

我们能做得更好吗?当然,如果我们的API支持批量请求,我们只能对后端执行一次提取操作,如下所示:

1
scss复制代码fetch('/authors?ids=1,2')

这也可以封装在上面的工具函数中。
在JavaScript中,可以使用 DataLoader 的工具实现上述策略,其他语言也有类似的工具。

进阶 - 3.工具和生态系统

您可能已经意识到,GraphQL生态系统正在以惊人的速度增长。之所以如此,原因之一是GraphQL使我们很容易开发出优秀的工具。在本节中,我们将看到为什么会这样,以及已经存在生态系统中有的一些惊人的工具。

如果您熟悉GraphQL基础知识,您可能知道GraphQL的类型系统如何帮助我们快速定义API的最外层。它允许开发人员清楚地定义API的功能,还可以根据Schema去验证传入的查询内容。

GraphQL神奇的是,这些功能不仅仅是服务器所知道的。GraphQL允许客户端向服务器询问其Schema的信息。 GraphQL调用这个introspection。

introspection

模式的设计者已经知道模式是什么样的,但客户端如何得知,可以通过GraphQL API访问哪些数据?我们可以通过查询__schema元字段来询问GraphQL,该元字段始终在符合规范的查询根类型上可用。

1
2
3
4
5
6
7
erlang复制代码query {
__schema {
types {
name
}
}
}

以此Schema为例:

1
2
3
4
5
6
7
8
9
10
11
bash复制代码type Query {
author(id: ID!): Author
}

type Author {
posts: [Post!]!
}

type Post {
title: String!
}

如果我们发送上述的introspection查询,我们会得到以下结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
JSON复制代码{
"data": {
"__schema": {
"types": [
{
"name": "Query"
},
{
"name": "Author"
},
{
"name": "Post"
},
{
"name": "ID"
},
{
"name": "String"
},
{
"name": "__Schema"
},
{
"name": "__Type"
},
{
"name": "__TypeKind"
},
{
"name": "__Field"
},
{
"name": "__InputValue"
},
{
"name": "__EnumValue"
},
{
"name": "__Directive"
},
{
"name": "__DirectiveLocation"
}
]
}
}
}

我们可以看到,我们查询了Schema上的所有类型。我们得到我们定义的对象类型和预定义类型。我们甚至可以再查询introspection类型!

对于introspection类型,不仅仅能拿到名字。看下面的例子:

1
2
3
4
5
6
javascript复制代码{
__type(name: "Author") {
name
description
}
}

在这个例子中,我们使用__type元字段来查询一个类型,我们得到它的名字和描述。此查询的结果:

1
2
3
4
5
6
7
8
json复制代码{
"data": {
"__type": {
"name": "Author",
"description": "The author of a post.",
}
}
}

如你所见,introspection是GraphQL非常强大的功能,我们只是了解了一点皮毛。在规范中,会详细介绍introspection模式中哪些字段和类型是可用的。

GraphQL生态系统中的许多工具都是通过introspection系统提供了神奇的功能。例如文档浏览器,自动补全,代码生成,和其他一切可能!在构建和使用GraphQL API时,当你重度使用introspection ,最有用的工具是GraphiQL。

GraphiQL

GraphiQL是用于编写,验证和测试GraphQL查询的运行在浏览器中的IDE。它有用于GraphQL查询的编辑器,配有自动补全和验证以及文档浏览,可以快速呈现出模式的结构(由introspection提供)。

这是一个非常强大的开发工具。它允许您在GraphQL服务器上调试和尝试查询,而无需通过curl去写GraphQL查询。

试一试吧! graphql.org/swapi-grap.…

进阶 - 4.安全

GraphQL为客户端提供强大的能力。但是拥有强大的能力时,也会带来更大的风险。

由于客户端有可能使用非常复杂的查询,因此我们的服务器必须能够妥善处理。这些查询可能是来自恶意客户端的滥用查询,或者可能只是合法客户端使用的非常大的查询。在这两种情况下,客户端可能会将您的GraphQL服务器崩溃。

我们将在本章中介绍一些减轻这些风险的策略。我们将以最简单到最复杂的顺序来说明,并看看这些方式的利弊。

超时策略

第一个策略,也是最简单的策略是使用简单的超时来防范大型查询。这不需要服务器了解有关传入查询的任何内容。服务器需要知道的仅仅是允许查询的最长时间。

例如,配置了5秒超时的服务器将停止执行超过5秒钟执行的任何查询。

超时的优势

  • 操作简单
  • 大多数策略都会使用超时作为最终保护

超时的缺点

  • 即使有超时策略,也可能会造成不好的后果
  • 有时难以实施。在一段时间之后切断连接可能会导致奇怪的行为。

最大查询深度

正如我们之前所述,使用GraphQL的客户可以随意写出任意的复杂查询。由于GraphQL模式通常是嵌套的,这意味着客户端可以写出如下所示的查询:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
javascript复制代码query IAmEvil {
author(id: "abc") {
posts {
author {
posts {
author {
posts {
author {
# that could go on as deep as the client wants!
}
}
}
}
}
}
}
}

如果我们可以阻止客户滥用这样的查询深度呢? 在了解定义的模式时,可以让你了解合法查询的深度。这实际上是可以实现的,并且通常称为最大查询深度。

通过分析查询文档的AST,GraphQL服务器能够根据其深度拒绝或接受请求。

例如,配置了最大查询深度为3的服务器,以及以下查询文档。红色方框选中的所有内容都被认为深度太深,查询无效。

图片描述

使用最大查询深度设置的graphql-ruby服务,我们得到以下返回结果:

1
2
3
4
5
6
7
json复制代码{
"errors": [
{
"message": "Query has depth of 6, which exceeds max depth of 3"
}
]
}

最大查询深度优点

  • 由于静态分析了文档的AST,因此查询甚至不执行,所以不会在GraphQL服务器上增加负担。

最大查询深度缺点

  • 只有深度往往不足以涵盖所有滥用查询。 例如,在根节点上请求大量的查询将是代价巨大的,但不太可能被查询深度分析器阻止。

查询复杂性

有时,查询的深度还不足以真正了解GraphQL查询的开销。在很多情况下,我们的模式中的某些字段比其他字段更复杂。

查询复杂性允许您定义这些字段的复杂程度,并限制最大复杂度的查询。这个想法是通过使用一个简单的数字来定义每个字段的复杂程度。一个常见的默认设置是给每个字段一个复杂的1。以这个查询为例:

1
2
3
4
5
6
7
bash复制代码query {
author(id: "abc") { # complexity: 1
posts { # complexity: 1
title # complexity: 1
}
}
}

一个简单的加法,告诉我们查询的复杂性是3。如果我们在我们的架构上设置最大复杂度为2,则此查询将会失败。

如果posts字段实际上比作者字段复杂度高很多呢?我们可以为该领域设置不同的复杂性。我们甚至可以根据参数设置不同的复杂性! 我们来看看一个类似的查询,其中posts会根据传入的参数去确定复杂性:

1
2
3
4
5
6
7
php复制代码query {
author(id: "abc") { # complexity: 1
posts(first: 5) { # complexity: 5
title # complexity: 1
}
}
}

查询复杂性的优点

  • 可以覆盖比更多的用例。
  • 通过静态分析复杂性,在执行前拒绝查询。

查询复杂性缺点

  • 很难实现完美
  • 如果需要开发时预估复杂性,我们如何保持状态最新?我们一开始怎么能知道查询成本?
  • Mutations 很难估计。如果他们有一个难以衡量的附加操作,如在后台排队执行的任务怎么办?

节流

到目前为止,我们看到的解决方案都是会阻止滥用服务器的查询。像这样使用它们的问题是,它们会阻止大量查询,但不会阻止客户端生成出大量查询!

在大多数API中,使用简单的节流方式是,阻止客户端频繁地请求资源。GraphQL有点特别,因为调节请求数并没有真正帮助我们。即使是很少的请求也可能是大量的查询。

事实上,我们不知道客户端定义了多少请求是可以接受的。那么我们如何来限制客户端呢?

基于服务器执行时间的调节

我们可以通过查询执行时的服务器耗时,来估计查询的复杂程度。我们可以使用这种式来限制查询。凭借对系统的了解,您可以提出客户端可以在特定时间范围内使用的最大服务器时间。

我们还决定随着时间的推移,客户端添加多少服务器时间。这是一个经典的leaky bucket 算法。请注意,还有其他节流算法,但这些算法超出了本章的范围。在下面的例子中我们将使用leaky bucket。

让我们想象一下,我们将允许的最大服务器时间(Bucket Size)设置为1000ms,客户端每秒获得100ms的服务器时间(Leak Rate),mutation 如下:

1
2
3
4
5
6
7
css复制代码mutation {
createPost(input: { title: "GraphQL Security" }) {
post {
title
}
}
}

这个mutation平均需要200ms才能完成。实际上,时间可能会有所不同,但我们假设为了这个例子,它总是需要200ms才能完成。

这意味着在1秒内调用此操作超过5次的客户端将被阻止,直到更多的可用服务器时间添加到客户端。

经过两秒钟(100ms加秒),我们的客户可以一次调用createPost。

正如你所看到的,基于时间的调节是限制GraphQL查询不错的方式,因为复杂的查询将最终消耗更多的时间,这意味着你不能频繁地调用它们,而较小的查询可能被更频繁地调用,因为它们将非常快速地计算。

但如果GraphQL API是公开的,向客户端提出这些限制条件就不那么容易了。在这种情况下,服务器耗时并不能很好地告知客户端,客户端也不能准确的估计他们的查询所需要的时间,在不先试着请求的情况下。

还记得我们之前提到的最大复杂度?如果我们根据这个调节,会怎么样?

基于查询复杂度的调节

基于查询复杂度的调节是与客户端合作的好方法,客户端可以遵循schema中的限制。

我们使用与“查询复杂性”部分中使用的相同的复杂性示例:

1
2
3
4
5
6
7
bash复制代码query {
author(id: "abc") { # complexity: 1
posts { # complexity: 1
title # complexity: 1
}
}
}

我们知道这个查询的成本是基于复杂度的3。就像时间流逝一样,我们可以得知客户可以使用的每次最高成本(Bucket Size)。

如果最大成本为9,我们的客户只能三次运行此查询,不允许查询更多。

这些原理与我们的时间节制相同,但现在将这些限制传达给客户端后。客户甚至可以自己计算查询成本,而无需估计服务器时间!

GitHub公共API实际上使用这种方法来扼制客户端。看看他们如何对用户表达这些限制:https://developer.github.com/v4/guides/resource-limitations/。

总结

GraphQL非常适合用于客户端,因为给予了更多的功能。但是,强大的功能也带来了风险,担心客户端会以非常昂贵的查询来滥用GraphQL服务器。

有许多方法来保护您的GraphQL服务器免受这些查询,但是它们都不是万无一失的。重要的是,我们要知道有哪些方法可用来限制,并了解他们的优缺点,然后采取最优的决定!

进阶 - 5.常见问题

GraphQL是数据库技术吗?

不是。GraphQL经常与数据库技术混淆。这是一个误解,GraphQL是API的查询语言,而不是数据库。在这个意义上,它是数据库无关的,可以用于任何类型的数据库,甚至根本没有数据库。

GraphQL仅适用于React / Javascript开发人员?

GraphQL是一种API技术,因此可以在需要API的任何上下文中使用。

在后端,GraphQL服务器可以用任何可用于构建Web服务器的编程语言实现。在JavaScript之外,Ruby,Python,Scala,Java,Clojure,Go和.NET都有流行的实现可以参考。

由于GraphQL API通常通过HTTP进行操作,任何可以发起HTTP请求的客户端都可以从GraphQL服务器查询数据。

注意:GraphQL实际上是传输层不可知的,所以您可以选择其他协议,比HTTP来实现您的服务器。

如何做服务器端缓存?

GraphQL的一个常见问题,特别是与REST进行比较时,难以维护服务器端缓存。使用REST,可以轻松地为每个端点缓存数据,因为它确保数据的结构不会改变。

另一方面,使用GraphQL,客户端下一步要求什么不清楚,所以将缓存层放在API的后面并没有什么意义。

服务器端缓存仍然是GraphQL的挑战。有关缓存的更多信息可以在GraphQL网站上找到。

如何进行身份验证和授权?

认证和授权往往是混淆的。身份验证描述了声明身份的过程。这是当您使用用户名和密码登录服务时,您进行身份验证。另一方面,授权描述了指定个别用户和用户组对系统某些部分的访问权限的权限规则。

GraphQL中的身份验证可以使用诸如OAuth的常用模式来实现。

为了实现授权,建议将任何数据访问逻辑委派给业务逻辑层,而不是直接在GraphQL实现中处理它。如果您想要了解如何实施授权的灵感,可以查看Graphcool的权限查询。

如何处理错误?

一个成功的GraphQL查询应该返回一个名为“data”的根字段的JSON对象。如果请求失败或部分失败(例如因为请求数据的用户没有正确的访问权限),则将一个称为“errors”的第二根字段添加到响应中:

1
2
3
4
json复制代码{
"data": { ... },
"errors": [ ... ]
}

有关更多详细信息,可参考GraphQL规范。

GraphQL是否支持脱机使用?

GraphQL是(web)API的查询语言,在这个意义上,只能在线工作。 但是,客户端的脱机支持是一个有意义的问题。 Relay和Apollo的缓存能力对于一些用例可能已经足够了,但目前,还没有一个流行的解决方案来存储数据。你可以在Relay和Apollo的GitHub问题中获得更多见解,其中讨论了关于脱机的支持。

离线使用和持久性的一个有趣的方法可以在这里找到。

1
复制代码

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

这么讲线程池,彻底明白了! 引言:老三取钱 实战:线程池管理

发表于 2021-11-02

大家好,我是老三,很高兴又和大家见面,最近降温,大家注意保暖。

这节分享Java线程池,接下来我们一步步把线程池扒个底朝天。

引言:老三取钱

有一个程序员,他的名字叫老三。

老三兜里没有钱,匆匆银行业务办。

这天起了一大早,银行姐姐说早安。

老三一看柜台空,卡里五毛都取完。

直接办理

老三这天起的晚,营业窗口都排满。

只好进入排队区,摸出手机等空闲。

老三排队等待

老三睡到上三杆,窗口排队都爆满。

经理一看开新口,排队同志赶紧办。

排队区满

这天业务太火爆,柜台排队都用完。

老三一看急上火,经理你说怎么办。

窗口,排队都爆满

经理挥手一笑间,这种场面已见惯。四种办法来处理,你猜我会怎么办。

  • 小小银行不堪负,陈旧系统已瘫痪。
  • 我们庙小对不起,谁叫你来找谁办。
  • 看你情况特别急,来去队里加个塞。
  • 今天实在没办法,不行你看改一天。

四种策略

对,没错,其实这个流程就和JDK线程池ThreadPoolExecutor的工作流程类似,先卖个关子,后面结合线程池工作流程,保证你会豁然开朗。

实战:线程池管理数据处理线程

光说不练假把式,show you code,我们来一个结合业务场景的线程池实战。——很多同学面试的时候,线程池原理背的滚瓜烂熟,一问项目中怎么用的,歇菜。看完这个例子,赶紧琢磨琢磨,项目里有什么地方能套用的。

应用场景

应用场景非常简单,我们的项目是一个审核类的系统,每年到了核算的时候,需要向第三方的核算系统提供数据,以供核算。

这里存在一个问题,由于历史原因,核算系统提供的接口只支持单条推送,但是实际的数据量是三十万条,如果一条条推送,那么起码得一个星期。

所以就考虑使用多线程的方式来推送数据,那么,线程通过什么管理呢?线程池。

为什么要用线程池管理线程呢?当然是为了线程复用。

线程池实际应用场景

思路也很简单,开启若干个线程,每个线程从数据库中读取取(start,count]区间未推送的数据进行推送。

数据分段推送

具体代码实现

我把这个场景提取了出来,主要代码:

主要代码

代码比较长,所以用了carbon美化,代码看不清,没关系,可运行的代码我都上传到了远程仓库,仓库地址:gitee.com/fighter3/th… ,这个例子比较简单,没有用过线程池的同学可以考虑你有没有什么数据处理、清洗的场景可以套用,不妨借鉴、演绎一下。

本文主题是线程池,所以我们重点关注线程池的代码:

线程池构造

1
2
3
4
5
6
java复制代码//核心线程数:设置为操作系统CPU数乘以2
private static final Integer CORE_POOL_SIZE = Runtime.getRuntime().availableProcessors() * 2;
//最大线程数:设置为和核心线程数相同
private static final Integer MAXIMUM_POOl_SIZE = CORE_POOL_SIZE;
//创建线程池
ThreadPoolExecutor pool = new ThreadPoolExecutor(CORE_POOL_SIZE, MAXIMUM_POOl_SIZE * 2, 0, TimeUnit.SECONDS, new LinkedBlockingQueue<>(100));

线程池直接采用ThreadPoolExecutor构造:

  • 核心线程数设置为CPU数×2
  • 因为需要分段数据,所以最大线程数设置为和核心线程数一样
  • 阻塞队列使用LinkedBlockingQueue
  • 拒绝策略使用默认

线程池提交任务

1
2
java复制代码//提交线程,用数据起始位置标识线程
Future<Integer> future = pool.submit(new PushDataTask(start, LIMIT, start));
  • 因为需要返回值,所以使用submit()提交任务,如果使用execute()提交任务,没有返回值。

代码不负责,可以done下来跑一跑。

那么,线程池具体是怎么工作的呢?我们接着往下看。

原理:线程池实现原理

线程池工作流程

构造方法

我们在构造线程池的时候,使用了ThreadPoolExecutor的构造方法:

1
2
3
4
5
6
7
8
java复制代码    public ThreadPoolExecutor(int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue) {
this(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue,
Executors.defaultThreadFactory(), defaultHandler);
}

先来看看几个参数的含义:

  • corePoolSize: 核心线程数
  • maximumPoolSize:允许的最大线程数(核心线程数+非核心线程数)
  • workQueue:线程池任务队列

用来保存等待执行的任务的阻塞队列,常见阻塞队列有:

+ `ArrayBlockingQueue`:一个基于数组结构的有界阻塞队列
+ `LinkedBlockingQueue`:基于链表结构的阻塞队列
+ `SynchronousQueue`:不存储元素的阻塞队列
+ `PriorityBlockingQueue`:具有优先级的无限阻塞队列
  • handler: 线程池饱和拒绝策略

JDK线程池框架提供了四种策略:

+ `AbortPolicy`:直接抛出异常,默认策略。
+ `CallerRunsPolicy`:用调用者所在线程来运行任务。
+ `DiscardOldestPolicy`:丢弃任务队列里最老的任务
+ `DiscardPolicy`:不处理,丢弃当前任务也可以根据自己的应用场景,实现`RejectedExecutionHandler`接口来自定义策略。

上面四个是和线程池工作流程息息相关的参数,我们再来看看剩下三个参数。

  • keepAliveTime:非核心线程闲置下来最多存活的时间
  • unit:线程池中非核心线程保持存活的时间
  • threadFactory:创建一个新线程时使用的工厂,可以用来设定线程名等

线程池工作流程

知道了几个参数,那么这几个参数是怎么应用的呢?

以execute()方法提交任务为例,我们来看线程池的工作流程:

线程池工作流程

向线程池提交任务的时候:

  • 如果当前运行的线程少于核心线程数corePoolSize,则创建新线程来执行任务
  • 如果运行的线程等于或多于核心线程数corePoolSize,则将任务加入任务队列workQueue
  • 如果任务队列workQueue已满,创建新的线程来处理任务
  • 如果创建新线程使当前总线程数超过最大线程数maximumPoolSize,任务将被拒绝,线程池拒绝策略handler执行

结合一下我们开头的生活事例,是不是就对上了:

老三取钱和线程池工作流程

线程池工作源码分析

上面的流程分析,让我们直观地了解了线程池的工作原理,我们再来通过源码看看细节。

提交线程(execute)

线程池执行任务的方法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
java复制代码    public void execute(Runnable command) {
if (command == null)
throw new NullPointerException();
//获取当前线程池的状态+线程个数变量的组合值
int c = ctl.get();
//1.如果正在运行线程数少于核心线程数
if (workerCountOf(c) < corePoolSize) {
//开启新线程运行
if (addWorker(command, true))
return;
c = ctl.get();
}
//2. 判断线程池是否处于运行状态,是则添加任务到阻塞队列
if (isRunning(c) && workQueue.offer(command)) {
//二次检查
int recheck = ctl.get();
//如果当前线程池不是运行状态,则从队列中移除任务,并执行拒绝策略
if (! isRunning(recheck) && remove(command))
reject(command);
//如若当前线程池为空,则添加一个新线程
else if (workerCountOf(recheck) == 0)
addWorker(null, false);
}
//最后尝试添加线程,如若添加失败,执行拒绝策略
else if (!addWorker(command, false))
reject(command);
}

我们来看一下execute()的详细流程图:

execute()具体代码执行

新增线程 (addWorker)

在execute方法代码里,有个关键的方法private boolean addWorker(Runnable firstTask, boolean core),这个方法主要完成两部分工作:增加线程数、添加任务,并执行。

  • 我们先来看第一部分增加线程数:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
java复制代码        retry:
for (;;) {
int c = ctl.get();
int rs = runStateOf(c);

// 1.检查队列是否只在必要时为空(判断线程状态,且队列不为空)
if (rs >= SHUTDOWN &&
! (rs == SHUTDOWN &&
firstTask == null &&
! workQueue.isEmpty()))
return false;
//2.循环CAS增加线程个数
for (;;) {
int wc = workerCountOf(c);
//2.1 如果线程个数超限则返回 false
if (wc >= CAPACITY ||
wc >= (core ? corePoolSize : maximumPoolSize))
return false;
//2.2 CAS方式增加线程个数,同时只有一个线程成功,成功跳出循环
if (compareAndIncrementWorkerCount(c))
break retry;
//2.3 CAS失败,看线程池状态是否变化,变化则跳到外层,尝试重新获取线程池状态,否则内层重新CAS
c = ctl.get(); // Re-read ctl
if (runStateOf(c) != rs)
continue retry;
}
}
//3. 到这说明CAS成功了
boolean workerStarted = false;
boolean workerAdded = false;
  • 接着来看第二部分添加任务,并执行
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
java复制代码       Worker w = null;
try {
//4.创建worker
w = new Worker(firstTask);
final Thread t = w.thread;
if (t != null) {
//4.1、加独占锁 ,为了实现workers同步,因为可能多个线程调用了线程池的excute方法
final ReentrantLock mainLock = this.mainLock;
mainLock.lock();
try {
//4.2、重新检查线程池状态,以避免在获取锁前调用了shutdown接口
int rs = runStateOf(ctl.get());

if (rs < SHUTDOWN ||
(rs == SHUTDOWN && firstTask == null)) {
if (t.isAlive()) // precheck that t is startable
throw new IllegalThreadStateException();
//4.3添加任务
workers.add(w);
int s = workers.size();
if (s > largestPoolSize)
largestPoolSize = s;
workerAdded = true;
}
} finally {
mainLock.unlock();
}
//4.4、添加成功之后启动任务
if (workerAdded) {
t.start();
workerStarted = true;
}
}
} finally {
if (! workerStarted)
addWorkerFailed(w);
}
return workerStarted;

我们来看一下整体的流程:

addWorker()流程

执行线程(runWorker)

用户线程提交到线程池之后,由Worker执行,Worker是线程池内部一个继承AQS、实现Runnable接口的自定义类,它是具体承载任务的对象。

Worker类图

先看一下它的构造方法:

1
2
3
4
5
java复制代码        Worker(Runnable firstTask) {
setState(-1); // 在调用runWorker之前禁止中断
this.firstTask = firstTask;
this.thread = getThreadFactory().newThread(this); //创建一个线程
}
  • 在构造函数内 首先设置 state=-1,现了简单不可重入独占锁,state=0表示锁未被获取状态,state=1表示锁已被获取状态,设置状态大小为-1,是为了避免线程在运行runWorker()方法之前被中断
  • firstTask记录该工作线程的第一个任务
  • thread是具体执行任务的线程

它的run方法直接调用runWorker,真正地执行线程就是在我们的runWorker 方法里:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
java复制代码    final void runWorker(Worker w) {
Thread wt = Thread.currentThread();
Runnable task = w.firstTask;
w.firstTask = null;
w.unlock(); // 允许中断
boolean completedAbruptly = true;
try {
//获取当前任务,从队列中获取任务
while (task != null || (task = getTask()) != null) {
w.lock();
…………
try {
//执行任务前做一些类似统计之类的事情
beforeExecute(wt, task);
Throwable thrown = null;
try {
//执行任务
task.run();
} catch (RuntimeException x) {
thrown = x; throw x;
} catch (Error x) {
thrown = x; throw x;
} catch (Throwable x) {
thrown = x; throw new Error(x);
} finally {
// 执行任务完毕后干一些些事情
afterExecute(task, thrown);
}
} finally {
task = null;
// 统计当前Worker 完成了多少个任务
w.completedTasks++;
w.unlock();
}
}
completedAbruptly = false;
} finally {
//执行清理工作
processWorkerExit(w, completedAbruptly);
}
}

代码看着多,其实砍掉枝蔓,最核心的点就是task.run() 让线程跑起来。

获取任务(getTask)

我们在上面的执行任务runWorker里看到,这么一句while (task != null || (task = getTask()) != null) ,执行的任务是要么当前传入的firstTask,或者还可以通过getTask()获取,这个getTask的核心目的就是从队列中获取任务。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
java复制代码private Runnable getTask() {
//poll()方法是否超时
boolean timedOut = false;
//循环获取
for (;;) {
int c = ctl.get();
int rs = runStateOf(c);

// 1.线程池未终止,且队列为空,返回null
if (rs >= SHUTDOWN && (rs >= STOP || workQueue.isEmpty())) {
decrementWorkerCount();
return null;
}
//工作线程数
int wc = workerCountOf(c);


boolean timed = allowCoreThreadTimeOut || wc > corePoolSize;

//2.判断工作线程数是否超过最大线程数 && 超时判断 && 工作线程数大于0或队列为空
if ((wc > maximumPoolSize || (timed && timedOut))
&& (wc > 1 || workQueue.isEmpty())) {
if (compareAndDecrementWorkerCount(c))
return null;
continue;
}

try {
//从任务队列中获取线程
Runnable r = timed ?
workQueue.poll(keepAliveTime, TimeUnit.NANOSECONDS) :
workQueue.take();
//获取成功
if (r != null)
return r;
timedOut = true;
} catch (InterruptedException retry) {
timedOut = false;
}
}
}

总结一下,Worker执行任务的模型如下[8]:

Worker执行任务模型

小结

到这,了解了execute和worker的一些流程,可以说其实ThreadPoolExecutor 的实现就是一个生产消费模型。

当用户添加任务到线程池时相当于生产者生产元素, workers 线程工作集中的线程直接执行任务或者从任务队列里面获取任务时则相当于消费者消费元素。

线程池生产消费模型

线程池生命周期

线程池状态表示

在ThreadPoolExecutor里定义了一些状态,同时利用高低位的方式,让ctl这个参数能够保存状态,又能保存线程数量,非常巧妙![6]

1
2
3
4
5
6
7
8
9
10
11
12
java复制代码    //记录线程池状态和线程数量
private final AtomicInteger ctl = new AtomicInteger(ctlOf(RUNNING, 0));
//29
private static final int COUNT_BITS = Integer.SIZE - 3;
private static final int CAPACITY = (1 << COUNT_BITS) - 1;

// 线程池状态
private static final int RUNNING = -1 << COUNT_BITS;
private static final int SHUTDOWN = 0 << COUNT_BITS;
private static final int STOP = 1 << COUNT_BITS;
private static final int TIDYING = 2 << COUNT_BITS;
private static final int TERMINATED = 3 << COUNT_BITS;

高3位表示状态,低29位记录线程数量:

高 3 位与低 29 位

线程池状态流转

线程池一共定义了五种状态,来看看这些状态是怎么流转的[6]:

线程池状态流转

  • RUNNING:运行状态,接受新的任务并且处理队列中的任务。
  • SHUTDOWN:关闭状态(调用了 shutdown 方法)。不接受新任务,,但是要处理队列中的任务。
  • STOP:停止状态(调用了 shutdownNow 方法)。不接受新任务,也不处理队列中的任务,并且要中断正在处理的任务。
  • TIDYING:所有的任务都已终止了,workerCount 为 0,线程池进入该状态后会调terminated() 方法进入 TERMINATED 状态。
  • TERMINATED:终止状态,terminated() 方法调用结束后的状态。

应用:打造健壮的线程池

合理地配置线程池

关于线程池的构造,我们需要注意两个配置,线程池的大小和任务队列。

线程池大小

关于线程池的大小,并没有一个需要严格遵守的“金规铁律”,按照任务性质,大概可以分为CPU密集型任务、IO密集型任务和混合型任务。

  • CPU密集型任务:CPU密集型任务应配置尽可能小的线程,如配置Ncpu+1个线程的线程池。
  • IO密集型任务:IO密集型任务线程并不是一直在执行任务,则应配置尽可能多的线程,如2*Ncpu。
  • 混合型任务:混合型任务可以按需拆分成CPU密集型任务和IO密集型任务。

当然,这个只是建议,实际上具体怎么配置,还要结合事前评估和测试、事中监控来确定一个大致的线程线程池大小。线程池大小也可以不用写死,使用动态配置的方式,以便调整。

任务队列

任务队列一般建议使用有界队列,无界队列可能会出现队列里任务无限堆积,导致内存溢出的异常。

线程池监控

[1]如果在系统中大量使用线程池,则有必要对线程池进行监控,方便在出现问题时,可以根据线程池的使用状况快速定位问题。

可以通过线程池提供的参数和方法来监控线程池:

  • getActiveCount() :线程池中正在执行任务的线程数量
  • getCompletedTaskCount() :线程池已完成的任务数量,该值小于等于 taskCount
  • getCorePoolSize() :线程池的核心线程数量
  • getLargestPoolSize():线程池曾经创建过的最大线程数量。通过这个数据可以知道线程池是否满过,也就是达到了 maximumPoolSize
  • getMaximumPoolSize():线程池的最大线程数量
  • getPoolSize() :线程池当前的线程数量
  • getTaskCount() :线程池已经执行的和未执行的任务总数

还可以通过扩展线程池来进行监控:

  • 通过继承线程池来自定义线程池,重写线程池的beforeExecute、afterExecute和terminated方法,
  • 也可以在任务执行前、执行后和线程池关闭前执行一些代码来进行监控。例如,监控任务的平均执行时间、最大执行时间和最小执行时间等。

End

这篇文章从一个生活场景入手,一步步从实战到原理来深入了解线程池。

但是你发现没有,我们平时常说的所谓四种线程池在文章里没有提及——当然是因为篇幅原因,下篇就安排线程池创建工具类Executors。

线程池也是面试的重点战区,面试又会问到哪些问题呢?

这些内容,都已经在路上。点赞、关注不迷路,下篇见!


参考:

[1]. 《Java并发编程的艺术》

[2]. 《Java发编程实战》

[3]. 讲真 这次绝对让你轻松学习线程池

[4]. 面试必备:Java线程池解析

[5]. 面试官问:“在项目中用过多线程吗?”你就把这个案例讲给他听!

[6]. 小傅哥 《Java面经手册》

[7]. 《Java并发编程之美》

[8]. Java线程池实现原理及其在美团业务中的实践

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

Go高阶15,垃圾回收、三色标记原理,终于能跟面试官扯皮了!

发表于 2021-11-02

这是我参与11月更文挑战的第2天,活动详情查看:2021最后一次更文挑战

垃圾回收(Garbage Collection,简称GC)是编程语言中自动的内存管理机制,垃圾回收,垃圾指的是不再需要的内存块,如果不及时清理就没有办法再利用。

垃圾回收算法

常见的垃圾回收算法有:

  • 引用计数:每个对象维护一个引用计数,如果这个对象被销毁,则计数 -1 ,当计数为 0 时,回收该对象。
    • 优点:对象可以很快被回收,不会出现内存耗尽或到达阀值才回收。
    • 缺点:不能很好的处理循环引用
  • 标记-清除:从根变量开始变量所以引用的对象,引用的对象标记“被引用”,没有被标记的则进行回收。
    • 优点:解决了引用计数的缺点。
    • 缺点:需要 STW(stop the world),暂时停止程序运行。
  • 分代收集::按照对象生命周期长短划分不同的代空间,生命周期长的放入老年代,短的放入新生代,不同代有不同的回收算法和回收频率。
    • 优点:回收性能好
    • 缺点:算法复杂

Go 语言的 GC(垃圾回收)

标记-清除(mark and sweep)

此算法是在Go V1.3 之前使用的,主要有两个主要的步骤:

标记(Mark phase)——》清除(Sweep phase)

  1. 暂停程序业务逻辑, 找出不可达的对象,然后做上标记。

注意:mark and sweep 算法在执行的时候,需要程序暂停!也就是所谓的 STW(stop the world)。这段时间程序会卡住。

程序可达对象为 1、2、4

  1. 开始标记,找出所有可达的对象,并标记

对象 1、2、4 做上标记

  1. 清除未被标记的对象

  1. 程序暂停取消。然后重复上面的过程,直至程序生命周期结束。

三色并发标记法

此算法是在Go V1.5 开始使用的,三色只是为了叙述上方便抽象出来的一种说法,实际上对象并没有颜色之分。这里的三色,对应了垃圾回收过程中对象的三种状态:

  • 灰色:对象还在标记队列中等待
  • 黑色:对象已被标记,该对象不会在本次GC中被清理
  • 白色:对象未被标记,该对象将会在本次GC中被清理
  1. 初始状态下所有对象都是白色的。
  2. 从根节点开始遍历所有对象,把遍历到的对象变成灰色对象(备注:这里变成灰色对象的都是根节点的对象)。
  3. 遍历灰色对象,将灰色对象引用的对象(备注:这里指的是灰色对象引用到的所有对象,包括灰色节点间接引用的那些对象)也变成灰色对象,然后将遍历过的灰色对象变成黑色对象。
  4. 循环步骤3,直到灰色对象全部变黑色。
  5. 通过写屏障(write-barrier)检测对象有变化,重复以上操作(备注:因为 mark 和用户程序是并行的,所以在上一步执行的时候可能会有新的对象分配,写屏障是为了解决这个问题引入的)。
  6. 收集所有白色对象(垃圾)。

举例说明:

  1. 初识阶段,所以对象均为白色,调用情况为:

root ->A->B/A->C/A<->D;

root->F;

E;

G->H;

  1. GC 开始扫描,从根节点开始遍历,发现只有 A 和 F 是根节点,于是将 A、F 从变为灰色对象。
  2. GC 继续扫描灰色对象,会将灰色对象的节点中引用的节点也变为灰色对象,A 节点引用的节点B、C、D 会被变为灰色对象,接着 A 的所有子节点遍历完毕,便会变为黑色对象,而 F 节点没有子节点,也会变为黑色对象。

  1. GC 会循环遍历灰色对象,直到灰色对象之中没有节点为止,在本例中,发现B、C、D 都没有子节点是白色,便将B、C、D 都变为黑色对象。

  1. 剩下E、G、H 为白色对象,GC 便进行回收这些白色对象。

  1. 上面的垃圾回收结束之后,GC 会在进行一步操作,也就是将黑色对象重新变色成白色对象,供下一次垃圾回收使用。

Stop The World

Golang 中的 STW(Stop The World)是停掉所有的 goroutine,专心做垃圾回收,待垃圾回收结束后再恢复
goroutine。
STW 时间的长短直接影响了应用的执行,时间过长对于一些web 应用来说是不可接受的,这也是广受诟病的原因之一。
为了缩短 STW 的时间,Golang 不断优化垃圾回收算法,这种情况得到了很大的改善。

垃圾回收优化

写屏障(Write Barrier)

STW 目的是防止 GC 扫描时内存变化而停掉 goroutine,而写屏障就是让 goroutine 与GC同时运行的手段。
虽然写屏障不能完全消除STW,但是可以大大减少STW的时间。
写屏障类似一种开关,在 GC 的特定时机开启,开启后指针传递时会把指针标记,即本轮不回收,下次 GC 时再确定。
GC 过程中新分配的内存会被立即标记,用的并不是写屏障技术,也即GC过程中分配的内存不会在本轮GC中回收。

辅助GC(Mutator Assist)

为了防止内存分配过快,在 GC 执行过程中,如果 goroutine 需要分配内存,那么这个 goroutine 会参与一部分GC的工作,即帮助 GC 做一部分工作,这个机制叫作 Mutator Assist。

垃圾回收触发时机

  1. 每次内存分配时都会检查当前内存分配量是否已达到阀值,如果达到阀值则立即启动 GC。内存增长率由环境变量 GOGC 控制,默认为100,即每当内存扩大一倍时启动GC。

阀值 = 上次GC内存分配量 * 内存增长率
2. 默认情况下,最长2分钟触发一次GC。
3. 程序代码中也可以使用 runtime.GC() 来手动触发GC。这主要用于GC性能测试和统计。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

SpringBoot中如何在过滤器中取post的参数值

发表于 2021-11-02

这是我参与11月更文挑战的第2天,活动详情查看:2021最后一次更文挑战

写在前面

我们在上篇文章中说到了如何在过滤器中取get请求的参数值,也给出了一些方法的应用示例,大家如果感兴趣,可以看一下。

SpringBoot中如何在过滤器中取get的参数值

今天我们要来学习的也是同样的系列,那就是如何在过滤器中取post请求的参数值。

应用场景

如果获取到post请求的参数,我们一般都在过滤器,或者是拦截器,甚至解析器中想要修改相关的参数,或者是在原来的基础上增加一些参数的话。

那么现在我们就要用到接下来的解决方案了,一起来看一下吧。

解决方案

我们一般都是通过继承HttpServletRequestWrapper类,来实现具体的参数处理方法。

假设我们现在要在过滤器中获取post请求的数据。

首先要创建一个RequestWrapper.java类,下面我们就直接先贴一下代码了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
java复制代码@Data
public class RequestWrapper extends HttpServletRequestWrapper {

private byte[] body;

public RequestWrapper(HttpServletRequest request) throws IOException {
super(request);
body = this.toByteArray(request.getInputStream());
}

private byte[] toByteArray(ServletInputStream inputStream) throws IOException {
ByteArrayOutputStream out = new ByteArrayOutputStream();
byte[] buffer = new byte[1024 * 4];
int n = 0;
while ((n = inputStream.read(buffer)) != -1) {
out.write(buffer, 0, n);
}
return out.toByteArray();
}

@Override
public BufferedReader getReader() {
return new BufferedReader(new InputStreamReader(getInputStream()));
}

@Override
public ServletInputStream getInputStream() {
final ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(body);
return new ServletInputStream() {
@Override
public boolean isFinished() {
return false;
}
@Override
public boolean isReady() {
return false;
}
@Override
public void setReadListener(ReadListener readListener) {
}
@Override
public int read() {
return byteArrayInputStream.read();
}
};
}
}

上述类中,继承了HttpServletRequestWrapper类,继承重写了父类的getReader、getInputStream方法,其实我们从字面意思上来看也能看出来相关的意思。

getReader():读取数据并返回一个BufferReader类。

getInputStream():获取文件输入流。

有了这个类,我们就可以将Post请求的参数值成功的拿出来了,让我们且接着看。

在过滤器的方法中,我们能拿到request对象,所以我们就不贴出太多代码了,只跟大家说一下重点代码。

1
2
3
ini复制代码RequestWrapper requestWrapper = new RequestWrapper(req);
byte[] bytes = requestWrapper.getBody();
Map<String, String> map = ByteUtils.changeByteToMap(bytes);

我这里使用了一个工具类,之后也和大家分享一下哈,本篇文章里就不赘述了,大家赶紧去试试吧。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

实战小技巧19:Map转换的几种方式

发表于 2021-11-02

「这是我参与11月更文挑战的第2天,活动详情查看:2021最后一次更文挑战」。

在日常开发过程中,从一个Map转换为另外一个Map属于基本操作了,那么我们一般怎么去实现这种场景呢?有什么更简洁省事的方法么?

实例场景

现在我们给一个简单的实例

希望将一个Map<String, Integer> 转换成 Map<String, String>,接下来看一下有哪些实现方式,以及各自的优缺点

首先提供一个创建Map的公共方法

1
2
3
4
5
6
7
8
java复制代码private static <T> Map<String, T> newMap(String key, T val, Object... kv) {
Map<String, T> ans = new HashMap<>(8);
ans.put(key, val);
for (int i = 0, size = kv.length; i < size; i += 2) {
ans.put(String.valueOf(kv[i]), (T) kv[i + 1]);
}
return ans;
}

方式一:基本的for循环转换

这种方式是最容易想到和实现的,直接for循环来转换即可

1
2
3
4
5
6
7
8
9
java复制代码@Test
public void forEachParse() {
Map<String, Integer> map = newMap("k", 1, "a", 2, "b", 3);
Map<String, String> ans = new HashMap<>(map.size());
for (Map.Entry<String, Integer> entry: map.entrySet()) {
ans.put(entry.getKey(), String.valueOf(entry.getValue()));
}
System.out.println(ans);
}

这种方式的优点很明显,实现容易,业务直观;

缺点就是可复用性较差,代码量多(相比于下面的case)

方式二:容器的流式使用

在jdk1.8提供了流式操作,同样也可以采用这种方式来实现转换

1
2
3
4
5
6
7
java复制代码@Test
public void stream() {
Map<String, Integer> map = newMap("k", 1, "a", 2, "b", 3);
Map<String, String> ans = map.entrySet().stream().collect(
Collectors.toMap(Map.Entry::getKey, s -> String.valueOf(s.getValue()), (a, b) -> a));
System.out.println(ans);
}

使用stream的方式,优点就是链式,代码量少;缺点是相较于上面的阅读体验会差一些(当然这个取决于个人,有些小伙伴就更习惯看这种链式的代码)

方式三:Guava的trasform方式

从代码层面来看,上面两个都不够直观,如果对guava熟悉的小伙伴对下面的代码可能就很熟悉了

1
2
3
4
5
6
java复制代码@Test
public void transfer() {
Map<String, Integer> map = newMap("k", 1, "a", 2, "b", 3);
Map<String, String> ans = Maps.transformValues(map, String::valueOf);
System.out.println(ans);
}

核心逻辑就一行 Maps.transformValues(map, String::valueOf),实现了我们的Map转换的诉求

很明显,这种方式的优点就是间接、直观;当然缺点就是需要引入guava,并且熟悉guava

最后一问,这篇文章目的是啥?

既然我们的标题是实战小技巧,本文除了给大家介绍可以使用guava的Maps.transformValues来实现map转换之外,更主要的一个目的是如果让我们自己来实现一个工具类,来支持这个场景,应该怎么做?

直接提供一个转换方法?

第一步:一个泛型的转换接口

1
2
java复制代码public <K, T, V> Map<K, V> transform(Map<K, T> map) {
}

定义上面这个接口之后,自然而然想到的缺点就是差一个value的转换实现

第二步:value转换的定义

这里采用Function接口思想来定义转换类

1
2
java复制代码public <K, T, V> Map<K, V> transform(Map<K, T> map, Function<T, V> func) {
}

当然到这里我们就需要注意jdk1.8以下是不支持函数编程的,那么我们可以怎么来实现呢?

这个时候再对照一下guava的实现,然后再手撸一个,知识点就到手了

一灰灰的联系方式

尽信书则不如无书,以上内容,纯属一家之言,因个人能力有限,难免有疏漏和错误之处,如发现bug或者有更好的建议,欢迎批评指正,不吝感激

  • 个人站点:blog.hhui.top
  • 微博地址: 小灰灰Blog
  • QQ: 一灰灰/3302797840
  • 微信公众号:一灰灰blog

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

内存管理:程序是如何被优雅的装载到内存中的

发表于 2021-11-02

内存作为计算机中一项比较重要的资源,它的主要作用就是解决CPU和磁盘之间速度的鸿沟,但是由于内存条是需要插入到主板上的,因此对于一台计算机来说,由于物理限制,它的内存不可能无限大的。我们知道我们写的代码最终是要从磁盘被加载到内存中的,然后再被CPU执行,不知道你有没有想过,为什么一些大型游戏大到10几G,却可以在只有8G内存的电脑上运行?甚至在玩游戏期间,我们还可以聊微信、听音乐…,这么多进程看着同时在运行,它们在内存中是如何被管理的?带着这些疑问我们来看看计算系统内存管理那些事。

内存的交换技术

如果我们的内存可以无限大,那么我们担忧的问题就不会存在,但是实际情况是往往我们的机器上会同时运行多个进程,这些进程小到需要几十兆内存,大到可能需要上百兆内存,当许许多多这些进程想要同时加载到内存的时候是不可能的,但是从我们用户的角度来看,似乎这些进程确实都在运行呀,这是怎么回事?

这就引入要说的交换技术了,从字面的意思来看,我想你应该猜到了,它会把某个内存中的进程交换出去。当我们的进程空闲的时候,其他的进程又需要被运行,然而很不幸,此时没有足够的内存空间了,这时候怎么办呢?似乎刚刚那个空闲的进程有种占着茅坑不拉屎的感觉,于是可以把这个空闲的进程从内存中交换到磁盘上去,这时候就会空出多余的空间来让这个新的进程运行,当这个换出去的空闲进程又需要被运行的时候,那么它就会被再次交换进内存中。通过这种技术,可以让有限的内存空间运行更多的进程,进程之间不停来回交换,看着好像都可以运行。

如图所示,一开始进程A被换入内存中,所幸还剩余的内存空间比较多,然后进程B也被换入内存中,但是剩余的空间比较少了,这时候进程C想要被换入到内存中,但是发现空间不够了,这时候会把已经运行一段时间的进程A换到磁盘中去,然后调入进程C。

内存碎片

通过这种交换技术,交替的换入和换出进程可以达到小内存可以运行更多的进程,但是这似乎也产生了一些问题,不知道你发现了没有,在进程C换入进来之后,在进程B和进程C之间有段较小的内存空间,并且进程B之上也有段较小的内存空间,说实话,这些小空间可能永远没法装载对应大小的程序,那么它们就浪费了,在某些情况下,可能会产生更多这种内存碎片。


如果想要节约内存,那么就得用到内存紧凑的技术了,即把所有的进程都向下移动,这样所有的碎片就会连接在一起变成一段更大的连续内存空间了。

但是这个移动的开销基本和当前内存中的活跃进程成正比,据统计,一台16G内存的计算机可以每8ns复制8个字节,它紧凑全部的内存大概需要16s,所以通常不会进行紧凑这个操作,因为它耗费的CPU时间还是比较大的。

动态增长

其实上面说的进程装载算是比较理想的了,正常来说,一个进程被创建或者被换入的时候,它占用多大的空间就分配多大的内存,但是如果我们的进程需要的空间是动态增长的,那就麻烦了,比如我们的程序在运行期间的for循环可能会利用到某个临时变量来存放目标数据(例如以下变量a,随着程序的运行是会越来越大的):

1
2
3
4
5
6
go复制代码var a []int64
for i:= 0;i <= 1000000;i++{
if i%2 == 0{
a = append(a,i) //a是不断增大的
}
}

当需要增长的时候:

  1. 如果进程的邻居是空闲区那还好,可以把该空闲区分配给进程
  2. 如果进程的邻居是另一个进程,那么解决的办法只能把增长的进程移动到一个更大的空闲内存中,但是万一没有更大的内存空间,那么就要触发换出,把一个或者多个进程换出去来提供更多的内存空间,很明显这个开销不小。

为了解决进程空间动态增长的问题,我们可以提前多给一些空间,比如进程本身需要10M,我们多给2M,这样如果进程发生增长的时候,可以利用这2M空间,当然前提是这2M空间够用,如果不够用还是得触发同样的移动、换出逻辑。

空闲的内存如何管理

前面我们说到内存的交换技术,交换技术的目的是腾出空闲内存来,那么我们是如何知道一块内存是被使用了,还是空闲的?因此需要一套机制来区分出空闲内存和已使用内存,一般操作系统对内存管理的方式有两种:位图法和链表法。

位图法

先说位图法,没错,位图法采用比特位的方式来管理我们的内存,每块内存都有位置,我们用一个比特位来表示:

  1. 如果某块内存被使用了,那么比特位为1
  2. 如果某块内存是空闲的,那么比特位为0

这里的某块内存具体是多大得看操作系统是如何管理的,它可能是一个字节、几个字节甚至几千个字节,但是这些不是重点,重点是我们要知道内存被这样分割了。


位图法的优点就是清晰明确,某个内存块的状态可以通过位图快速的知道,因为它的时间复杂度是O(1),当然它的缺点也很明显,就是需要占用太多的空间,尤其是管理的内存块越小的时候。更糟糕的是,进程分配的空间不一定是内存块的整数倍,那么最后一个内存块中一定是有浪费的。

如图,进程A和进程B都占用的最后一个内存块的一部分,那么对于最后一个内存块,它的另一部分一定是浪费的。

链表法

相比位图法,链表法对空间的利用更加合理,我相信你应该已经猜到了,链表法简单理解就是把使用的和空闲的内存用链表的方式连接起来,那么对于每个链表的元素节点来说,他应该具备以下特点:

  1. 应该知道每个节点是空闲的还是被使用的
  2. 每个节点都应该知道当前节点的内存的开始地址和结束地址

针对这些特点,最终内存对应的链表节点大概是这样的:


p代表这个节点对应的内存空间是被使用的,H代表这个节点对应的内存空间是空闲的,start代表这块内存空间的开始地址,length代表的是这块内存的长度,最后还有指向邻居节点的pre和next指针。

因此对于一个进程来说,它与邻居的组合有四种:

  1. 它的前后节点都不是空闲的
  2. 它的前一个节点是空闲的,它的后一个节点也不是空闲的
  3. 它的前一个节点不是空闲的,它的后一个节点是空闲的
  4. 它的前后节点都是空闲的

当一个内存节点被换出或者说进程结束后,那么它对应的内存就是空闲的,此时如果它的邻居也是空闲的,就会发生合并,即两块空闲的内存块合并成一个大的空闲内存块。

ok,通过链表的方式把我们的内存给管理起来了,接下来就是当创建一个进程或者从磁盘换入一个进程的时候,如何从链表中找到一块合适的内存空间?

首次适应算法

其实想要找到空闲内存空间最简单的办法就是顺着链表找到第一个满足需要内存大小的节点,如果找到的第一个空闲内存块和我们需要的内存空间是一样大小的,那么就直接利用,但是这太理想了,现实情况大部分可能是找到的第一个目标内存块要比我们的需要的内存空间要大一些,这时候呢,会把这个空闲内存空间分成两块,一块正好使用,一块继续充当空闲内存块。


一个需要3M内存的进程,会把4M的空间拆分成3M和1M。

下次适配算法

和首次适应算法很相似,在找到目标内存块后,会记录下位置,这样下次需要再次查找内存块的时候,会从这个位置开始找,而不用从链表的头节点开始寻找,这个算法存在的问题就是,如果标记的位置之前有合适的内存块,那么就会被跳过。


一个需要2M内存的进程,在5这个位置找到了合适的空间,下次如果需要这1M的内存会从5这个位置开始,然后会在7这个位置找到合适的空间,但是会跳过1这个位置。

最佳适配算法

相比首次适应算法,最佳适配算法的区别就是:不是找到第一个合适的内存块就停止,而是会继续向后找,并且每次都可能要检索到链表的尾部,因为它要找到最合适那个内存块,什么是最合适的内存块呢?如果刚好大小一致,则一定是最合适的,如果没有大小一致的,那么能容得下进程的那个最小的内存块就是最合适的,可以看出最佳适配算法的平均检索时间相对是要慢的,同时可能会造成很多小的碎片。


假设现在进程需要2M的内存,那么最佳适配算法会在检索到3号位置(3M)后,继续向后检索,最终会选择5号位置的空闲内存块。

最差适配算法

我们知道最佳适配算法中最佳的意思是找到一个最贴近真实大小的空闲内存块,但是这会造成很多细小的碎片,这些细小的碎片一般情况下,如果没有进行内存紧凑,那么大概率是浪费的,为了避免这种情况,就出现了这个最差适配算法,这个算法它和最佳适配算法是反着来的,它每次尝试分配最大的可用空闲区,因为这样的话,理论上剩余的空闲区也是比较大的,内存碎片不会那么小,还能得到重复利用。


一个需要1.5M的进程,在最差适配算法情况下,不会选择3号(2M)内存空闲块,而是会选择更大的5号(3M)内存空闲块。

快速适配算法

上面的几种算法都有一个共同的特点:空闲内存块和已使用内存块是共用的一个链表,这会有什么问题呢?正常来说,我要查找一个空闲块,我并不需要检索已经被使用的内存块,所以如果能把已使用的和未使用的分开,然后用两个链表分别维护,那么上面的算法无论哪种,速度都将得到提升,并且节点也不需要P和M来标记状态了。但是分开也有缺点,如果进程终止或者被换出,那么对应的内存块需要从已使用的链表中删掉然后加入到未使用的链表中,这个开销是要稍微大点的。当然对于未使用的链表如果是排序的,那么首次适应算法和最佳适应算法是一样快的。

快速适配算法就是利用了这个特点,这个算法会为那些常用大小的空闲块维护单独的链表,比如有4K的空闲链表、8K的空闲链表…,如果要分配一个7K的内存空间,那么既可以选择两个4K的,也可以选择一个8K的。


它的优点很明显,在查找一个指定大小的空闲区会很快速,但是一个进程终止或被换出时,会寻找它的相邻块查看是否可以合并,这个过程相对较慢,如果不合并的话,那么同样也会产生很多的小空闲区,它们可能无法被利用,造成浪费。

虚拟内存:小内存运行大程序

可能你看到小内存运行大程序比较诧异,因为上面不是说到了吗?只要把空闲的进程换出去,把需要运行的进程再换进来不就行了吗?内存交换技术似乎解决了,这里需要注意的是,首先内存交换技术在空间不够的情况下需要把进程换出到磁盘上,然后从磁盘上换入新进程,看到磁盘你可能明白了,很慢。其次,你发现没,换入换出的是整个进程,我们知道进程也是由一块一块代码组成的,也就是许许多多的机器指令,对于内存交换技术来说,一个进程下的所有指令要么全部进内存,要么全部不进内存。看到这里你可能觉得这不是正常吗?好的,别急,我们接着往下看。

后来出现了更牛逼的技术:虚拟内存。它的基本思想就是,每个程序拥有自己的地址空间,尤其注意后面的自己的地址空间,然后这个空间可以被分割成多个块,每一个块我们称之为页(page)或者叫页面,对于这些页来说,它们的地址是连续的,同时它们的地址是虚拟的,并不是真正的物理内存地址,那怎么办?程序运行需要读到真正的物理内存地址,别跟我玩虚的,这就需要一套映射机制,然后MMU出现了,MMU全称叫做:Memory Managment Unit,即内存管理单元,正常来说,CPU读某个内存地址数据的时候,会把对应的地址发到内存总线上,但是在虚拟内存的情况下,直接发到内存总线上肯定是找不到对应的内存地址的,这时候CPU会把虚拟地址告诉MMU,让MMU帮我们找到对应的内存地址,没错,MMU就是一个地址转换的中转站。

程序地址分页的好处是:

  1. 对于程序来说,不需要像内存交换那样把所有的指令都加载到内存中才能运行,可以单独运行某一页的指令
  2. 当进程的某一页不在内存中的时候,CPU会在这个页加载到内存的过程中去执行其他的进程。

当然虚拟内存会分页,那么对应的物理内存其实也会分页,只不过物理内存对应的单元我们叫页框。页面和页框通常是一样大的。我们来看个例子,假设此时页面和页框的大小都是4K,那么对于64K的虚拟地址空间可以得到64/4=16个虚拟页面,而对于32K的物理地址空间可以得到32/4=8个页框,很明显此时的页框是不够的,总有些虚拟页面找不到对应的页框。

我们先来看看虚拟地址为20500对应物理地址如何被找到的:

  1. 首先虚拟地址20500对应5号页面(20480-24575)
  2. 5号页面的起始地址20480向后查找20个字节,就是虚拟地址的位置
  3. 5号页面对应3号物理页框
  4. 3号物理页框的起始地址是12288,12288+20=12308,即12308就是我们实际的目标物理地址。

但是对于虚拟地址而言,图中还有红色的区域,上面我们也说到了,总有些虚拟地址没有对应的页框,也就是这部分虚拟地址是没有对应的物理地址,当程序访问到一个未被映射的虚拟地址(红色区域)的时候,那么就会发生缺页中断,然后操作系统会找到一个最近很少使用的页框把它的内容换到磁盘上去,再把刚刚发生缺页中断的页面从磁盘读到刚刚回收的页框中去,最后修改虚拟地址到页框的映射,然后重启引起中断的指令。

最后可以发现分页机制使我们的程序更加细腻了,运行的粒度是页而不是整个进程,大大提高了效率。

页表

上面说到虚拟内存到物理内存有个映射,这个映射我们知道是MMU做的,但是它是如何实现的?最简单的办法就是需要有一张类似hash表的结构来查看,比如页面1对应的页框是10,那么就记录成hash[1]=10,但是这仅仅是定位到了页框,具体的位置还没定位到,也就是类似偏移量的数据没有。不猜了,我们直接来看看MMU是如何做到的,以一个16位的虚拟地址,并且页面和页框都是4K的情况来说,MMU会把前4位当作是索引,也就是定位到页框的序号,后12位作为偏移量,这里为什么是12位,很巧妙,因为2^12=4K,正好给每个页框里的数据上了个标号。因此我们只需要根据前4位找到对应的页框即可,然后偏移量就是后12位。找页框就是去我们即将要说的页表里去找,页表除了有页面对应的页框后,还有个标志位来表示对应的页面是否有映射到对应的页框,缺页中断就是根据这个标志位来的。

可以看出页表非常关键,不仅仅要知道页框、以及是否缺页,其实页表还有保护位、修改位、访问位和高速缓存禁止位。

  • 保护位:指的是一个页允许什么类型的访问,常见的是用三个比特位分别表示读、写、执行。
  • 修改位:有时候也称为脏位,由硬件自动设置,当一个页被修改后,也就是和磁盘的数据不一致了,那么这个位就会被标记为1,下次在页框置换的时候,需要把脏页刷回磁盘,如果这个页的标记为0,说明没有被修改,那么不需要刷回磁盘,直接把数据丢弃就行了。
  • 访问位:当一个页面不论是发生读还是发生写,该页面的访问位都会设置成1,表示正在被访问,它的作用就是在发生缺页中断时,根据这个标志位优先在那些没有被访问的页面中选择淘汰其中的一个或者多个页框。
  • 高速缓存禁止位:对于那些映射到设备寄存器而不是常规内存的页面而言,这个特性很重要,加入操作系统正在紧张的循环等待某个IO设备对它刚发出的指令做出响应,保证这个设备读的不是被高速缓存的副本非常重要。

TLB快表加速访问

通过页表我们可以很好的实现虚拟地址到物理地址的转换,然而现代计算机至少是32位的虚拟地址,以4K为一页来说,那么对于32位的虚拟地址,它的页表项就有2^20=1048576个,无论是页表本身的大小还是检索速度,这个数字其实算是有点大了。如果是64位虚拟的地址,按照这种方式的话,页表项将大到超乎想象,更何况最重要的是每个进程都会有一个这样的页表。

我们知道如果每次都要在庞大的页表里面检索页框的话,效率一定不是很高。而且计算机的设计者们观察到这样一种现象:大多数程序总是对少量的页进行多次访问,如果能为这些经常被访问的页单独建立一个查询页表,那么速度就会大大提升,这就是快表,快表只会包含少量的页表项,通常不会超过256个,当我们要查找一个虚拟地址的时候。首先会在快表中查找,如果能找到那么就可以直接返回对应的页框,如果找不到才会去页表中查找,然后从快表中淘汰一个表项,用新找到的页替代它。

总体来说,TLB类似一个体积更小的页表缓存,它存放的都是最近被访问的页,而不是所有的页。

多级页表

TLB虽然一定程度上可以解决转换速度的问题,但是没有解决页表本身占用太大空间的问题。其实我们可以想想,大部分程序会使用到所有的页面吗?其实不会。一个进程在内存中的地址空间一般分为程序段、数据段和堆栈段,堆栈段在内存的结构上是从高地址向低地址增长的,其他两个是从低地址向高地址增长的。

可以发现中间部分是空的,也就是这部分地址是用不到的,那我们完全不需要把中间没有被使用的内存地址也引入页表呀,这就是多级页表的思想。以32位地址为例,后12位是偏移量,前20位可以拆成两个10位,我们暂且叫做顶级页表和二级页表,每10位可以表示2^10=1024个表项,因此它的结构大致如下:


对于顶级页表来说,中间灰色的部分就是没有被使用的内存空间。顶级页表就像我们身份证号前面几个数字,可以定位到我们是哪个城市或者县的,二级页表就像身份证中间的数字,可以定位到我们是哪个街道或者哪个村的,最后的偏移量就像我们的门牌号和姓名,通过这样的分段可以大大减少空间,我们来看个简单的例子:

如果我们不拆出顶级页表和二级页表,那么所需要的页表项就是2^20个,如果我们拆分,那么就是1个顶级页表+2^10个二级页表,两者的存储差距明显可以看出拆分后更加节省空间,这就是多级页表的好处。

当然我们的二级也可以拆成三级、四级甚至更多级,级数越多灵活性越大,但是级数越多,检索越慢,这一点是需要注意的。


最后

为了便于大家理解,本文画了20张图,肝了将近7000多字,创作不易,各位的三连就是对作者最大的支持,也是作者最大的创作动力。

微信搜一搜【假装懂编程】,与作者共同学习,共同进步。

往期精彩:
  • 简单!代码原来是这样被CPU跑起来的
  • 20张图!常见分布式理论与解决方案
  • 小心陷入MySQL索引的坑

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

系统学Java新特性-Lambda表达式

发表于 2021-11-02

这是我参与11月更文挑战的第2天,活动详情查看:2021最后一次更文挑战

阅读《Java实战》一书,本文为第3章总结,主要系统梳理Lambda表达式的来龙去脉,以及如何灵活使用。

1、Lambda语法

1.1 特性

作为简洁的可传递匿名函数,没有名词,但又参数列表、函数主体、返回类型,主要有以下几个特点:

  • 匿名:没有声明名称。
  • 函数:一种函数,不像方法归属于某个类,但和方法一样,有参数列表、函数主体、返回类型。
  • 传递:Lambda表达式可以作为参数传递给方法或者存储在变量中。
  • 简洁:相比匿名内部类,不需要很多结构性模板代码。

1.2 语法结构

Lambda表达式主要有三部分构成,参数列表、箭头以及Lambda主体。

  • 参数列表:可以是多个也可以是无参。
  • 箭头:用来分隔参数列表和Lambda主体。
  • Lambda主体:一般分为表达式风格(单行代码)和块风格(多行)。

常见示例:

1
2
3
4
5
6
7
8
9
10
11
12
java复制代码//参数一个String,返回int
(String s)-> s.length();
//一个对象类型参数,返回boolean
(Apple a)-> a.getWeight()>150;
// 无参,返回一个对象
()-> new Apple(10);
// 无返回值,消费对象,块风格主体
(Apple a)-> {
System.out.println(a.getWeigth());
}
// 两个参数,返回int,对象比较
(Apple a1,Apple a2)-> a1.getWeight().compareTo(a2.getWeight());

2、Lambda如何使用上

在什么场景下,又如何使用Lambda,对应着两个概念,函数式接口+函数描述符。

2.1 函数式接口

  • 只定义一个抽象方法得接口。
  • Lambda本质是用内联形式为函数式接口的抽象方法提供实现,一个完整表达式==函数式接口的一个具体实现实例。因此,有函数式接口的方法或者参数,就可以用lambda来提供具体实现。

2.2 函数描述符

函数式接口是Lambda使用基础,而如何使用以及怎么使用,就由其内部抽象方法签名(方法参数+返回值)决定,因此函数式接口的抽象方法,也就是函数描述符,也是Lambda表达式的签名。

2.3 @FunctionalInterface注解

函数式接口建议加上该注解,主要用来声明该接口为函数式接口,编译器会检测该接口是否有且只有声明一个抽象方法。

3、Lambda实战

环绕执行模式是生产中常见的场景,比如资源处理流程’打开一个资源>做一些处理>关闭资源’,其中打开和关闭总是很类似,而中间处理有比较多变,很适合Lambda表达式使用,因此我们以此为例,演示如何将一个传统方法扩展成支持多变Lambda场景:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
java复制代码    // 读取文件一行数据,演示一个传统的流程,如何改造成支持lambda的流程
// 传统实现:固定写死逻辑
public static String processFile() throws IOException {
try (BufferedReader br = new BufferedReader(new FileReader(filePath))){
return br.readLine();
}
}
//基于Lambda扩展
//1、行为参数化:
//1.1 引入函数式接口
@FunctionalInterface
interface ProcessFilePredicate{
String process(BufferedReader br) throws IOException ;
}
//1.2 用函数式接口传统行为
public static String processFile(ProcessFilePredicate p) throws IOException {
try (BufferedReader br = new BufferedReader(new FileReader(filePath))){
return p.process(br);
}
}

public static void main(String[] args) throws IOException {
System.out.println(processFile());
System.out.println("---");
// 基于Lambda的灵活使用
String oneLine = processFile((BufferedReader br) -> br.readLine());
System.out.println(oneLine);
System.out.println("---");
String twoLine = processFile((BufferedReader br) -> br.readLine()+br.readLine());
System.out.println(twoLine);
}

总体核心要素是方法通过相应函数式接口,实现行为参数化,使用时再按需通过Lambda表达式快速实现相应的行为定义,完整代码地址。

4、Java8自带函数式接口

为了应用不同的Lambda表达式的需求,Java8已经在java.util.function下定义了常见的一些函数式接口实现,主要可分为三大类:

  • 四种常见类型:Predicate<T>、Consumer<T>、Function<T,R>、Supplier<T>
  • 基础类型特化:IntPredicate、LongPredicate等处理特定具体基础数据类型参数的接口,避免数据类型拆箱、装箱损耗
  • 两个参数的扩展:BiPredicate<T,U>、BiConsumer<T,U>、BiFunction<T,U,R>

4.1 Predicate

  • 抽象方法: boolean test(T t),接受一个泛型参数,并返回boolean。
  • 函数描述符: T -> boolean
  • 适用场景:表示一个涉及类型T的布尔表达式
1
2
3
4
5
java复制代码// 一个参数的boolean值函数
@FunctionalInterface
public interface Predicate<T> {
boolean test(T t);
}

4.2 Consumer

  • 抽象方法:void accept(T t)接受一个泛型参数,没有返回值。
  • 函数描述符: T -> void。
  • 适用场景:访问泛型T的对象,只对其执行某些操作,却不需要返回。

4.3 Function<T,R>

  • 抽象方法:R apply(T t) 接受一个泛型T的对象,返回一个泛型R的对象。
  • 函数描述符:T -> R。
  • 适用场景:输入对象映射输出到新对象。

5、Lambda底层机制和规则

前面介绍了怎么用、如何用,接下来进一步深入理解编译器如何处理,以及一些现有的规则。

Lambda的表达式等价于一个函数式接口的实例,但本身却没有包含具体接口信息,因此需要上下文中推测出所需要的类型,也称为目标类型,推断方式主要有三种:

  • 方法调用的上下文(参数和返回值)
  • 赋值的上下文
  • 类型转换的上下文

5.1 类型检查

类型检查主要工作内容:

  • 通过调用方法上下文匹配,找出目标类型。
  • 通过目标类型的抽象方法,推测出对应函数描述符。
  • 解析Lambda表达式签名,与目标类型的函数描述符做匹配检查。

详细流程示例如下图:

image-20211102163900601.png

5.2 同样Lambda不同的函数式接口

有了目标类型概念,同一个表达式就可以通过赋值的方式和不同的函数式接口联系起来,只要它们表达式兼容。如下:

1
2
3
4
5
6
7
8
9
10
java复制代码//示例1:
Callable<Integer> c = () -> 42; //赋值目标对象 Callable<T>
PrivilegedAction<Integer> p = () -> 42;//赋值目标对象 PrivilegedAction<T>
// 示例2:
Comparator<Apple> c1 =
(Apple a1, Apple a2) -> a1.getWeight().compareTo(a2.getWeight());
ToIntBiFunction<Apple, Apple> c2 =
(Apple a1, Apple a2) -> a1.getWeight().compareTo(a2.getWeight());
BiFunction<Apple, Apple, Integer> c3 =
(Apple a1, Apple a2) -> a1.getWeight().compareTo(a2.getWeight());

5.3 参数类型推断

编译器会从上下文(目标类型)推断出匹配Lambda表达式的函数式接口,也就意味着能推断出Lambda的签名,因此函数描述符可以通过目标类型得到,因此可以进一步省略类型,示例如下:

1
2
3
4
5
6
java复制代码//原始格式
Comparator<Apple> c =
(Apple a1, Apple a2) -> a1.getWeight().compareTo(a2.getWeight());
//不声明类型,编译器会自动推断出来
Comparator<Apple> c =
(a1, a2) -> a1.getWeight().compareTo(a2.getWeight());

5.4 局部变量使用限制

Lambda表达式本质和匿名函数类似,因此使用外部变量时,必须是final类型或者事实上final(lambda使用后,后面不可再变更),否则会编译报错,提示如下:

image-20211102171046259.png

该限制的本质原因:

  • 实例变量存储堆中,局部变量保存在栈上。lambda使用的是其副本,而非原始,变更不可见,会导致误解,因此直接禁止变更(Lambda对值封闭,而不是对变量封闭)。
  • 不鼓励使用改变外部变量的典型命令式编程模式。

6、方法引用

6.1 定义

重复使用方法的定义,并像Lambda一样传递它们。

  • 基础思想:一个Lambda只是描述’直接调用这个方法’,最好名称来调用,而不是去描述如何调用,因为前者可读性更好。
  • 基础格式:目标引用放分隔符::前,方法名称放后面。

6.2 构建场景

方法引用主要有三类:

  • 指向静态方法的方法引用:如Integer的parseInt方法,写作Integer::parseInt
  • 任意类型的实例方法引用:如String的length方法,写作String::length
  • 指向现有对象的实例方法:如局部变量exp的getValue方法,可写作exp::getValue

此外,还有有针对构造函数、数组构造函数和父类调用(super-call)的一些特殊形式的方法引用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
java复制代码//示例1:
// Lambda表示式
(args) -> ClassaName.staticMethod(args)
//相应方法引用写法
ClassName::staticMethod

//示例2:
// Lambda表示式(arg0是ClassName类型实例)
(arg0,rest) -> arg0.instanceMethod(rest)
//相应方法引用写法
ClassName::instanceMethod

//示例3:
// Lambda表示式
(args) -> expr.instanceMethod(args)
//相应方法引用写法
expr::instanceMethod

6.2 构造函数引用

可以按构造函数的类型,选择相应的抽象方法来实现。

  • 无参构造器,与Supplier<T>签名()-> T一致。
1
2
3
4
5
6
7
java复制代码//方法引用实现
Supplier<Apple> c1 = Apple::new;
Apple a1 = c1.get()

//等价的Lambda实现
Supplier<Apple> c1 = () -> new Apple();
Apple a1 = c1.get()
  • 一个参数,与Function<T,R>函数描述符 T -> R一致。
1
2
3
4
5
6
7
java复制代码//方法引用实现
Function<Integer,Apple> c1 = Apple::new;
Apple a1 = c1.apply(100);

//等价的Lambda实现
Function<Integer,Apple> c1 = (weight) -> new Apple(weight);
Apple a1 = c1.apply(100);
  • 两个参数,与BiFunction<T,U,R>签名一致
1
2
3
4
5
6
7
java复制代码//方法引用实现
BiFunction<String,Integer,Apple> c1 = Apple::new;
Apple a1 = c1.apply(Color.RED,100);

//等价的Lambda实现
BiFunction<String,Integer,Apple> c1 = (color,weight) -> new Apple(color,weight);
Apple a1 = c1.apply(Color.RED,100);

7、复合Lambda表达式的常见方法

主要是用于将多个简单得Lambda复合成复杂的表达式,主要包含比较器复合、Predicate复合、函数复合。

7.1 比较器的复合

  • 逆序
1
java复制代码inventory.sort(comparing(Apple::getWeight).reversed());
  • 比较器链:主要用于解决多级比较,比如苹果按重量递减排序,相同重量,再按国家排序
1
2
3
4
java复制代码inventory.sort(comparing(Apple::getWeight)
.reversed()
.thenComparing(Apple::getCountry)
);

7.2 Predicate复合

主要有negate-非、and-与、or-或三种复合操作。

1
2
3
4
5
6
7
8
9
java复制代码//红色的苹果
Predicate<Apple> redApple = (a) -> Color.RED.equals(a.getColor());
//非红色的苹果
Predicate<Apple> notRedApple = redApple.negate();
//红色且重量大于150
Predicate<Apple> redAndHeavyApple = redApple.and(a -> a.getWeight() > 150);
//重量>150的红苹果,或者绿苹果
Predicate<Apple> redAndHeavyAppleOrGreen = redApple.and(a -> a.getWeight() > 150)
.or(a -> "green".equals(a.getColor()));

7.3 Function复合

Function接口配置了andThen和compose两个默认方法,用来将其复合。

  • andThen:先计算自身,再计算参数的函数
  • compose:先计算参数函数,结果作为参数,再计算自身
1
2
3
4
5
6
7
8
9
10
11
12
java复制代码        Function<Integer,Integer> f = (x) -> x+1;
Function<Integer,Integer> g = (x) -> x*2;

//andThen测试
Function<Integer,Integer> h1 = f.andThen(g);
// 结果:4 (先执行f的+1,再执行g的*2)
System.out.println(h1.apply(1));

//compose测试
Function<Integer,Integer> h2 = f.compose(g);
// 结果:3 (先执行g的*2,再执行f的+1)
System.out.println(h2.apply(1));

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

1…433434435…956

开发者博客

9558 日志
1953 标签
RSS
© 2025 开发者博客
本站总访问量次
由 Hexo 强力驱动
|
主题 — NexT.Muse v5.1.4
0%