Java核心技术36讲-扩展部分

目录

Docker和Java

注入攻击

从应用角度看安全

后台服务变慢

Lambda 能让 Java 程序慢 30 倍

JVM优化

数据库

Spring 

Netty

分布式ID设计方案


Docker和Java

Docker之类的容器和虚拟机非常相似,也有自己的shell,能独立安装软件包,运行时与其他容器互不干扰,但Docker并不是一种虚拟化技术,而是一种轻量级的隔离技术


对于 Java 平台来说,这些未隐藏的底层信息带来了很多意外的困难,主要体现在几个方面:

  • 第一,容器环境对于计算资源的管理方式是全新的,CGroup 作为相对比较新的技术,历史版本的 Java 显然并不能自然地理解相应的资源限制。
  • 第二,namespace 对于容器内的应用细节增加了一些微妙的差异,比如 jcmd、jstack 等工具会依赖于“/proc//”下面提供的部分信息,但是 Docker 的设计改变了这部分信息的原有结构,我们需要对原有工具进行修改以适应这种变化。

JVM 会大概根据检测到的内存大小,设置最初启动时的堆大小为系统内存的 1/64;并将堆最大值,设置为系统内存的 1/4。
而 JVM 检测到系统的 CPU 核数,则直接影响到了 Parallel GC 的并行线程数目和 JIT complier 线程数目,甚至是我们应用中 ForkJoinPool 等机制的并行等级。
这些默认参数,是根据通用场景选择的初始值。但是由于容器环境的差异,Java 的判断很可能是基于错误信息而做出的。这就类似,我以为我住的是整栋别墅,实际上却只有一个房间是给我住的。

更加严重的是,JVM 的一些原有诊断或备用机制也会受到影响。为保证服务的可用性,一种常见的选择是依赖“-XX:OnOutOfMemoryError”功能,通过调用处理脚本的形式来做一些补救措施,比如自动重启服务等。但是,这种机制是基于 fork 实现的,当 Java 进程已经过度提交内存时,fork 新的进程往往已经不可能正常运行了。

针对这种情况,JDK 9 中引入了一些实验性的参数,以方便 Docker 和 Java“沟通”,例如针对内存限制,可以使用下面的参数设置:

-XX:+UnlockExperimentalVMOptions
-XX:+UseCGroupMemoryLimitForHeap

如果你可以切换到 JDK 10 或者更新的版本,问题就更加简单了。Java 对容器(Docker)的支持已经比较完善,默认就会自适应各种资源限制和实现差异。前面提到的实验性参数“UseCGroupMemoryLimitForHeap”已经被标记为废弃。
与此同时,新增了参数用以明确指定 CPU 核心的数目。

-XX:ActiveProcessorCount=N

如果实践中发现有问题,也可以使用“-XX:-UseContainerSupport”,关闭 Java 的容器支持特性,这可以作为一种防御性机制
JDK9的一些特性已经移植到JDK8中了

对于不能升级JDK的建议:
明确设置堆、元数据区等内存区域大小,保证 Java 进程的总大小可控。
可能在环境中,这样限制容器内存:

$ docker run -it --rm --name yourcontainer -p 8080:8080 -m 800M repo/your-java-container:openjdk

那么,就可以额外配置下面的环境变量,直接指定 JVM 堆大小。

-e JAVA_OPTIONS='-Xmx300m'

明确配置 GC 和 JIT 并行线程数目,以避免二者占用过多计算资源。

-XX:ParallelGCThreads
-XX:CICompilerCount

在很多场景中还发现 Java 在 Docker 环境中,似乎会意外使用 Swap。具体原因待查,但很有可能也是因为 Ergonomics 机制失效导致的,建议配置下面参数,明确告知 JVM 系统内存限额。

-XX:MaxRAM=`cat /sys/fs/cgroup/memory/memory.limit_in_bytes`

也可以指定 Docker 运行参数,例如:

--memory-swappiness=0

 

 

 

注入攻击

  • SQL注入
  • 输入的字符串拼接成操作系统命令注入
  • XML注入
  • LDAP等允许动态内容的协议也会被注入
  • XSS

Java API和工具构成了Java安全基础
1.运行时安全机制,限制Java运行时的行为,不要坐越权或者不靠谱的事情

  • 类加载过程中的字节码校验防止不合法的代码
  • 类加载器本身也可以对代码之间进行隔离
  • SecurityManager机制和相关组件,限制代码的运行时行为
  • Java的GC等资源回收机制也算作运行时安全的一部分,如果相应机制失效会导致OOM,就是拒绝服务了


2.提供的安全框架API

  • 加密,解密API
  • 授权,鉴权API
  • 安全通讯相关的类库,如基本HTTPS通讯协议相关标准实现 TLS1.3,或复数的类似证书撤销状态判断OSCP等协议

3.JDK集成的各种安全工具

  • keytool,可以管理安全场景中不可缺的密码,证书等,还有java的keystore文件
  • jarsigner,用于对jar文件进行签名和验证
  • 老版本的SecurityManager有10%--15%的性能损失

 

 

从应用角度看安全

需要重视的是程序级别的攻击,
也就是利用 Java、JVM 或应用程序的瑕疵,进行低成本的 DoS 攻击,这也是想要写出安全的 Java 代码所必须考虑的。例如:

  • 如果使用的是早期的 JDK 和 Applet 等技术,攻击者构建合法但恶劣的程序就相对容易,例如,将其线程优先级设置为最高,做一些看起来无害但空耗资源的事情。幸运的是类似技术已经逐步退出历史舞台,在 JDK 9 以后,相关模块就已经被移除。
  • 哈希碰撞攻击,就是个典型的例子,对方可以轻易消耗系统有限的 CPU 和线程资源。从这个角度思考,类似加密、解密、图形处理等计算密集型任务,都要防范被恶意滥用,以免攻击者通过直接调用或者间接触发方式,消耗系统资源。
  • 利用 Java 构建类似上传文件或者其他接受输入的服务,需要对消耗系统内存或存储的上限有所控制,因为我们不能将系统安全依赖于用户的合理使用。其中特别注意的是涉及解压缩功能时,就需要防范Zip bomb等特定攻击。
  • Java 程序中需要明确释放的资源有很多种,比如文件描述符、数据库连接,甚至是再入锁,任何情况下都应该保证资源释放成功,否则即使平时能够正常运行,也可能被攻击者利用而耗尽某类资源,这也算是可能的 DoS 攻击来源。

针对序列化,通常建议:

  • 敏感信息不要被序列化!在编码中,建议使用 transient 关键字将其保护起来。
  • 反序列化中,建议在 readObject 中实现与对象构件过程相同的安全检查和数据检查。
  • 在 JDK 9 中,Java 引入了过滤器机制,以保证反序列化过程中数据都要经过基本验证才可以使用。其原理是通过黑名单和白名单,限定安全或者不安全的类型,并且你可以进行定制,然后通过环境变量灵活进行配置, 更加具体的可以参考 ObjectInputFilter
     


开发和测试阶段

落实到实际开发流程中,以 OpenJDK 团队为例,我们应用了几个不同角度的实践:

  • 在早期设计阶段,就由安全专家组对新特性进行风险评估。
  • 开发过程中,尤其是 code review 阶段,应用 OpenJDK 自身定制的代码规范。
  • 利用多种静态分析工具如FindBugs、Parfait等,帮助早期发现潜在安全风险,并对相应问题采取零容忍态度,强制要求解决。
  • 甚至 OpenJDK 会默认将任何(编译等)警告,都当作错误对待,并体现在 CI 流程中。
  • 在代码 check-in 等关键环节,利用 hook 机制去调用规则检查工具,以保证不合规代码不能进入 OpenJDK 代码库。
     


部署阶段

  • JDK 自身的也是个软件,难免会存在实现瑕疵,我们平时看到 JDK 更新的安全漏洞补丁,其实就是在修补这些漏洞。我最近还注意到,某大厂后台被曝出了使用的 JDK 版本存在序列化相关的漏洞。类似这种情况,大多数都是因为使用的 JDK 是较低版本,算是可以通过部署解决的问题。
  • 如果是安全敏感型产品,可关注 JDK 在加解密方面的路线图,同样的标准也应用于其他语言和平台,很多早期认为非常安全的算法,已经被攻破,及时地升级基础软件是安全的必要条件。
  • 攻击和防守是不对称的,只要有一个严重漏洞,对于攻击者就足够了,所以,不能对黑盒形式的部署心存侥幸,这并不能保证系统的安全,攻击者可以利用对软件设计的猜测,结合一系列手段,探测出漏洞。
     

 

 


后台服务变慢

服务是突然变慢还是长时间运行后观察到变慢?类似问题是否重复出现?
“慢”的定义是什么,我能够理解是系统对其他方面的请求的反应延时变长吗?

可能是java自身原因,可能是外部原因
记住java自身的工具,加上系统工具,还有Profiling进行监控

Charlie Hunt 曾将其方法论总结为两类:

  • 自上而下。从应用的顶层,逐步深入到具体的不同模块,或者更近一步的技术细节单元,找到可能的问题和解决办法。这是最常见的性能分析思路,也是大多数工程师的选择。
  • 自下而上。从类似 CPU 这种硬件底层,判断类似Cache-Miss之类的问题和调优机会,出发点是指令级别优化。这往往是专业的性能工程师才能掌握的技能,并且需要专业工具配合,大多数是移植到新的平台上,或需要提供极致性能时才会进行。

JVM层面的性能分析

  • 利用 JMC、JConsole 等工具进行运行时监控。
  • 利用各种工具,在运行时进行堆转储分析,或者获取各种角度的统计数据(如jstat -gcutil 分析 GC、内存分带等)。
  • GC 日志等手段,诊断 Full GC、Minor GC,或者引用堆积等。
  • 这里并不存在放之四海而皆准的办法,具体问题可能非常不同,还要看你是否能否充分利用这些工具,从种种迹象之中,逐步判断出问题所在。
  • 对于应用Profiling,简单来说就是利用一些侵入性的手段,收集程序运行时的细节,以定位性能问题瓶颈。所谓的细节,就是例如内存的使用情况、最频繁调用的方法是什么,或者上下文切换的情况等。

生产环境的监控
建议使用 JFR 配合JMC来做 Profiling,因为它是从 Hotspot JVM 内部收集底层信息,并经过了大量优化,性能开销非常低,通常是低于 2% 的;并且如此强大的工具,也已经被 Oracle 开源出来!

不需要重新启动系统或者提前增加配置。例如,你可以在运行时启动 JFR 记录,并将这段时间的信息写入文件:

//首先打开
jcmd 10833 VM.unlock_commercial_features
10833:
Commercial Features now unlocked.

//记录
jcmd 10833 JFR.start duration=120s filename=myrecording.jfr 
10833:
Started recording 1. The result will be written to:

/root/java/myrecording.jfr

然后,使用 JMC 打开“.jfr 文件”就可以进行分析了,方法、异常、线程、IO 等应有尽有,其功能非常强大
 

 

 

Lambda 能让 Java 程序慢 30 倍

这个争论实际反映了几个方面:

  1. 基准测试是一个非常有效的通用手段,让我们以直观、量化的方式,判断程序在特定条件下的性能表现。
  2. 基准测试必须明确定义自身的范围和目标,否则很有可能产生误导的结果。前面代码片段本身的逻辑就有瑕疵,更多的开销是源于自动装箱、拆箱(auto-boxing/unboxing),而不是源自 Lambda 和 Stream,所以得出的初始结论是没有说服力的。
  3. 虽然 Lambda/Stream 为 Java 提供了强大的函数式编程能力,但是也需要正视其局限性:


一般来说,我们可以认为 Lambda/Stream 提供了与传统方式接近对等的性能,但是如果对于性能非常敏感,就不能完全忽视它在特定场景的性能差异了,例如:初始化的开销。 Lambda 并不算是语法糖,而是一种新的工作机制,在首次调用时,JVM 需要为其构建CallSite实例。这意味着,如果 Java 应用启动过程引入了很多 Lambda 语句,会导致启动过程变慢。其实现特点决定了 JVM 对它的优化可能与传统方式存在差异。
增加了程序诊断等方面的复杂性,程序栈要复杂很多,Fluent 风格本身也不算是对于调试非常友好的结构,并且在可检查异常的处理方面也存在着局限性等。

当需要对一个大型软件的某小部分的性能进行评估时,就可以考虑微基准测试。换句话说,微基准测试大多是 API 级别的验证,或者与其他简单用例场景的对比,例如:

  • 你在开发共享类库,为其他模块提供某种服务的 API 等。
  • 你的 API 对于性能,如延迟、吞吐量有着严格的要求,例如,实现了定制的 HTTP 客户端 API,需要明确它对 HTTP 服务器进行大量 GET 请求时的吞吐能力,或者需要对比其他 API,保证至少对等甚至更高的性能标准。
  • 所以微基准测试更是偏基础、底层平台开发者的需求,当然,也是那些追求极致性能的前沿工程师的最爱。
  • 目前应用最为广泛的框架之一就是JMH,OpenJDK 自身也大量地使用 JMH 进行性能对比,如果你是做 Java API 级别的性能对比,JMH 往往是你的首选。

JMH 是由 Hotspot JVM 团队专家开发的,除了支持完整的基准测试过程,包括预热、运行、统计和报告等,还支持 Java 和其他 JVM 语言。更重要的是,它针对 Hotspot JVM 提供了各种特性,以保证基准测试的正确性,整体准确性大大优于其他框架,并且,JMH 还提供了用近乎白盒的方式进行 Profiling 等工作的能力。

使用 JMH 也非常简单,你可以直接将其依赖加入 Maven 工程,如下图:


也可以,利用类似下面的命令,直接生成一个 Maven 项目。

$ mvn archetype:generate \
      -DinteractiveMode=false \
      -DarchetypeGroupId=org.openjdk.jmh \
          -DarchetypeArtifactId=jmh-java-benchmark-archetype \
      -DgroupId=org.sample \
      -DartifactId=test \
      -Dversion=1.0

JMH 利用注解(Annotation),定义具体的测试方法,以及基准测试的详细配置。例如,至少要加上“@Benchmark”以标识它是个基准测试方法,而 BenchmarkMode 则指定了基准测试模式,例如下面例子指定了吞吐量(Throughput)模式,还可以根据需要指定平均时间(AverageTime)等其他模式。

@Benchmark
@BenchmarkMode(Mode.Throughput)
public void testMethod() {
   // Put your benchmark code here.
}


当我们实现了具体的测试后,用 mvn clean install 命令构建。
运行基准测试则与运行不同的 Java 应用没有明显区别。

java -jar target/benchmarks.jar

微基准测试的坑

  • 首先,构建微基准测试,需要从白盒层面理解代码,尤其是具体的性能开销,不管是 CPU 还是内存分配。这有两个方面的考虑,第一,需要保证我们写出的基准测试符合测试目的,确实验证的是我们要覆盖的功能点,这一讲的问题就是个典型例子;第二,通常对于微基准测试,我们通常希望代码片段确实是有限的,例如,执行时间如果需要很多毫秒(ms),甚至是秒级,那么这个有效性就要存疑了,也不便于诊断问题所在
  • 保证代码经过了足够并且合适的预热
  • 防止 JVM 进行无效代码消除(Dead Code Elimination)
  • 保证每个函数都有返回值,或者用JMH提供的BlackHole设施
  • 防止发生常量折叠,如果JVM发现计算过程依赖于常量可能会直接计算结果,用JMH提供的State机制解决
  • JMH会对State对象进行额外的处理,消除伪共享的影响
  • 防止GC的影响,用Serial或者JDK11的Epsilon GC

 

JVM优化

JVM 在对代码执行的优化可分为运行时(runtime)优化和即时编译器(JIT)优化

  • 运行时优化主要是解释执行和动态编译通用的一些机制,比如说锁机制(如偏斜锁)、内存分配机制(如 TLAB)等。除此之外,还有一些专门用于优化解释执行效率的,比如说模版解释器、内联缓存(inline cache,用于优化虚方法调用的动态绑定)
  • JVM 的即时编译器优化是指将热点代码以方法为单位转换成机器码,直接运行在底层硬件之上。它采用了多种优化方式,包括静态编译器可以使用的如方法内联、逃逸分析,也包括基于程序运行 profile 的投机性优化(speculative/optimistic optimization)。

从整体的角度来看看 Java 代码的整个生命周期


Java 通过引入字节码这种中间表达方式,屏蔽了不同硬件的差异,由 JVM 负责完成从字节码到机器码的转化。
javac 优化与 JVM 内部优化也存在关联,毕竟它负责了字节码的生成。例如,Java 9 中的字符串拼接,会被 javac 替换成对 StringConcatFactory 的调用,进而为 JVM 进行字符串拼接优化提供了统一的入口。在实际场景中,还可以通过不同的策略选项来干预这个过程。

在通常情况下,编译器和解释器是共同起作用的,具体流程可以参考下面的示意图


解释器和编译器也会进行一些通用优化

  • 锁优化
  • Intrinsic 机制,或者叫作内建方法,就是针对特别重要的基础方法,JDK 团队直接提供定制的实现,利用汇编或者编译器的中间表达方式编写,然后 JVM 会直接在运行时进行替换。
  • 这么做的理由有很多,例如,不同体系结构的 CPU 在指令等层面存在着差异,定制才能充分发挥出硬件的能力。我们日常使用的典型字符串操作、数组拷贝等基础方法,Hotspot 都提供了内建实现。
  • 而即时编译器(JIT),则是更多优化工作的承担者。JIT 对 Java 编译的基本单元是整个方法,通过对方法调用的计数统计,甄别出热点方法,编译为本地代码。另外一个优化场景,则是最针对所谓热点循环代码,利用通常说的栈上替换技术(OSR,On-Stack Replacement,更加细节请参考R 大的文章),如果方法本身的调用频度还不够编译标准,但是内部有大的循环之类,则还是会有进一步优化的价值。
  • 从理论上来看,JIT 可以看作就是基于两个计数器实现,方法计数器和回边计数器提供给 JVM 统计数据,以定位到热点代码。实际中的 JIT 机制要复杂得多,郑博士提到了逃逸分析、循环展开、方法内联等,包括前面提到的 Intrinsic 等通用机制同样会在 JIT 阶段发生。
     

查看这些优化的方式

//打印编译发生的细节。
-XX:+PrintCompilation

//输出更多编译的细节。
-XX:UnlockDiagnosticVMOptions -XX:+LogCompilation -XX:LogFile=<your_file_path>

JVM 会生成一个 xml 形式的文件,另外, LogFile 选项是可选的,不指定则会输出到

hotspot_pid<pid>.log

具体格式可以参考 Ben Evans 提供的JitWatch工具和分析指南。

打印内联的发生,可利用下面的诊断选项,也需要明确解锁。

-XX:+PrintInlining

如何知晓 Code Cache 的使用状态呢?
很多工具都已经提供了具体的统计信息,比如,JMC、JConsole 之类,我也介绍过使用 NMT 监控其使用

调整热点代码门限值
server 模式默认 10000 次,client 是 1500 次。门限大小也存在着调优的可能,可以使用下面的参数调整;与此同时,该参数还可以变相起到降低预热时间的作用。

-XX:CompileThreshold=N

 JVM 会周期性的对计数的数值进行衰减操作,导致调用计数器永远不能达到门限值,除了可以利用 CompileThreshold 适当调整大小,还有一个办法就是关闭计数器衰减。

-XX:-UseCounterDecay

JVM 会生成一个 xml 形式的文件,另外, LogFile 选项是可选的,不指定则会输出到

hotspot_pid<pid>.log

具体格式可以参考 Ben Evans 提供的JitWatch工具和分析指南。

打印内联的发生,可利用下面的诊断选项,也需要明确解锁。

-XX:+PrintInlining

如何知晓 Code Cache 的使用状态呢?
很多工具都已经提供了具体的统计信息,比如,JMC、JConsole 之类


对于开发者需要调整的参数

调整热点代码门限值

-XX:CompileThreshold=N

JVM 会周期性的对计数的数值进行衰减操作,导致调用计数器永远不能达到门限值,除了可以利用 CompileThreshold 适当调整大小,还有一个办法就是关闭计数器衰减。

-XX:-UseCounterDecay

 

//如果你是利用 debug 版本的 JDK,但是生产版本是不支持这个选项的
-XX:CounterHalfLifeTime

//调整 Code Cache 大小
-XX:ReservedCodeCacheSize=<SIZE>

//调整初始大小
-XX:InitialCodeCacheSize=<SIZE>

在相对较新版本的 Java 中,由于分层编译(Tiered-Compilation)的存在,Code Cache 的空间需求大大增加,其本身默认大小也被提高了。

调整编译器线程数,或者选择适当的编译器模式
JVM 的编译器线程数目与我们选择的模式有关,选择 client 模式默认只有一个编译线程,而 server 模式则默认是两个,如果是当前最普遍的分层编译模式,则会根据 CPU 内核数目计算 C1 和 C2 的数值,你可以通过下面的参数指定的编译线程数。

-XX:CICompilerCount=N

其他一些相对边界比较混淆的所谓“优化”
比如,减少进入安全点。严格说,它远远不只是发生在动态编译的时候,GC 阶段发生的更加频繁,你可以利用下面选项诊断安全点的影响。

-XX:+PrintSafepointStatistics ‑XX:+PrintGCApplicationStoppedTime

注意,在 JDK 9 之后,PrintGCApplicationStoppedTime 已经被移除了,你需要使用“-Xlog:safepoint”之类方式来指定。

很多优化阶段都可能和安全点相关,例如:

在 JIT 过程中,逆优化等场景会需要插入安全点。
常规的锁优化阶段也可能发生,比如,偏斜锁的设计目的是为了避免无竞争时的同步开销,但是当真的发生竞争时,撤销偏斜锁会触发安全点,是很重的操作。所以,在并发场景中偏斜锁的价值其实是被质疑的,经常会明确建议关闭偏斜锁。

-XX:-UseBiasedLocking

 

 

数据库

(Multi-Versioning Concurrency Control)和锁的复合实现,按照隔离程度从低到高,MySQL 事务隔离级别分为四个不同层次:

  • 读未提交(Read uncommitted),就是一个事务能够看到其他事务尚未提交的修改,这是最低的隔离水平,允许脏读出现。
  • 读已提交(Read committed),事务能够看到的数据都是其他事务已经提交的修改,也就是保证不会看到任何中间性状态,当然脏读也不会出现。读已提交仍然是比较低级别的隔离,并不保证再次读取时能够获取同样的数据,也就是允许其他事务并发修改数据,允许不可重复读和幻象读(Phantom Read)出现。
  • 可重复读(Repeatable reads),保证同一个事务中多次读取的数据是一致的,这是 MySQL InnoDB 引擎的默认隔离级别,但是和一些其他数据库实现不同的是,可以简单认为 MySQL 在可重复读级别不会出现幻象读。
  • 串行化(Serializable),并发事务之间是串行化的,通常意味着读取需要获取共享读锁,更新需要获取排他写锁,如果 SQL 使用 WHERE 语句,还会获取区间锁(MySQL 以 GAP 锁形式实现,可重复读级别中默认也会使用),这是最高的隔离级别。

数据库衍生出很多不同的职责方向:

  • 数据库管理员(DBA),这是一个单独的专业领域。
  • 数据库应用工程师,很多业务开发者就是这种定位,综合利用数据库和其他编程语言等技能,开发业务应用。
  • 数据库工程师,更加侧重于开发数据库、数据库中间件等基础软件。


从最广泛的应用开发者角度,至少需要掌握:

  • 数据库设计基础,包括数据库设计中的几个基本范式,各种数据库的基础概念,例如表、视图、索引、外键、序列号生成器等,清楚如何将现实中业务实体和其依赖关系映射到数据库结构中,掌握典型实体数据应该使用什么样的数据库数据类型等。
  • 每种数据库的设计和实现多少会存在差异,所以至少要精通你使用过的数据库的设计要点。我今天开篇谈到的 MySQL 事务隔离级别,就区别于其他数据库,进一步了解 MVCC、Locking 等机制对于处理进阶问题非常有帮助;还需要了解,不同索引类型的使用,甚至是底层数据结构和算法等。
  • 常见的 SQL 语句,掌握基础的 SQL 调优技巧,至少要了解基本思路是怎样的,例如 SQL 怎样写才能更好利用索引、知道如何分析SQL 执行计划等。
  • 更进一步,至少需要了解针对高并发等特定场景中的解决方案,例如读写分离、分库分表,或者如何利用缓存机制等,目前的数据存储也远不止传统的关系型数据库了。


Spring JDBC,mybatis,Hibernate

  • 从整体上把握主流框架的架构和设计理念,掌握主要流程,例如 SQL 解析生成、SQL 执行到结果映射等处理过程到底发生了什么。
  • 掌握映射等部分的细节定义和原理,根据我在准备专栏时整理的面试题目,发现很多题目都是偏向于映射定义的细节。
  • 另外,对比不同框架的设计和实现,既有利于你加深理解,也是面试考察的热点方向之一。
     

 

 

Spring 

Bean 生命周期比较复杂,可以分为创建和销毁两个过程。

首先,创建 Bean 会经过一系列的步骤,主要包括:

  • 实例化 Bean 对象。
  • 设置 Bean 属性。
  • 如果我们通过各种 Aware 接口声明了依赖关系,则会注入 Bean 对容器基础设施层面的依赖。具体包括 BeanNameAware、BeanFactoryAware 和 ApplicationContextAware,分别会注入 Bean ID、Bean Factory 或者 ApplicationContext。
  • 调用 BeanPostProcessor 的前置初始化方法 postProcessBeforeInitialization。
  • 如果实现了 InitializingBean 接口,则会调用 afterPropertiesSet 方法。
  • 调用 Bean 自身定义的 init 方法。
  • 调用 BeanPostProcessor 的后置初始化方法 postProcessAfterInitialization。
  • 创建过程完毕。

下面示意图理解这个具体过程和先后顺序。

第二,Spring Bean 的销毁过程会依次调用 DisposableBean 的 destroy 方法和 Bean 自身定制的 destroy 方法。
Spring Bean 有五个作用域,

  • Singleton,这是 Spring 的默认作用域,也就是为每个 IOC 容器创建唯一的一个 Bean 实例。
  • Prototype,针对每个 getBean 请求,容器都会单独创建一个 Bean 实例。
  • 从 Bean 的特点来看,Prototype 适合有状态的 Bean,而 Singleton 则更适合无状态的情况。另外,使用 Prototype 作用域需要经过仔细思考,毕竟频繁创建和销毁 Bean 是有明显开销的。
  • Request,为每个 HTTP 请求创建单独的 Bean 实例。
  • Session,很显然 Bean 实例的作用域是 Session 范围。
  • GlobalSession,用于 Portlet 容器,因为每个 Portlet 有单独的 Session,GlobalSession 提供一个全局性的 HTTP Session
     

Spring 的基础机制

  • 控制反转(Inversion of Control),或者也叫依赖注入(Dependency Injection),广泛应用于 Spring 框架之中,可以有效地改善了模块之间的紧耦合问题。从 Bean 创建过程可以看到,它的依赖关系都是由容器负责注入,具体实现方式包括带参数的构造函数、setter 方法或者AutoWired方式实现。
  • AOP,切面编程机制,Spring 框架中的事务、安全、日志等功能都依赖于 AOP 技术,

Spring生态系统

  • Spring Boot,通过整合通用实践,更加自动、智能的依赖管理等,Spring Boot 提供了各种典型应用领域的快速开发基础,所以它是以应用为中心的一个框架集合。
  • Spring Cloud,可以看作是在 Spring Boot 基础上发展出的更加高层次的框架,它提供了构建分布式系统的通用模式,包含服务发现和服务注册、分布式配置管理、负载均衡、分布式诊断等各种子系统,可以简化微服务系统的构建。
  • 还有针对特定领域的 Spring Security、Spring Data 等。

Spring AOP 引入了其他几个关键概念:

  • Aspect,通常叫作方面,它是跨不同 Java 类层面的横切性逻辑。在实现形式上,既可以是 XML 文件中配置的普通类,也可以在类代码中用“@Aspect”注解去声明。在运行时,Spring 框架会创建类似Advisor来指代它,其内部会包括切入的时机(Pointcut)和切入的动作(Advice)。
  • Join Point,它是 Aspect 可以切入的特定点,在 Spring 里面只有方法可以作为 Join Point。
  • Advice,它定义了切面中能够采取的动作。如果你去看 Spring 源码,就会发现 Advice、Join Point 并没有定义在 Spring 自己的命名空间里,这是因为他们是源自AOP 联盟,可以看作是 Java 工程师在 AOP 层面沟通的通用规范。
  • Java 核心类库中同样存在类似代码,例如 Java 9 中引入的 Flow API 就是 Reactive Stream 规范的最小子集,通过这种方式,可以保证不同产品直接的无缝沟通,促进了良好实践的推广。
     


BeforeAdvice 和 AfterAdvice 包括它们的子接口是最简单的实现。而 Interceptor 则是所谓的拦截器,用于拦截住方法(也包括构造器)调用事件,进而采取相应动作,所以 Interceptor 是覆盖住整个方法调用过程的 Advice。通常将拦截器类型的 Advice 叫作 Around,在代码中可以使用“@Around”来标记,或者在配置中使用“<aop:around>”。

如果从时序上来看,则可以参考下图,理解具体发生的时机

Pointcut,它负责具体定义 Aspect 被应用在哪些 Join Point,可以通过指定具体的类名和方法名来实现,或者也可以使用正则表达式来定义条件。
可以参看下面的示意图,来进一步理解上面这些抽象在逻辑上的意义

Join Point 仅仅是可利用的机会。
Pointcut 是解决了切面编程中的 Where 问题,让程序可以知道哪些机会点可以应用某个切面动作。
而 Advice 则是明确了切面编程中的 What,也就是做什么;同时通过指定 Before、After 或者 Around,定义了 When,也就是什么时候做。

 

 

Netty

Netty 在基础的 NIO 等类库之上进行了很多改进,例如:

  • 更加优雅的 Reactor 模式实现、灵活的线程模型、利用 EventLoop 等创新性的机制,可以非常高效地管理成百上千的 Channel。
  • 充分利用了 Java 的 Zero-Copy 机制,并且从多种角度,“斤斤计较”般的降低内存分配和回收的开销。例如,使用池化的 Direct Buffer 等技术,在提高 IO 性能的同时,减少了对象的创建和销毁;利用反射等技术直接操纵 SelectionKey,使用数组而不是 Java 容器等。
  • 使用更多本地代码。例如,直接利用 JNI 调用 Open SSL 等方式,获得比 Java 内建 SSL 引擎更好的性能。
  • 在通信协议、序列化等其他角度的优化。
  • 总的来说,Netty 并没有 Java 核心类库那些强烈的通用性、跨平台等各种负担,针对性能等特定目标以及 Linux 等特定环境,采取了一些极致的优化手段

Netty 的设计强调了 “Separation Of Concerns”,通过精巧设计的事件机制,将业务逻辑和无关技术逻辑进行隔离,并通过各种方便的抽象,一定程度上填补了了基础平台和业务开发之间的鸿沟,更有利于在应用开发中普及业界的最佳实践。

另外,Netty > java.nio + java. net!

从 API 能力范围来看,Netty 完全是 Java NIO 框架的一个大大的超集,可以参考 Netty 官方的模块划分。

Netty 还额外提供了很多功能,例如:

  • 从网络协议的角度,Netty 除了支持传输层的 UDP、TCP、SCTP协议,也支持 HTTP(s)、WebSocket 等多种应用层协议,它并不是单一协议的 API。
  • 在应用中,需要将数据从 Java 对象转换成为各种应用协议的数据格式,或者进行反向的转换,Netty 为此提供了一系列扩展的编解码框架,与应用开发场景无缝衔接,并且性能良好。
  • 它扩展了 Java NIO Buffer,提供了自己的 ByteBuf 实现,并且深度支持 Direct Buffer 等技术,甚至 hack 了 Java 内部对 Direct Buffer 的分配和销毁等。同时,Netty 也提供了更加完善的 Scatter/Gather 机制实现。
  • 可以看到,Netty 的能力范围大大超过了 Java 核心类库中的 NIO 等 API,可以说它是一个从应用视角出发的产物。

当然,对于基础 API 设计,Netty 也有自己独到的见解,未来 Java NIO API 也可能据此进行一定的改进,可以参考JDK-8187540

注意我用红框标记出的部分:

  • ServerBootstrap,服务器端程序的入口,这是 Netty 为简化网络程序配置和关闭等生命周期管理,所引入的 Bootstrapping 机制。我们通常要做的创建 Channel、绑定端口、注册 Handler 等,都可以通过这个统一的入口,以Fluent API 等形式完成,相对简化了 API 使用。与之相对应, Bootstrap则是 Client 端的通常入口。
  • Channel,作为一个基于 NIO 的扩展框架,Channel 和 Selector 等概念仍然是 Netty 的基础组件,但是针对应用开发具体需求,提供了相对易用的抽象。
  • EventLoop,这是 Netty 处理事件的核心机制。例子中使用了 EventLoopGroup。我们在 NIO 中通常要做的几件事情,如注册感兴趣的事件、调度相应的 Handler 等,都是 EventLoop 负责。
  • ChannelFuture,这是 Netty 实现异步 IO 的基础之一,保证了同一个 Channel 操作的调用顺序。Netty 扩展了 Java 标准的 Future,提供了针对自己场景的特有Future定义。
  • ChannelHandler,这是应用开发者放置业务逻辑的主要地方,也是我上面提到的“Separation Of Concerns”原则的体现。
  • ChannelPipeline,它是 ChannelHandler 链条的容器,每个 Channel 在创建后,自动被分配一个 ChannelPipeline。在上面的示例中,我们通过 ServerBootstrap 注册了 ChannelInitializer,并且实现了 initChannel 方法,而在该方法中则承担了向 ChannelPipleline 安装其他 Handler 的任务。

可以参考下面的简化示意图,忽略 Inbound/OutBound Handler 的细节,理解这几个基本单元之间的操作流程和对应关系

对比 Java 标准 NIO 的代码,Netty 提供的相对高层次的封装,减少了对 Selector 等细节的操纵,而 EventLoop、Pipeline 等机制则简化了编程模型,开发者不用担心并发等问题,在一定程度上简化了应用代码的开发。最难能可贵的是,这一切并没有以可靠性、可扩展性为代价,反而将其大幅度提高。

Netty 的一些实现原理问题需要注意:

  • Reactor 模式和 Netty 线程模型。
  • Pipelining、EventLoop 等部分的设计实现细节。
  • Netty 的内存管理机制、引用计数等特别手段。
  • 对比 Java 标准 NIO API,是否知道 Java NIO 早期版本中的 Epoll空转问题,以及 Netty 的解决方式等。
     

 

分布式ID设计方案

分布式 ID 定义,基本的要求包括:

  • 全局唯一,区别于单点系统的唯一,全局是要求分布式系统内唯一。
  • 有序性,通常都需要保证生成的 ID 是有序递增的。例如,在数据库存储等场景中,有序 ID 便于确定数据位置,往往更加高效。

目前业界的方案很多,典型方案包括:

  • 基于数据库自增序列的实现。这种方式优缺点都非常明显,好处是简单易用,但是在扩展性和可靠性等方面存在局限性。
  • 基于 Twitter 早期开源的Snowflake的实现,以及相关改动方案。这是目前应用相对比较广泛的一种方式,其结构定义可以参考下面的示意图。

 

  • 整体长度通常是 64 (1 + 41 + 10+ 12 = 64)位,适合使用 Java 语言中的 long 类型来存储。
  • 头部是 1 位的正负标识位。
  • 紧跟着的高位部分包含 41 位时间戳,通常使用 System.currentTimeMillis()。
  • 后面是 10 位的 WorkerID,标准定义是 5 位数据中心 + 5 位机器 ID,组成了机器编号,以区分不同的集群节点。
  • 最后的 12 位就是单位毫秒内可生成的序列号数目的理论极限。

Snowflake 的官方版本是基于 Scala 语言,Java 等其他语言的参考实现有很多,是一种非常简单实用的方式,具体位数的定义是可以根据分布式系统的真实场景进行修改的,并不一定要严格按照示意图中的设计。

  • Redis、Zookeeper、MongoDB 等中间件,也都有各种唯一 ID 解决方案。其中一些设计也可以算作是 Snowflake 方案的变种。例如,MongoDB 的ObjectId提供了一个 12 byte(96 位)的 ID 定义,其中 32 位用于记录以秒为单位的时间,机器 ID 则为 24 位,16 位用作进程 ID,24 位随机起始的计数序列。
  • 国内的一些大厂开源了其自身的部分分布式 ID 实现,InfoQ 就曾经介绍过微信的seqsvr,它采取了相对复杂的两层架构,并根据社交应用的数据特点进行了针对性设计,具体请参考相关代码实现。另外,百度、美团等也都有开源或者分享了不同的分布式 ID 实现,都可以进行参考。

从 Snowflake 的具体算法实现寻找答案。我们知道 Snowflake 算法的 Java 实现,大都是依赖于 System.currentTimeMillis(),这个数值代表什么呢?从 Javadoc 可以看出,它是返回当前时间和 1970 年 1 月 1 号 UTC 时间相差的毫秒数,这个数值与夏 / 冬令时并没有关系,所以并不受冬令时切换影响。


我们到底需要一个什么样的分布式 ID?

  • 除了唯一和有序,考虑到分布式系统的功能需要,通常还会额外希望分布式 ID 保证:
  • 有意义,或者说包含更多信息,例如时间、业务等信息。这一点和有序性要求存在一定关联,如果 ID 中包含时间,本身就能保证一定程度的有序,虽然并不能绝对保证。ID 中包含额外信息,在分布式数据存储等场合中,有助于进一步优化数据访问的效率。
  • 高可用性,这是分布式系统的必然要求。前面谈到的方案中,有的是真正意义上的分布式,有得还是传统主从的思路,这一点没有绝对的对错,取决于我们业务对扩展性、性能等方面的要求。
  • 紧凑性,ID 的大小可能受到实际应用的制约,例如数据库存储往往对长 ID 不友好,太长的 ID 会降低 MySQL 等数据库索引的性能;编程语言在处理时也可能受数据类型长度限制。
  • 在具体的生产环境中,还有可能提出对 QPS 等方面的具体要求,尤其是在国内一线互联网公司的业务规模下,更是需要考虑峰值业务场景的数量级层次需求。

主流方案的优缺点分析

  • 对于数据库自增方案,除了实现简单,它生成的 ID 还能够保证固定步长的递增,使用很方便。
  • 但是,因为每获取一个 ID 就会触发数据库的写请求,是一个代价高昂的操作,构建高扩展性、高性能解决方案比较复杂,性能上限明显,更不要谈扩容等场景的难度了。与此同时,保证数据库方案的高可用性也存在挑战,数据库可能发生宕机,即使采取主从热备等各种措施,也可能出现 ID 重复等问题。
  • 实际大厂商往往是构建了多层的复合架构,例如美团公开的数据库方案Leaf-Segment,引入了起到缓存等作用的 Leaf 层,对数据库操作则是通过数据库中间件提供的批量操作,这样既能保证性能、扩展性,也能保证高可用。但是,这种方案对基础架构层面的要求很多,未必适合普通业务规模的需求。
  • 与其相比,Snowflake 方案的好处是算法简单,依赖也非常少,生成的序列可预测,性能也非常好,比如 Twitter 的峰值超过 10 万 /s。
  • 但是,它也存在一定的不足,例如:时钟偏斜问题(Clock Skew)。我们知道普通的计算机系统时钟并不能保证长久的一致性,可能发生时钟回拨等问题,这就会导致时间戳不准确,进而产生重复 ID。
  • 针对这一点,Twitter 曾经在文档中建议开启NTP,毕竟 Snowflake 对时间存在依赖,但是也有人提议关闭 NTP。我个人认为还是应该开启 NTP,只是可以考虑将 stepback 设置为 0,以禁止回调。

其他问题

  • 序列号的可预测性是把双刃剑,虽然简化了一些工程问题,但很多业务场景并不适合可预测的 ID。如果你用它作为安全令牌之类,则是非常危险的,很容易被黑客猜测并利用。
  • ID 设计阶段需要谨慎考虑暴露出的信息。例如,Erlang 版本的 flake 实现基于 MAC 地址计算 WorkerID,在安全敏感的领域往往是不可以这样使用的。
  • 从理论上来说,类似 Snowflake 的方案由于时间数据位数的限制,存在与2038 年问题相似的理论极限。虽然目前的系统设计考虑数十年后的问题还太早,但是理解这些可能的极限是有必要的。
  • 如果更加深入到时钟和分布式系统时序的问题,还有与分布式 ID 相关但又有所区别的问题,比如在分布式系统中,不同机器的时间很可能是不一致的,如何保证事件的有序性?Lamport 在 1978 年的论文(Time, Clocks, and the Ording of Events in a Distributed System)中就有很深入的阐述

再补充一些当前分布式领域的热点,例如:

  • 分布式事务,包括其产生原因、业务背景、主流的解决方案等。
  • 理解CAP、BASE等理论,懂得从最终一致性等角度来思考问题,理解Paxos、Raft等一致性算法。
  • 理解典型的分布式锁实现,例如最常见的Redis 分布式锁。
  • 负载均衡等分布式领域的典型算法,至少要了解主要方案的原理。

 

 

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值