first commit

This commit is contained in:
张乾 2024-10-16 09:22:22 +08:00
parent 206fad82a2
commit bf199f7d5e
538 changed files with 97223 additions and 2 deletions

View File

@ -1,6 +1,6 @@
<?php
// Define the URL
$url = "https://learn.lianglianglee.com/";
$url = "https://learn.lianglianglee.com";
# 1 获取文件主目录
// $response = file_get_contents($url);
@ -40,7 +40,7 @@ foreach ($lines as $line) {
$folderName = "/Users/01397713/Documents/github/learn-tech".$folderName;
$line = str_replace(' ', '%20', $line);
$curlUrl = $url. $line;
$curlUrl = $url . $line;
$response = file_get_contents($curlUrl);
mkdir($folderName, 0777, true);

View File

@ -0,0 +1,362 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
33 路由机制:请求到底怎么走,它说了算(下)
在上一课时,我们介绍了 Router 接口的基本功能以及 RouterChain 加载多个 Router 的实现,之后介绍了 ConditionRouter 这个类对条件路由规则的处理逻辑以及 ScriptRouter 这个类对脚本路由规则的处理逻辑。本课时我们继续上一课时的内容,介绍剩余的三个 Router 接口实现类。
FileRouterFactory
FileRouterFactory 是 ScriptRouterFactory 的装饰器,其扩展名为 fileFileRouterFactory 在 ScriptRouterFactory 基础上增加了读取文件的能力。我们可以将 ScriptRouter 使用的路由规则保存到文件中,然后在 URL 中指定文件路径FileRouterFactory 从中解析到该脚本文件的路径并进行读取,调用 ScriptRouterFactory 去创建相应的 ScriptRouter 对象。
下面我们来看 FileRouterFactory 对 getRouter() 方法的具体实现,其中完成了 file 协议的 URL 到 script 协议 URL 的转换,如下是一个转换示例,首先会将 file:// 协议转换成 script:// 协议,然后会添加 type 参数和 rule 参数,其中 type 参数值根据文件后缀名确定,该示例为 jsrule 参数值为文件内容。
我们可以再结合接下来这个示例分析 getRouter() 方法的具体实现:
public Router getRouter(URL url) {
// 默认使用script协议
String protocol = url.getParameter(ROUTER_KEY, ScriptRouterFactory.NAME);
String type = null;
String path = url.getPath();
if (path != null) { // 获取脚本文件的语言类型
int i = path.lastIndexOf('.');
if (i > 0) {
type = path.substring(i + 1);
}
}
// 读取脚本文件中的内容
String rule = IOUtils.read(new FileReader(new File(url.getAbsolutePath())));
boolean runtime = url.getParameter(RUNTIME_KEY, false);
// 创建script协议的URL
URL script = URLBuilder.from(url)
.setProtocol(protocol)
.addParameter(TYPE_KEY, type)
.addParameter(RUNTIME_KEY, runtime)
.addParameterAndEncoded(RULE_KEY, rule)
.build();
// 获取script对应的Router实现
return routerFactory.getRouter(script);
}
TagRouterFactory & TagRouter
TagRouterFactory 作为 RouterFactory 接口的扩展实现,其扩展名为 tag。但是需要注意的是TagRouterFactory 与上一课时介绍的 ConditionRouterFactory、ScriptRouterFactory 的不同之处在于,它是通过继承 CacheableRouterFactory 这个抽象类,间接实现了 RouterFactory 接口。
CacheableRouterFactory 抽象类中维护了一个 ConcurrentMap 集合routerMap 字段)用来缓存 Router其中的 Key 是 ServiceKey。在 CacheableRouterFactory 的 getRouter() 方法中,会优先根据 URL 的 ServiceKey 查询 routerMap 集合,查询失败之后会调用 createRouter() 抽象方法来创建相应的 Router 对象。在 TagRouterFactory.createRouter() 方法中,创建的自然就是 TagRouter 对象了。
基于 Tag 的测试环境隔离方案
通过 TagRouter我们可以将某一个或多个 Provider 划分到同一分组,约束流量只在指定分组中流转,这样就可以轻松达到流量隔离的目的,从而支持灰度发布等场景。
目前Dubbo 提供了动态和静态两种方式给 Provider 打标签,其中动态方式就是通过服务治理平台动态下发标签,静态方式就是在 XML 等静态配置中打标签。Consumer 端可以在 RpcContext 的 attachment 中添加 request.tag 附加属性,注意保存在 attachment 中的值将会在一次完整的远程调用中持续传递,我们只需要在起始调用时进行设置,就可以达到标签的持续传递。
了解了 Tag 的基本概念和功能之后,我们再简单介绍一个 Tag 的使用示例。
在实际的开发测试中,一个完整的请求会涉及非常多的 Provider分属不同团队进行维护这些团队每天都会处理不同的需求并在其负责的 Provider 服务中进行修改如果所有团队都使用一套测试环境那么测试环境就会变得很不稳定。如下图所示4 个 Provider 分属不同的团队管理Provider 2 和 Provider 4 在测试环境测试,部署了有 Bug 的版本,这样就会导致整个测试环境无法正常处理请求,在这样一个不稳定的测试环境中排查 Bug 是非常困难的,因为可能排查到最后,发现是别人的 Bug。
不同状态的 Provider 节点
为了解决上述问题,我们可以针对每个需求分别独立出一套测试环境,但是这个方案会占用大量机器,前期的搭建成本以及后续的维护成本也都非常高。
下面是一个通过 Tag 方式实现环境隔离的架构图,其中,需求 1 对 Provider 2 的请求会全部落到有需求 1 标签的 Provider 上,其他 Provider 使用稳定测试环境中的 Provider需求 2 对 Provider 4 的请求会全部落到有需求 2 标签的 Provider 4 上,其他 Provider 使用稳定测试环境中的 Provider。
依赖 Tag 实现的测试环境隔离方案
在一些特殊场景中,会有 Tag 降级的场景,比如找不到对应 Tag 的 Provider会按照一定的规则进行降级。如果在 Provider 集群中不存在与请求 Tag 对应的 Provider 节点,则默认将降级请求 Tag 为空的 Provider如果希望在找不到匹配 Tag 的 Provider 节点时抛出异常的话,我们需设置 request.tag.force = true。
如果请求中的 request.tag 未设置,只会匹配 Tag 为空的 Provider也就是说即使集群中存在可用的服务若 Tag 不匹配也就无法调用。一句话总结,携带 Tag 的请求可以降级访问到无 Tag 的 Provider但不携带 Tag 的请求永远无法访问到带有 Tag 的 Provider。
TagRouter
下面我们再来看 TagRouter 的具体实现。在 TagRouter 中持有一个 TagRouterRule 对象的引用,在 TagRouterRule 中维护了一个 Tag 集合,而在每个 Tag 对象中又都维护了一个 Tag 的名称,以及 Tag 绑定的网络地址集合,如下图所示:
TagRouter、TagRouterRule、Tag 与 address 映射关系图
另外,在 TagRouterRule 中还维护了 addressToTagnames、tagnameToAddresses 两个集合(都是 Map`> 类型),分别记录了 Tag 名称到各个 address 的映射以及 address 到 Tag 名称的映射。在 TagRouterRule 的 init() 方法中,会根据 tags 集合初始化这两个集合。
了解了 TagRouterRule 的基本构造之后,我们继续来看 TagRouter 构造 TagRouterRule 的过程。TagRouter 除了实现了 Router 接口之外,还实现了 ConfigurationListener 接口,如下图所示:
TagRouter 继承关系图
ConfigurationListener 用于监听配置的变化,其中就包括 TagRouterRule 配置的变更。当我们通过动态更新 TagRouterRule 配置的时候,就会触发 ConfigurationListener 接口的 process() 方法TagRouter 对 process() 方法的实现如下:
public synchronized void process(ConfigChangedEvent event) {
// DELETED事件会直接清空tagRouterRule
if (event.getChangeType().equals(ConfigChangeType.DELETED)) {
this.tagRouterRule = null;
} else { // 其他事件会解析最新的路由规则并记录到tagRouterRule字段中
this.tagRouterRule = TagRuleParser.parse(event.getContent());
}
}
我们可以看到,如果是删除配置的操作,则直接将 tagRouterRule 设置为 null如果是修改或新增配置则通过 TagRuleParser 解析传入的配置,得到对应的 TagRouterRule 对象。TagRuleParser 可以解析 yaml 格式的 TagRouterRule 配置,下面是一个配置示例:
force: false
runtime: true
enabled: false
priority: 1
key: demo-provider
tags:
- name: tag1
addresses: null
- name: tag2
addresses: ["30.5.120.37:20880"]
- name: tag3
addresses: []
经过 TagRuleParser 解析得到的 TagRouterRule 结构,如下所示:
TagRouterRule 结构图
除了上图展示的几个集合字段TagRouterRule 还从 AbstractRouterRule 抽象类继承了一些控制字段,后面介绍的 ConditionRouterRule 也继承了 AbstractRouterRule。
AbstractRouterRule继承关系图
AbstractRouterRule 中核心字段的具体含义大致可总结为如下。
keystring 类型、scopestring 类型key 明确规则体作用在哪个服务或应用。scope 为 service 时key 由 [{group}:]{service}[:{version}] 构成scope 为 application 时key 为 application 的名称。
rawRulestring 类型):记录了路由规则解析前的原始字符串配置。
runtimeboolean 类型):表示是否在每次调用时执行该路由规则。如果设置为 false则会在 Provider 列表变更时预先执行并缓存结果,调用时直接从缓存中获取路由结果。
forceboolean 类型):当路由结果为空时,是否强制执行,如果不强制执行,路由结果为空的路由规则将自动失效。该字段默认值为 false。
validboolean 类型):用于标识解析生成当前 RouterRule 对象的配置是否合法。
enabledboolean 类型):标识当前路由规则是否生效。
priorityint 类型):用于表示当前 RouterRule 的优先级。
dynamicboolean 类型):表示该路由规则是否为持久数据,当注册方退出时,路由规则是否依然存在。
我们可以看到AbstractRouterRule 中的核心字段与前面的示例配置是一一对应的。
我们知道Router 最终目的是要过滤符合条件的 Invoker 对象,下面我们一起来看 TagRouter 是如何使用 TagRouterRule 路由逻辑进行 Invoker 过滤的,大致步骤如下。
如果 invokers 为空,直接返回空集合。
检查关联的 tagRouterRule 对象是否可用,如果不可用,则会直接调用 filterUsingStaticTag() 方法进行过滤,并返回过滤结果。在 filterUsingStaticTag() 方法中,会比较请求携带的 tag 值与 Provider URL 中的 tag 参数值。
获取此次调用的 tag 信息,这里会尝试从 Invocation 以及 URL 的参数中获取。
如果此次请求指定了 tag 信息,则首先会获取 tag 关联的 address 集合。
如果 address 集合不为空,则根据该 address 集合中的地址,匹配出符合条件的 Invoker 集合。如果存在符合条件的 Invoker则直接将过滤得到的 Invoker 集合返回;如果不存在,就会根据 force 配置决定是否返回空 Invoker 集合。
如果 address 集合为空,则会将请求携带的 tag 值与 Provider URL 中的 tag 参数值进行比较,匹配出符合条件的 Invoker 集合。如果存在符合条件的 Invoker则直接将过滤得到的 Invoker 集合返回;如果不存在,就会根据 force 配置决定是否返回空 Invoker 集合。
如果 force 配置为 false且符合条件的 Invoker 集合为空,则返回所有不包含任何 tag 的 Provider 列表。
如果此次请求未携带 tag 信息,则会先获取 TagRouterRule 规则中全部 tag 关联的 address 集合。如果 address 集合不为空,则过滤出不在 address 集合中的 Invoker 并添加到结果集合中,最后,将 Provider URL 中的 tag 值与 TagRouterRule 中的 tag 名称进行比较,得到最终的 Invoker 集合。
上述流程的具体实现是在 TagRouter.route() 方法中,如下所示:
public <T> List<Invoker<T>> route(List<Invoker<T>> invokers, URL url, Invocation invocation) throws RpcException {
... // 如果invokers为空直接返回空集合(略)
final TagRouterRule tagRouterRuleCopy = tagRouterRule;
if (tagRouterRuleCopy == null || !tagRouterRuleCopy.isValid() || !tagRouterRuleCopy.isEnabled()) {
return filterUsingStaticTag(invokers, url, invocation);
}
// 检查关联的tagRouterRule对象是否可用如果不可用则会直接调用filterUsingStaticTag() 方法进行过滤
List<Invoker<T>> result = invokers;
// 获取此次调用的tag信息尝试从Invocation以及URL中获取
String tag = StringUtils.isEmpty(invocation.getAttachment(TAG_KEY)) ? url.getParameter(TAG_KEY) :
invocation.getAttachment(TAG_KEY);
if (StringUtils.isNotEmpty(tag)) { // 此次请求一个特殊的tag
// 获取tag关联的address集合
List<String> addresses = tagRouterRuleCopy.getTagnameToAddresses().get(tag);
if (CollectionUtils.isNotEmpty(addresses)) {
// 根据上面的address集合匹配符合条件的Invoker
result = filterInvoker(invokers, invoker -> addressMatches(invoker.getUrl(), addresses));
// 如果存在符合条件的Invoker则直接将过滤得到的Invoker集合返回
// 如果不存在符合条件的Invoker根据force配置决定是否返回空Invoker集合
if (CollectionUtils.isNotEmpty(result) || tagRouterRuleCopy.isForce()) {
return result;
}
} else {
// 如果 address 集合为空,则会将请求携带的 tag 与 Provider URL 中的 tag 参数值进行比较,匹配出符合条件的 Invoker 集合。
result = filterInvoker(invokers, invoker -> tag.equals(invoker.getUrl().getParameter(TAG_KEY)));
}
if (CollectionUtils.isNotEmpty(result) || isForceUseTag(invocation)) {
return result; // 存在符合条件的Invoker或是force配置为true
}else { // 如果 force 配置为 false且符合条件的 Invoker 集合为空,则返回所有不包含任何 tag 的 Provider 列表。
List<Invoker<T>> tmp = filterInvoker(invokers, invoker -> addressNotMatches(invoker.getUrl(),
tagRouterRuleCopy.getAddresses()));
return filterInvoker(tmp, invoker -> StringUtils.isEmpty(invoker.getUrl().getParameter(TAG_KEY)));
}
} else {
// 如果此次请求未携带 tag 信息,则会先获取 TagRouterRule 规则中全部 tag 关联的 address 集合。
List<String> addresses = tagRouterRuleCopy.getAddresses();
if (CollectionUtils.isNotEmpty(addresses)) {
// 如果 address 集合不为空,则过滤出不在 address 集合中的 Invoker 并添加到结果集合中。
result = filterInvoker(invokers, invoker -> addressNotMatches(invoker.getUrl(), addresses));
if (CollectionUtils.isEmpty(result)) {
return result;
}
}
// 如果不存在符合条件的 Invoker 或是 address 集合为空,则会将请求携带的 tag 与 Provider URL 中的 tag 参数值进行比较,得到最终的 Invoker 集合。
return filterInvoker(result, invoker -> {
String localTag = invoker.getUrl().getParameter(TAG_KEY);
return StringUtils.isEmpty(localTag) || !tagRouterRuleCopy.getTagNames().contains(localTag);
});
}
}
ServiceRouter & AppRouter
除了前文介绍的 TagRouterFactory 继承了 CacheableRouterFactory 之外ServiceRouterFactory 也继承 CachabelRouterFactory具有了缓存的能力具体继承关系如下图所示
CacheableRouterFactory 继承关系图
ServiceRouterFactory 创建的 Router 实现是 ServiceRouter与 ServiceRouter 类似的是 AppRouter两者都继承了 ListenableRouter 抽象类(虽然 ListenableRouter 是个抽象类,但是没有抽象方法留给子类实现),继承关系如下图所示:
ListenableRouter 继承关系图
ListenableRouter 在 ConditionRouter 基础上添加了动态配置的能力ListenableRouter 的 process() 方法与 TagRouter 中的 process() 方法类似,对于 ConfigChangedEvent.DELETE 事件,直接清空 ListenableRouter 中维护的 ConditionRouterRule 和 ConditionRouter 集合的引用;对于 ADDED、UPDATED 事件,则通过 ConditionRuleParser 解析事件内容,得到相应的 ConditionRouterRule 对象和 ConditionRouter 集合。这里的 ConditionRuleParser 同样是以 yaml 文件的格式解析 ConditionRouterRule 的相关配置。ConditionRouterRule 中维护了一个 conditions 集合List<String> 类型),记录了多个 Condition 路由规则,对应生成多个 ConditionRouter 对象。
整个解析 ConditionRouterRule 的过程,与前文介绍的解析 TagRouterRule 的流程类似,这里不再赘述。
在 ListenableRouter 的 route() 方法中,会遍历全部 ConditionRouter 过滤出符合全部路由条件的 Invoker 集合,具体实现如下:
public <T> List<Invoker<T>> route(List<Invoker<T>> invokers, URL url, Invocation invocation) throws RpcException {
if (CollectionUtils.isEmpty(invokers) || conditionRouters.size() == 0) {
return invokers; // 检查边界条件直接返回invokers集合
}
for (Router router : conditionRouters) { // 路由规则进行过滤
invokers = router.route(invokers, url, invocation);
}
return invokers;
}
ServiceRouter 和 AppRouter 都是简单地继承了 ListenableRouter 抽象类,且没有覆盖 ListenableRouter 的任何方法,两者只有以下两点区别。
一个是 priority 字段值不同。ServiceRouter 为 140AppRouter 为 150也就是说 ServiceRouter 要先于 AppRouter 执行。
另一个是获取 ConditionRouterRule 配置的 Key 不同。ServiceRouter 使用的 RuleKey 是由 {interface}:[version]:[group] 三部分构成,获取的是一个服务对应的 ConditionRouterRule。AppRouter 使用的 RuleKey 是 URL 中的 application 参数值,获取的是一个服务实例对应的 ConditionRouterRule。
总结
本课时我们是紧接上一课时的内容,继续介绍了剩余 Router 接口实现的内容。
我们首先介绍了基于文件的 FileRouter 实现,其底层会依赖上一课时介绍的 ScriptRouter接下来又讲解了基于 Tag 的测试环境隔离方案,以及如何基于 TagRouter 实现该方案,同时深入分析了 TagRouter 的核心实现;最后我们还介绍了 ListenableRouter 抽象类以及 ServerRouter 和 AppRouter 两个实现,它们是在条件路由的基础上添加了动态变更路由规则的能力,同时区分了服务级别和服务实例级别的配置。

View File

@ -0,0 +1,215 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
03 库源码文件
你已经使用过Go语言编写了小命令或者说微型程序
当你在编写“Hello, world”的时候一个源码文件就足够了虽然这种小玩意儿没什么用最多能给你一点点莫名的成就感。如果你对这一点点并不满足别着急跟着学我肯定你也可以写出很厉害的程序。
我们在上一篇的文章中学到了命令源码文件的相关知识那么除了命令源码文件你还能用Go语言编写库源码文件。那么什么是库源码文件呢
在我的定义中库源码文件是不能被直接运行的源码文件它仅用于存放程序实体这些程序实体可以被其他代码使用只要遵从Go语言规范的话
这里的“其他代码”可以与被使用的程序实体在同一个源码文件内,也可以在其他源码文件,甚至其他代码包中。
那么程序实体是什么呢在Go语言中程序实体是变量、常量、函数、结构体和接口的统称。
我们总是会先声明或者说定义程序实体然后再去使用。比如在上一篇的例子中我们先定义了变量name然后在main函数中调用fmt.Printf函数的时候用到了它。
再多说一点程序实体的名字被统称为标识符。标识符可以是任何Unicode编码可以表示的字母字符、数字以及下划线“_”但是其首字母不能是数字。
从规则上说,我们可以用中文作为变量的名字。但是,我觉得这种命名方式非常不好,自己也会在开发团队中明令禁止这种做法。作为一名合格的程序员,我们应该向着编写国际水准的程序无限逼近。
回到正题。
我们今天的问题是:怎样把命令源码文件中的代码拆分到其他库源码文件?
我们用代码演示,把这个问题说得更具体一些。
如果在某个目录下有一个命令源码文件demo4.go如下
package main
import (
"flag"
)
var name string
func init() {
flag.StringVar(&name, "name", "everyone", "The greeting object.")
}
func main() {
flag.Parse()
hello(name)
}
其中的代码你应该比较眼熟了。我在讲命令源码文件的时候贴过很相似的代码那个源码文件名为demo2.go。
这两个文件的不同之处在于demo2.go直接通过调用fmt.Printf函数打印问候语而当前的demo4.go在同样位置调用了一个叫作hello的函数。
函数hello被声明在了另外一个源码文件中我把它命名为demo4_lib.go并且放在与demo4.go相同的目录下。如下
// 需在此处添加代码。[1]
import "fmt"
func hello(name string) {
fmt.Printf("Hello, %s!\n", name)
}
那么问题来了注释1处应该填入什么代码
典型回答
答案很简单填入代码包声明语句package main。为什么我之前说过在同一个目录下的源码文件都需要被声明为属于同一个代码包。
如果该目录下有一个命令源码文件那么为了让同在一个目录下的文件都通过编译其他源码文件应该也声明属于main包。
如此一来,我们就可以运行它们了。比如,我们可以在这些文件所在的目录下运行如下命令并得到相应的结果。
$ go run demo4.go demo4_lib.go
Hello, everyone!
或者,像下面这样先构建当前的代码包再运行。
$ go build puzzlers/article3/q1
$ ./q1
Hello, everyone!
在这里我把demo4.go和demo4_lib.go都放在了一个相对路径为puzzlers/article3/q1的目录中。
在默认情况下,相应的代码包的导入路径会与此一致。我们可以通过代码包的导入路径引用其中声明的程序实体。但是,这里的情况是不同的。
注意demo4.go和demo4_lib.go都声明自己属于main包。我在前面讲Go语言源码的组织方式的时候提到过这种用法源码文件声明的包名可以与其所在目录的名称不同只要这些文件声明的包名一致就可以。
顺便说一下我为本专栏创建了一个名为“Golang_Puzzlers”的项目。该项目的src子目录下会存有我们涉及的所有代码和相关文件。
也就是说正确的用法是你需要把该项目的打包文件下载到本地的任意目录下然后经解压缩后把“Golang_Puzzlers”目录加入到环境变量GOPATH中。还记得吗这会使“Golang_Puzzlers”目录成为工作区之一。
问题解析
这个问题考察的是代码包声明的基本规则。这里再总结一下。
第一条规则,同目录下的源码文件的代码包声明语句要一致。也就是说,它们要同属于一个代码包。这对于所有源码文件都是适用的。
如果目录中有命令源码文件那么其他种类的源码文件也应该声明属于main包。这也是我们能够成功构建和运行它们的前提。
第二条规则,源码文件声明的代码包的名称可以与其所在的目录的名称不同。在针对代码包进行构建时,生成的结果文件的主名称与其父目录的名称一致。
对于命令源码文件而言,构建生成的可执行文件的主名称会与其父目录的名称相同,这在我前面的回答中也验证过了。
好了,经过我的反复强调,相信你已经记住这些规则了。下面的内容也将会与它们相关。
在编写真正的程序时我们仅仅把代码拆分到几个源码文件中是不够的。我们往往会用模块化编程的方式根据代码的功能和用途把它们放置到不同的代码包中。不过这又会牵扯进一些Go语言的代码组织规则。我们一起来往下看。
知识精讲
1. 怎样把命令源码文件中的代码拆分到其他代码包?
我们先不用关注拆分代码的技巧。我在这里仍然依从前面的拆分方法。我把demo4.go另存为demo5.go并放到一个相对路径为puzzlers/article3/q2的目录中。
然后我再创建一个相对路径为puzzlers/article3/q2/lib的目录再把demo4_lib.go复制一份并改名为demo5_lib.go放到该目录中。
现在为了让它们通过编译我们应该怎样修改代码你可以先思考一下。我在这里给出一部分答案我们一起来看看已经过修改的demo5_lib.go文件。
package lib5
import "fmt"
func Hello(name string) {
fmt.Printf("Hello, %s!\n", name)
}
可以看到我在这里修改了两个地方。第一个改动是我把代码包声明语句由package main改为了package lib5。注意我故意让声明的包名与其所在的目录的名称不同。第二个改动是我把全小写的函数名hello改为首字母大写的Hello。
基于以上改动,我们再来看下面的几个问题。
2. 代码包的导入路径总会与其所在目录的相对路径一致吗?
库源码文件demo5_lib.go所在目录的相对路径是puzzlers/article3/q2/lib而它却声明自己属于lib5包。在这种情况下该包的导入路径是puzzlers/article3/q2/lib还是puzzlers/article3/q2/lib5
这个问题往往会让Go语言的初学者们困惑就算是用Go开发过程序的人也不一定清楚。我们一起来看看。
首先我们在构建或者安装这个代码包的时候提供给go命令的路径应该是目录的相对路径就像这样
go install puzzlers/article3/q2/lib
该命令会成功完成。之后当前工作区的pkg子目录下会产生相应的归档文件具体的相对路径是:
pkg/darwin_amd64/puzzlers/article3/q2/lib.a
其中的darwin_amd64就是我在讲工作区时提到的平台相关目录。可以看到这里与源码文件所在目录的相对路径是对应的。
为了进一步说明问题我需要先对demo5.go做两个改动。第一个改动是在以import为前导的代码包导入语句中加入puzzlers/article3/q2/lib也就是试图导入这个代码包。
第二个改动是把对hello函数的调用改为对lib.Hello函数的调用。其中的lib.叫做限定符旨在指明右边的程序实体所在的代码包。不过这里与代码包导入路径的完整写法不同只包含了路径中的最后一级lib这与代码包声明语句中的规则一致。
现在我们可以通过运行go run demo5.go命令试一试。错误提示会类似于下面这种。
./demo5.go:5:2: imported and not used: "puzzlers/article3/q2/lib" as lib5
./demo5.go:16:2: undefined: lib
第一个错误提示的意思是我们导入了puzzlers/article3/q2/lib包但没有实际使用其中的任何程序实体。这在Go语言中是不被允许的在编译时就会导致失败。
注意这里还有另外一个线索那就是“as lib5”。这说明虽然导入了代码包puzzlers/article3/q2/lib但是使用其中的程序实体的时候应该以lib5.为限定符。这也就是第二个错误提示的原因了。Go命令找不到lib.这个限定符对应的代码包。
为什么会是这样根本原因就是我们在源码文件中声明所属的代码包与其所在目录的名称不同。请记住源码文件所在的目录相对于src目录的相对路径就是它的代码包导入路径而实际使用其程序实体时给定的限定符要与它声明所属的代码包名称对应。
有两个方式可以使上述构建成功完成。我在这里选择把demo5_lib.go文件中的代码包声明语句改为package lib。理由是为了不让该代码包的使用者产生困惑我们总是应该让声明的包名与其父目录的名称一致。
3. 什么样的程序实体才可以被当前包外的代码引用?
你可能会有疑问我为什么要把demo5_lib.go文件中的那个函数名称hello的首字母大写实际上这涉及了Go语言中对于程序实体访问权限的规则。
超级简单,名称的首字母为大写的程序实体才可以被当前包外的代码引用,否则它就只能被当前包内的其他代码引用。
通过名称Go语言自然地把程序实体的访问权限划分为了包级私有的和公开的。对于包级私有的程序实体即使你导入了它所在的代码包也无法引用到它。
4. 对于程序实体,还有其他的访问权限规则吗?
答案是肯定的。在Go 1.5及后续版本中我们可以通过创建internal代码包让一些程序实体仅仅能被当前模块中的其他代码引用。这被称为Go程序实体的第三种访问权限模块级私有。
具体规则是internal代码包中声明的公开程序实体仅能被该代码包的直接父包及其子包中的代码引用。当然引用前需要先导入这个internal包。对于其他代码包导入该internal包都是非法的无法通过编译。
“Golang_Puzzlers”项目的puzzlers/article3/q4包中有一个简单的示例可供你查看。你可以改动其中的代码并体会internal包的作用。
总结
我们在本篇文章中详细讨论了把代码从命令源码文件中拆分出来的方法,这包括拆分到其他库源码文件,以及拆分到其他代码包。
这里涉及了几条重要的Go语言基本编码规则代码包声明规则、代码包导入规则以及程序实体的访问权限规则。在进行模块化编程时你必须记住这些规则否则你的代码很可能无法通过编译。
思考题
这次的思考题都是关于代码包导入的,如下。
如果你需要导入两个代码包而这两个代码包的导入路径的最后一级是相同的比如dep/lib/flag和flag那么会产生冲突吗
如果会产生冲突,那么怎样解决这种冲突,有几种方式?
第一个问题比较简单你一试便知。强烈建议你编写个例子然后运行go命令构建它并看看会有什么样的提示。
而第二个问题涉及了代码包导入语句的高级写法你可能需要去查阅一下Go语言规范。不过也不难。你最多能想出几种解决办法呢你可以给我留言我们一起讨论。
戳此查看Go语言专栏文章配套详细代码。

View File

@ -0,0 +1,288 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
05 有哪几种实现生产者消费者模式的方法?
本课时我们主要学习如何用 wait/notify/Condition/BlockingQueue 实现生产者消费者模式。
生产者消费者模式
我们先来看看什么是生产者消费者模式,生产者消费者模式是程序设计中非常常见的一种设计模式,被广泛运用在解耦、消息队列等场景。在现实世界中,我们把生产商品的一方称为生产者,把消费商品的一方称为消费者,有时生产者的生产速度特别快,但消费者的消费速度跟不上,俗称“产能过剩”,又或是多个生产者对应多个消费者时,大家可能会手忙脚乱。如何才能让大家更好地配合呢?这时在生产者和消费者之间就需要一个中介来进行调度,于是便诞生了生产者消费者模式。
使用生产者消费者模式通常需要在两者之间增加一个阻塞队列作为媒介,有了媒介之后就相当于有了一个缓冲,平衡了两者的能力,整体的设计如图所示,最上面是阻塞队列,右侧的 1 是生产者线程,生产者在生产数据后将数据存放在阻塞队列中,左侧的 2 是消费者线程,消费者获取阻塞队列中的数据。而中间的 3 和 4 分别代表生产者消费者之间互相通信的过程,因为无论阻塞队列是满还是空都可能会产生阻塞,阻塞之后就需要在合适的时机去唤醒被阻塞的线程。
那么什么时候阻塞线程需要被唤醒呢?有两种情况。第一种情况是当消费者看到阻塞队列为空时,开始进入等待,这时生产者一旦往队列中放入数据,就会通知所有的消费者,唤醒阻塞的消费者线程。另一种情况是如果生产者发现队列已经满了,也会被阻塞,而一旦消费者获取数据之后就相当于队列空了一个位置,这时消费者就会通知所有正在阻塞的生产者进行生产,这便是对生产者消费者模式的简单介绍。
如何用 BlockingQueue 实现生产者消费者模式
我们接下来看如何用 wait/notify/Condition/BlockingQueue 实现生产者消费者模式,先从最简单的 BlockingQueue 开始讲起:
public static void main(String[] args) {
BlockingQueue<Object> queue = new ArrayBlockingQueue<>(10);
Runnable producer = () -> {
while (true) {
queue.put(new Object());
}
};
new Thread(producer).start();
new Thread(producer).start();
Runnable consumer = () -> {
while (true) {
queue.take();
}
};
new Thread(consumer).start();
new Thread(consumer).start();
}
如代码所示,首先,创建了一个 ArrayBlockingQueue 类型的 BlockingQueue命名为 queue 并将它的容量设置为 10其次创建一个简单的生产者while(true) 循环体中的queue.put() 负责往队列添加数据然后创建两个生产者线程并启动同样消费者也非常简单while(true) 循环体中的 queue.take() 负责消费数据,同时创建两个消费者线程并启动。为了代码简洁并突出设计思想,代码里省略了 try/catch 检测,我们不纠结一些语法细节。以上便是利用 BlockingQueue 实现生产者消费者模式的代码。虽然代码非常简单,但实际上 ArrayBlockingQueue 已经在背后完成了很多工作,比如队列满了就去阻塞生产者线程,队列有空就去唤醒生产者线程等。
如何用 Condition 实现生产者消费者模式
BlockingQueue 实现生产者消费者模式看似简单,背后却暗藏玄机,我们在掌握这种方法的基础上仍需要掌握更复杂的实现方法。我们接下来看如何在掌握了 BlockingQueue 的基础上利用 Condition 实现生产者消费者模式,它们背后的实现原理非常相似,相当于我们自己实现一个简易版的 BlockingQueue
public class MyBlockingQueueForCondition {
private Queue queue;
private int max = 16;
private ReentrantLock lock = new ReentrantLock();
private Condition notEmpty = lock.newCondition();
private Condition notFull = lock.newCondition();
public MyBlockingQueueForCondition(int size) {
this.max = size;
queue = new LinkedList();
}
public void put(Object o) throws InterruptedException {
lock.lock();
try {
while (queue.size() == max) {
notFull.await();
}
queue.add(o);
notEmpty.signalAll();
} finally {
lock.unlock();
}
}
public Object take() throws InterruptedException {
lock.lock();
try {
while (queue.size() == 0) {
notEmpty.await();
}
Object item = queue.remove();
notFull.signalAll();
return item;
} finally {
lock.unlock();
}
}
}
如代码所示,首先,定义了一个队列变量 queue 并设置最大容量为 16其次定义了一个 ReentrantLock 类型的 Lock 锁,并在 Lock 锁的基础上创建两个 Condition一个是 notEmpty另一个是 notFull分别代表队列没有空和没有满的条件最后声明了 put 和 take 这两个核心方法。
因为生产者消费者模式通常是面对多线程的场景,需要一定的同步措施保障线程安全,所以在 put 方法中先将 Lock 锁上,然后,在 while 的条件里检测 queue 是不是已经满了,如果已经满了,则调用 notFull 的 await() 阻塞生产者线程并释放 Lock如果没有满则往队列放入数据并利用 notEmpty.signalAll() 通知正在等待的所有消费者并唤醒它们。最后在 finally 中利用 lock.unlock() 方法解锁,把 unlock 方法放在 finally 中是一个基本原则,否则可能会产生无法释放锁的情况。
下面再来看 take 方法take 方法实际上是与 put 方法相互对应的,同样是通过 while 检查队列是否为空,如果为空,消费者开始等待,如果不为空则从队列中获取数据并通知生产者队列有空余位置,最后在 finally 中解锁。
这里需要注意,我们在 take() 方法中使用 while( queue.size() == 0 ) 检查队列状态,而不能用 if( queue.size() == 0 )。为什么呢?大家思考这样一种情况,因为生产者消费者往往是多线程的,我们假设有两个消费者,第一个消费者线程获取数据时,发现队列为空,便进入等待状态;因为第一个线程在等待时会释放 Lock 锁,所以第二个消费者可以进入并执行 if( queue.size() == 0 ),也发现队列为空,于是第二个线程也进入等待;而此时,如果生产者生产了一个数据,便会唤醒两个消费者线程,而两个线程中只有一个线程可以拿到锁,并执行 queue.remove 操作,另外一个线程因为没有拿到锁而卡在被唤醒的地方,而第一个线程执行完操作后会在 finally 中通过 unlock 解锁,而此时第二个线程便可以拿到被第一个线程释放的锁,继续执行操作,也会去调用 queue.remove 操作,然而这个时候队列已经为空了,所以会抛出 NoSuchElementException 异常,这不符合我们的逻辑。而如果用 while 做检查,当第一个消费者被唤醒得到锁并移除数据之后,第二个线程在执行 remove 前仍会进行 while 检查,发现此时依然满足 queue.size() == 0 的条件,就会继续执行 await 方法,避免了获取的数据为 null 或抛出异常的情况。
如何用 wait/notify 实现生产者消费者模式
最后我们再来看看使用 wait/notify 实现生产者消费者模式的方法实际上实现原理和Condition 是非常类似的,它们是兄弟关系:
class MyBlockingQueue {
private int maxSize;
private LinkedList<Object> storage;
public MyBlockingQueue(int size) {
this.maxSize = size;
storage = new LinkedList<>();
}
public synchronized void put() throws InterruptedException {
while (storage.size() == maxSize) {
wait();
}
storage.add(new Object());
notifyAll();
}
public synchronized void take() throws InterruptedException {
while (storage.size() == 0) {
wait();
}
System.out.println(storage.remove());
notifyAll();
}
}
如代码所示,最主要的部分仍是 take 与 put 方法,我们先来看 put 方法put 方法被 synchronized 保护while 检查队列是否为满,如果不满就往里放入数据并通过 notifyAll() 唤醒其他线程。同样take 方法也被 synchronized 修饰while 检查队列是否为空,如果不为空就获取数据并唤醒其他线程。使用这个 MyBlockingQueue 实现的生产者消费者代码如下:
/**
* 描述: wait形式实现生产者消费者模式
*/
public class WaitStyle {
public static void main(String[] args) {
MyBlockingQueue myBlockingQueue = new MyBlockingQueue(10);
Producer producer = new Producer(myBlockingQueue);
Consumer consumer = new Consumer(myBlockingQueue);
new Thread(producer).start();
new Thread(consumer).start();
}
}
class Producer implements Runnable {
private MyBlockingQueue storage;
public Producer(MyBlockingQueue storage) {
this.storage = storage;
}
@Override
public void run() {
for (int i = 0; i < 100; i++) {
try {
storage.put();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
class Consumer implements Runnable {
private MyBlockingQueue storage;
public Consumer(MyBlockingQueue storage) {
this.storage = storage;
}
@Override
public void run() {
for (int i = 0; i < 100; i++) {
try {
storage.take();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
以上就是三种实现生产者消费者模式的讲解,其中,第一种 BlockingQueue 模式实现比较简单,但其背后的实现原理在第二种、第三种实现方法中得以体现,第二种、第三种实现方法本质上是我们自己实现了 BlockingQueue 的一些核心逻辑,供生产者与消费者使用。

View File

@ -0,0 +1,113 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
38 对比Java标准NIO类库你知道Netty是如何实现更高性能的吗-极客时间
今天我会对 NIO 进行一些补充,在【专栏第 11 讲】中,我们初步接触了 Java 提供的几种 IO 机制作为语言基础类库Java 自身的 NIO 设计更偏底层,这本无可厚非,但是对于一线的应用开发者,其复杂性、扩展性等方面,就存在一定的局限了。在基础 NIO 之上Netty 构建了更加易用、高性能的网络框架,广泛应用于互联网、游戏、电信等各种领域。
今天我要问你的问题是,对比 Java 标准 NIO 类库,你知道 Netty 是如何实现更高性能的吗?
典型回答
单独从性能角度Netty 在基础的 NIO 等类库之上进行了很多改进,例如:
更加优雅的 Reactor 模式实现、灵活的线程模型、利用 EventLoop 等创新性的机制,可以非常高效地管理成百上千的 Channel。
充分利用了 Java 的 Zero-Copy 机制,并且从多种角度,“斤斤计较”般的降低内存分配和回收的开销。例如,使用池化的 Direct Buffer 等技术,在提高 IO 性能的同时,减少了对象的创建和销毁;利用反射等技术直接操纵 SelectionKey使用数组而不是 Java 容器等。
使用更多本地代码。例如,直接利用 JNI 调用 Open SSL 等方式,获得比 Java 内建 SSL 引擎更好的性能。
在通信协议、序列化等其他角度的优化。
总的来说Netty 并没有 Java 核心类库那些强烈的通用性、跨平台等各种负担,针对性能等特定目标以及 Linux 等特定环境,采取了一些极致的优化手段。
考点分析
这是一个比较开放的问题,我给出的回答是个概要性的举例说明。面试官很可能利用这种开放问题作为引子,针对你回答的一个或者多个点,深入探讨你在不同层次上的理解程度。
在面试准备中,兼顾整体性的同时,不要忘记选定个别重点进行深入理解掌握,最好是进行源码层面的深入阅读和实验。如果你希望了解更多从性能角度 Netty 在编码层面的手段,可以参考 Norman 在 Devoxx 上的分享,其中的很多技巧对于实现极致性能的 API 有一定借鉴意义,但在一般的业务开发中要谨慎采用。
虽然提到 Netty人们会自然地想到高性能但是 Netty 本身的优势不仅仅只有这一个方面,
下面我会侧重两个方面:
对 Netty 进行整体介绍,帮你了解其基本组成。
从一个简单的例子开始,对比在【第 11 讲】中基于 IO、NIO 等标准 API 的实例,分析它的技术要点,给你提供一个进一步深入学习的思路。
知识扩展
首先,我们从整体了解一下 Netty。按照官方定义它是一个异步的、基于事件 Client/Server 的网络框架,目标是提供一种简单、快速构建网络应用的方式,同时保证高吞吐量、低延时、高可靠性。
从设计思路和目的上Netty 与 Java 自身的 NIO 框架相比有哪些不同呢?
我们知道 Java 的标准类库,由于其基础性、通用性的定位,往往过于关注技术模型上的抽象,而不是从一线应用开发者的角度去思考。我曾提到过,引入并发包的一个重要原因就是,应用开发者使用 Thread API 比较痛苦,需要操心的不仅仅是业务逻辑,而且还要自己负责将其映射到 Thread 模型上。Java NIO 的设计也有类似的特点开发者需要深入掌握线程、IO、网络等相关概念学习路径很长很容易导致代码复杂、晦涩即使是有经验的工程师也难以快速地写出高可靠性的实现。
Netty 的设计强调了 “Separation Of Concerns”通过精巧设计的事件机制将业务逻辑和无关技术逻辑进行隔离并通过各种方便的抽象一定程度上填补了了基础平台和业务开发之间的鸿沟更有利于在应用开发中普及业界的最佳实践。
另外Netty &gt; 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。
接下来,我们一起来看一个入门的代码实例,看看 Netty 应用到底是什么样子。
与【第 11 讲】类似,同样是以简化的 Echo Server 为例,下图是 Netty 官方提供的 Server 部分,完整用例请点击链接。
上面的例子,虽然代码很短,但已经足够体现出 Netty 的几个核心概念,请注意我用红框标记出的部分:
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 等机制则简化了编程模型,开发者不用担心并发等问题,在一定程度上简化了应用代码的开发。最难能可贵的是,这一切并没有以可靠性、可扩展性为代价,反而将其大幅度提高。
我在【专栏周末福利】中已经推荐了 Norman Maurer 等编写的《Netty 实战》Netty In Action如果你想系统学习 Netty它会是个很好的入门参考。针对 Netty 的一些实现原理,很可能成为面试中的考点,例如:
Reactor 模式和 Netty 线程模型。
Pipelining、EventLoop 等部分的设计实现细节。
Netty 的内存管理机制、引用计数等特别手段。
有的时候面试官也喜欢对比 Java 标准 NIO API例如你是否知道 Java NIO 早期版本中的 Epoll空转问题以及 Netty 的解决方式等。
对于这些知识点,公开的深入解读已经有很多了,在学习时希望你不要一开始就被复杂的细节弄晕,可以结合实例,逐步、有针对性的进行学习。我的一个建议是,可以试着画出相应的示意图,非常有助于理解并能清晰阐述自己的看法。
今天,从 Netty 性能的问题开始,我概要地介绍了 Netty 框架,并且以 Echo Server 为例,对比了 Netty 和 Java NIO 在设计上的不同。但这些都仅仅是冰山的一角,全面掌握还需要下非常多的功夫。
一课一练
关于今天我们讨论的题目你做到心中有数了吗今天的思考题是Netty 的线程模型是什么样的?
请你在留言区写写你对这个问题的思考,我会选出经过认真思考的留言,送给你一份学习奖励礼券,欢迎你与我一起讨论。
你的朋友是不是也在准备面试呢?你可以“请朋友读”,把今天的题目分享给好友,或许你能帮到他。

View File

@ -0,0 +1,327 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
32 JNI的运行机制
我们经常会遇见 Java 语言较难表达,甚至是无法表达的应用场景。比如我们希望使用汇编语言(如 X86_64 的 SIMD 指令)来提升关键代码的性能;再比如,我们希望调用 Java 核心类库无法提供的,某个体系架构或者操作系统特有的功能。
在这种情况下,我们往往会牺牲可移植性,在 Java 代码中调用 C/C++ 代码(下面简述为 C 代码),并在其中实现所需功能。这种跨语言的调用,便需要借助 Java 虚拟机的 Java Native InterfaceJNI机制。
关于 JNI 的例子,你应该特别熟悉 Java 中标记为native的、没有方法体的方法下面统称为 native 方法)。当在 Java 代码中调用这些 native 方法时Java 虚拟机将通过 JNI调用至对应的 C 函数(下面将 native 方法对应的 C 实现统称为 C 函数)中。
public class Object {
public native int hashCode();
}
举个例子Object.hashCode方法便是一个 native 方法。它对应的 C 函数将计算对象的哈希值,并缓存在对象头、栈上锁记录(轻型锁)或对象监视锁(重型锁所使用的 monitor以确保该值在对象的生命周期之内不会变更。
native 方法的链接
在调用 native 方法前Java 虚拟机需要将该 native 方法链接至对应的 C 函数上。
链接方式主要有两种。第一种是让 Java 虚拟机自动查找符合默认命名规范的 C 函数,并且链接起来。
事实上我们并不需要记住所谓的命名规范而是采用javac -h命令便可以根据 Java 程序中的 native 方法声明,自动生成包含符合命名规范的 C 函数的头文件。
举个例子在下面这段代码中Foo类有三个 native 方法分别为静态方法foo以及两个重载的实例方法bar。
package org.example;
public class Foo {
public static native void foo();
public native void bar(int i, long j);
public native void bar(String s, Object o);
}
通过执行javac -h . org/example/Foo.java命令我们将在当前文件夹对应-h后面跟着的.生成名为org_example_Foo.h的头文件。其内容如下所示
/* DO NOT EDIT THIS FILE - it is machine generated */
#include <jni.h>
/* Header for class org_example_Foo */
#ifndef _Included_org_example_Foo
#define _Included_org_example_Foo
#ifdef __cplusplus
extern "C" {
#endif
/*
* Class: org_example_Foo
* Method: foo
* Signature: ()V
*/
JNIEXPORT void JNICALL Java_org_example_Foo_foo
(JNIEnv *, jclass);
/*
* Class: org_example_Foo
* Method: bar
* Signature: (IJ)V
*/
JNIEXPORT void JNICALL Java_org_example_Foo_bar__IJ
(JNIEnv *, jobject, jint, jlong);
/*
* Class: org_example_Foo
* Method: bar
* Signature: (Ljava/lang/String;Ljava/lang/Object;)V
*/
JNIEXPORT void JNICALL Java_org_example_Foo_bar__Ljava_lang_String_2Ljava_lang_Object_2
(JNIEnv *, jobject, jstring, jobject);
#ifdef __cplusplus
}
#endif
#endif
这里我简单讲解一下该命名规范。
首先native 方法对应的 C 函数都需要以Java_为前缀之后跟着完整的包名和方法名。由于 C 函数名不支持/字符,因此我们需要将/转换为_而原本方法名中的_符号则需要转换为_1。
举个例子org.example包下Foo类的foo方法Java 虚拟机会将其自动链接至名为Java_org_example_Foo_foo的 C 函数中。
当某个类出现重载的 native 方法时Java 虚拟机还会将参数类型纳入自动链接对象的考虑范围之中。具体的做法便是在前面 C 函数名的基础上追加__以及方法描述符作为后缀。
方法描述符的特殊符号同样会被替换掉,如引用类型所使用的;会被替换为_2数组类型所使用的[会被替换为_3。
基于此命名规范你可以手动拼凑上述代码中Foo类的两个bar方法所能自动链接的 C 函数名并用javac -h命令所生成的结果来验证一下。
第二种链接方式则是在 C 代码中主动链接。
这种链接方式对 C 函数名没有要求。通常我们会使用一个名为registerNatives的 native 方法,并按照第一种链接方式定义所能自动链接的 C 函数。在该 C 函数中,我们将手动链接该类的其他 native 方法。
举个例子Object类便拥有一个registerNatives方法所对应的 C 代码如下所示:
// 注Object 类的 registerNatives 方法的实现位于 java.base 模块里的 C 代码中
static JNINativeMethod methods[] = {
{"hashCode", "()I", (void *)&JVM_IHashCode},
{"wait", "(J)V", (void *)&JVM_MonitorWait},
{"notify", "()V", (void *)&JVM_MonitorNotify},
{"notifyAll", "()V", (void *)&JVM_MonitorNotifyAll},
{"clone", "()Ljava/lang/Object;", (void *)&JVM_Clone},
};
JNIEXPORT void JNICALL
Java_java_lang_Object_registerNatives(JNIEnv *env, jclass cls)
{
(*env)->RegisterNatives(env, cls,
methods, sizeof(methods)/sizeof(methods[0]));
}
我们可以看到,上面这段代码中的 C 函数将调用RegisterNatives API注册Object类中其他 native 方法所要链接的 C 函数。并且,这些 C 函数的名字并不符合默认命名规则。
当使用第二种方式进行链接时,我们需要在其他 native 方法被调用之前完成链接工作。因此我们往往会在类的初始化方法里调用该registerNatives方法。具体示例如下所示
public class Object {
private static native void registerNatives();
static {
registerNatives();
}
}
下面我们采用第一种链接方式并且实现其中的bar(String, Object)方法。如下所示:
// foo.c
#include <stdio.h>
#include "org_example_Foo.h"
JNIEXPORT void JNICALL Java_org_example_Foo_bar__Ljava_lang_String_2Ljava_lang_Object_2
(JNIEnv *env, jobject thisObject, jstring str, jobject obj) {
printf("Hello, World\n");
return;
}
然后,我们可以通过 gcc 命令将其编译成为动态链接库:
# 该命令仅适用于 macOS
$ gcc -I$JAVA_HOME/include -I$JAVA_HOME/include/darwin -o libfoo.dylib -shared foo.c
这里需要注意的是动态链接库的名字须以lib为前缀以.dylib(或 Linux 上的.so为扩展名。在 Java 程序中我们可以通过System.loadLibrary("foo")方法来加载libfoo.dylib如下述代码所示
package org.example;
public class Foo {
public static native void foo();
public native void bar(int i, long j);
public native void bar(String s, Object o);
int i = 0xDEADBEEF;
public static void main(String[] args) {
try {
System.loadLibrary("foo");
} catch (UnsatisfiedLinkError e) {
e.printStackTrace();
System.exit(1);
}
new Foo().bar("", "");
}
}
如果libfoo.dylib不在当前路径下我们可以在启动 Java 虚拟机时配置java.library.path参数使其指向包含libfoo.dylib的文件夹。具体命令如下所示
$ java -Djava.library.path=/PATH/TO/DIR/CONTAINING/libfoo.dylib org.example.Foo
Hello, World
JNI 的 API
在 C 代码中,我们也可以使用 Java 的语言特性,如 instanceof 测试等。这些功能都是通过特殊的 JNI 函数JNI Functions来实现的。
Java 虚拟机会将所有 JNI 函数的函数指针聚合到一个名为JNIEnv的数据结构之中。
这是一个线程私有的数据结构。Java 虚拟机会为每个线程创建一个JNIEnv并规定 C 代码不能将当前线程的JNIEnv共享给其他线程否则 JNI 函数的正确性将无法保证。
这么设计的原因主要有两个。一是给 JNI 函数提供一个单独命名空间。二是允许 Java 虚拟机通过更改函数指针替换 JNI 函数的具体实现,例如从附带参数类型检测的慢速版本,切换至不做参数类型检测的快速版本。
在 HotSpot 虚拟机中JNIEnv被内嵌至 Java 线程的数据结构之中。部分虚拟机代码甚至会从JNIEnv的地址倒推出 Java 线程的地址。因此如果在其他线程中使用当前线程的JNIEnv会使这部分代码错误识别当前线程。
JNI 会将 Java 层面的基本类型以及引用类型映射为另一套可供 C 代码使用的数据结构。其中,基本类型的对应关系如下表所示:
引用类型对应的数据结构之间也存在着继承关系,具体如下所示:
jobject
|- jclass (java.lang.Class objects)
|- jstring (java.lang.String objects)
|- jthrowable (java.lang.Throwable objects)
|- jarray (arrays)
|- jobjectArray (object arrays)
|- jbooleanArray (boolean arrays)
|- jbyteArray (byte arrays)
|- jcharArray (char arrays)
|- jshortArray (short arrays)
|- jintArray (int arrays)
|- jlongArray (long arrays)
|- jfloatArray (float arrays)
|- jdoubleArray (double arrays)
我们回头看看Foo类 3 个 native 方法对应的 C 函数的参数。
JNIEXPORT void JNICALL Java_org_example_Foo_foo
(JNIEnv *, jclass);
JNIEXPORT void JNICALL Java_org_example_Foo_bar__IJ
(JNIEnv *, jobject, jint, jlong);
JNIEXPORT void JNICALL Java_org_example_Foo_bar__Ljava_lang_String_2Ljava_lang_Object_2 (JNIEnv *, jobject, jstring, jobject);
静态 native 方法foo将接收两个参数分别为存放 JNI 函数的JNIEnv指针以及一个jclass参数用来指代定义该 native 方法的类即Foo类。
两个实例 native 方法bar的第二个参数则是jobject类型的用来指代该 native 方法的调用者也就是Foo类的实例。
如果 native 方法声明了参数,那么对应的 C 函数将接收这些参数。在我们的例子中第一个bar方法声明了 int 型和 long 型的参数,对应的 C 函数则接收 jint 和 jlong 类型的参数第二个bar方法声明了 String 类型和 Object 类型的参数,对应的 C 函数则接收 jstring 和 jobject 类型的参数。
下面我们继续修改上一小节中的foo.c并在 C 代码中获取Foo类实例的i字段。
// foo.c
#include <stdio.h>
#include "org_example_Foo.h"
JNIEXPORT void JNICALL Java_org_example_Foo_bar__Ljava_lang_String_2Ljava_lang_Object_2
(JNIEnv *env, jobject thisObject, jstring str, jobject obj) {
jclass cls = (*env)->GetObjectClass(env, thisObject);
jfieldID fieldID = (*env)->GetFieldID(env, cls, "i", "I");
jint value = (*env)->GetIntField(env, thisObject, fieldID);
printf("Hello, World 0x%x\n", value);
return;
}
我们可以看到,在 JNI 中访问字段类似于反射 API我们首先需要通过类实例获得FieldID然后再通过FieldID获得某个实例中该字段的值。不过与 Java 代码相比,上述代码貌似不用处理异常。事实果真如此吗?
下面我就尝试获取了不存在的字段j运行结果如下所示
$ java org.example.Foo
Hello, World 0x5
Exception in thread "main" java.lang.NoSuchFieldError: j
at org.example.Foo.bar(Native Method)
at org.example.Foo.main(Foo.java:20)
我们可以看到printf语句照常执行并打印出Hello, World 0x5但这个数值明显是错误的。当从 C 函数返回至 main 方法时Java 虚拟机又会抛出NoSuchFieldError异常。
实际上,当调用 JNI 函数时Java 虚拟机便已生成异常实例,并缓存在内存中的某个位置。与 Java 编程不一样的是,它并不会显式地跳转至异常处理器或者调用者中,而是继续执行接下来的 C 代码。
因此,当从可能触发异常的 JNI 函数返回时,我们需要通过 JNI 函数ExceptionOccurred检查是否发生了异常并且作出相应的处理。如果无须抛出该异常那么我们需要通过 JNI 函数ExceptionClear显式地清空已缓存的异常。
具体示例如下所示为了控制代码篇幅我仅在第一个GetFieldID后检查异常以及清空异常
// foo.c
#include <stdio.h>
#include "org_example_Foo.h"
JNIEXPORT void JNICALL Java_org_example_Foo_bar__Ljava_lang_String_2Ljava_lang_Object_2
(JNIEnv *env, jobject thisObject, jstring str, jobject obj) {
jclass cls = (*env)->GetObjectClass(env, thisObject);
jfieldID fieldID = (*env)->GetFieldID(env, cls, "j", "I");
if((*env)->ExceptionOccurred(env)) {
printf("Exception!\n");
(*env)->ExceptionClear(env);
}
fieldID = (*env)->GetFieldID(env, cls, "i", "I");
jint value = (*env)->GetIntField(env, thisObject, fieldID);
// we should put an exception guard here as well.
printf("Hello, World 0x%x\n", value);
return;
}
局部引用与全局引用
在 C 代码中,我们可以访问所传入的引用类型参数,也可以通过 JNI 函数创建新的 Java 对象。
这些 Java 对象显然也会受到垃圾回收器的影响。因此Java 虚拟机需要一种机制,来告知垃圾回收算法,不要回收这些 C 代码中可能引用到的 Java 对象。
这种机制便是 JNI 的局部引用Local Reference和全局引用Global Reference。垃圾回收算法会将被这两种引用指向的对象标记为不可回收。
事实上,无论是传入的引用类型参数,还是通过 JNI 函数除NewGlobalRef及NewWeakGlobalRef之外返回的引用类型对象都属于局部引用。
不过,一旦从 C 函数中返回至 Java 方法之中,那么局部引用将失效。也就是说,垃圾回收器在标记垃圾时不再考虑这些局部引用。
这就意味着,我们不能缓存局部引用,以供另一 C 线程或下一次 native 方法调用时使用。
对于这种应用场景,我们需要借助 JNI 函数NewGlobalRef将该局部引用转换为全局引用以确保其指向的 Java 对象不会被垃圾回收。
相应的,我们还可以通过 JNI 函数DeleteGlobalRef来消除全局引用以便回收被全局引用指向的 Java 对象。
此外,当 C 函数运行时间极其长时,我们也应该考虑通过 JNI 函数DeleteLocalRef消除不再使用的局部引用以便回收被引用的 Java 对象。
另一方面,由于垃圾回收器可能会移动对象在内存中的位置,因此 Java 虚拟机需要另一种机制,来保证局部引用或者全局引用将正确地指向移动过后的对象。
HotSpot 虚拟机是通过句柄handle来完成上述需求的。这里句柄指的是内存中 Java 对象的指针的指针。当发生垃圾回收时,如果 Java 对象被移动了,那么句柄指向的指针值也将发生变动,但句柄本身保持不变。
实际上,无论是局部引用还是全局引用,都是句柄。其中,局部引用所对应的句柄有两种存储方式,一是在本地方法栈帧中,主要用于存放 C 函数所接收的来自 Java 层面的引用类型参数;另一种则是线程私有的句柄块,主要用于存放 C 函数运行过程中创建的局部引用。
当从 C 函数返回至 Java 方法时,本地方法栈帧中的句柄将会被自动清除。而线程私有句柄块则需要由 Java 虚拟机显式清理。
进入 C 函数时对引用类型参数的句柄化和调整参数位置C 调用和 Java 调用传参的方式不一样),以及从 C 函数返回时清理线程私有句柄块,共同造就了 JNI 调用的额外性能开销(具体可参考该 stackoverflow 上的回答)。
总结与实践
今天我介绍了 JNI 的运行机制。
Java 中的 native 方法的链接方式主要有两种。一是按照 JNI 的默认规范命名所要链接的 C 函数,并依赖于 Java 虚拟机自动链接。另一种则是在 C 代码中主动链接。
JNI 提供了一系列 API 来允许 C 代码使用 Java 语言特性。这些 API 不仅使用了特殊的数据结构来表示 Java 类,还拥有特殊的异常处理模式。
JNI 中的引用可分为局部引用和全局引用。这两者都可以阻止垃圾回收器回收被引用的 Java 对象。不同的是,局部引用在 native 方法调用返回之后便会失效。传入参数以及大部分 JNI API 函数的返回值都属于局部引用。
今天的实践环节,请阅读该文档中的 Performance pitfalls 以及 Correctness pitfalls 两节。

View File

@ -0,0 +1,327 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
33 Java Agent与字节码注入
关于 Java agent大家可能都听过大名鼎鼎的premain方法。顾名思义这个方法指的就是在main方法之前执行的方法。
package org.example;
public class MyAgent {
public static void premain(String args) {
System.out.println("premain");
}
}
我在上面这段代码中定义了一个premain方法。这里需要注意的是Java 虚拟机所能识别的premain方法接收的是字符串类型的参数而并非类似于main方法的字符串数组。
为了能够以 Java agent 的方式运行该premain方法我们需要将其打包成 jar 包,并在其中的 MANIFEST.MF 配置文件中指定所谓的Premain-class。具体的命令如下所示
# 注意第一条命令会向 manifest.txt 文件写入两行数据,其中包括一行空行
$ echo 'Premain-Class: org.example.MyAgent
' > manifest.txt
$ jar cvmf manifest.txt myagent.jar org/
$ java -javaagent:myagent.jar HelloWorld
premain
Hello, World
除了在命令行中指定 Java agent 之外,我们还可以通过 Attach API 远程加载。具体用法如下面的代码所示:
import java.io.IOException;
import com.sun.tools.attach.*;
public class AttachTest {
public static void main(String[] args)
throws AttachNotSupportedException, IOException, AgentLoadException, AgentInitializationException {
if (args.length <= 1) {
System.out.println("Usage: java AttachTest <PID> /PATH/TO/AGENT.jar");
return;
}
VirtualMachine vm = VirtualMachine.attach(args[0]);
vm.loadAgent(args[1]);
}
}
使用 Attach API 远程加载的 Java agent 不会再先于main方法执行这取决于另一虚拟机调用 Attach API 的时机。并且它运行的也不再是premain方法而是名为agentmain的方法。
public class MyAgent {
public static void agentmain(String args) {
System.out.println("agentmain");
}
}
相应的,我们需要更新 jar 包中的 manifest 文件使其包含Agent-Class的配置例如Agent-Class: org.example.MyAgent。
$ echo 'Agent-Class: org.example.MyAgent
' > manifest.txt
$ jar cvmf manifest.txt myagent.jar org/
$ java HelloWorld
Hello, World
$ jps
$ java AttachTest <pid> myagent.jar
agentmain
// 最后一句输出来自于运行 HelloWorld 的 Java 进程
Java 虚拟机并不限制 Java agent 的数量。你可以在 java 命令后附上多个-javaagent参数或者远程 attach 多个 Java agentJava 虚拟机会按照定义顺序,或者 attach 的顺序逐个执行这些 Java agent。
在premain方法或者agentmain方法中打印一些字符串并不出奇我们完全可以将其中的逻辑并入main方法或者其他监听端口的线程中。除此之外Java agent 还提供了一套 instrumentation 机制,允许应用程序拦截类加载事件,并且更改该类的字节码。
接下来,我们来了解一下基于这一机制的字节码注入。
字节码注入
package org.example;
import java.lang.instrument.*;
import java.security.ProtectionDomain;
public class MyAgent {
public static void premain(String args, Instrumentation instrumentation) {
instrumentation.addTransformer(new MyTransformer());
}
static class MyTransformer implements ClassFileTransformer {
public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined,
ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException {
System.out.printf("Loaded %s: 0x%X%X%X%X\n", className, classfileBuffer[0], classfileBuffer[1],
classfileBuffer[2], classfileBuffer[3]);
return null;
}
}
}
我们先来看一个例子。在上面这段代码中premain方法多出了一个Instrumentation类型的参数我们可以通过它来注册类加载事件的拦截器。该拦截器需要实现ClassFileTransformer接口并重写其中的transform方法。
transform方法将接收一个 byte 数组类型的参数,它代表的是正在被加载的类的字节码。在上面这段代码中,我将打印该数组的前四个字节,也就是 Java class 文件的魔数magic number0xCAFEBABE。
transform方法将返回一个 byte 数组代表更新过后的类的字节码。当方法返回之后Java 虚拟机会使用所返回的 byte 数组来完成接下来的类加载工作。不过如果transform方法返回 null 或者抛出异常,那么 Java 虚拟机将使用原来的 byte 数组完成类加载工作。
基于这一类加载事件的拦截功能我们可以实现字节码注入bytecode instrumentation往正在被加载的类中插入额外的字节码。
在工具篇中我曾经介绍过字节码工程框架 ASM 的用法。下面我将演示它的tree 包(依赖于基础包),用面向对象的方式注入字节码。
package org.example;
import java.lang.instrument.*;
import java.security.ProtectionDomain;
import org.objectweb.asm.*;
import org.objectweb.asm.tree.*;
public class MyAgent {
public static void premain(String args, Instrumentation instrumentation) {
instrumentation.addTransformer(new MyTransformer());
}
static class MyTransformer implements ClassFileTransformer, Opcodes {
public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined,
ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException {
ClassReader cr = new ClassReader(classfileBuffer);
ClassNode classNode = new ClassNode(ASM7);
cr.accept(classNode, ClassReader.SKIP_FRAMES);
for (MethodNode methodNode : classNode.methods) {
if ("main".equals(methodNode.name)) {
InsnList instrumentation = new InsnList();
instrumentation.add(new FieldInsnNode(GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;"));
instrumentation.add(new LdcInsnNode("Hello, Instrumentation!"));
instrumentation
.add(new MethodInsnNode(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false));
methodNode.instructions.insert(instrumentation);
}
}
ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_FRAMES | ClassWriter.COMPUTE_MAXS);
classNode.accept(cw);
return cw.toByteArray();
}
}
}
上面这段代码不难理解。我们将使用ClassReader读取所传入的 byte 数组并将其转换成ClassNode。然后我们将遍历ClassNode中的MethodNode节点也就是该类中的构造器和方法。
当遇到名字为"main"的方法时我们会在方法的入口处注入System.out.println("Hello, Instrumentation!");。运行结果如下所示:
$ java -javaagent:myagent.jar -cp .:/PATH/TO/asm-7.0-beta.jar:/PATH/TO/asm-tree-7.0-beta.jar HelloWorld
Hello, Instrumentation!
Hello, World!
Java agent 还提供了另外两个功能redefine和retransform。这两个功能针对的是已加载的类并要求用户传入所要redefine或者retransform的类实例。
其中redefine指的是舍弃原本的字节码并替换成由用户提供的 byte 数组。该功能比较危险,一般用于修复出错了的字节码。
retransform则将针对所传入的类重新调用所有已注册的ClassFileTransformer的transform方法。它的应用场景主要有如下两个。
第一在执行premain或者agentmain方法前Java 虚拟机早已加载了不少类而这些类的加载事件并没有被拦截因此也没有被注入。使用retransform功能可以注入这些已加载但未注入的类。
第二,在定义了多个 Java agent多个注入的情况下我们可能需要移除其中的部分注入。当调用Instrumentation.removeTransformer去除某个注入类后我们可以调用retransform功能重新从原始 byte 数组开始进行注入。
Java agent 的这些功能都是通过 JVMTI agent也就是 C agent 来实现的。JVMTI 是一个事件驱动的工具实现接口,通常,我们会在 C agent 加载后的入口方法Agent_OnLoad处注册各个事件的钩子hook方法。当 Java 虚拟机触发了这些事件时,便会调用对应的钩子方法。
JNIEXPORT jint JNICALL
Agent_OnLoad(JavaVM *vm, char *options, void *reserved);
举个例子,我们可以为 JVMTI 中的ClassFileLoadHook事件设置钩子从而在 C 层面拦截所有的类加载事件。关于 JVMTI 的其他事件,你可以参考该链接。
基于字节码注入的 profiler
我们可以利用字节码注入来实现代码覆盖工具例如JaCoCo或者各式各样的 profiler。
通常,我们会定义一个运行时类,并在某一程序行为的周围,注入对该运行时类中方法的调用,以表示该程序行为正要发生或者已经发生。
package org.example;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;
public class MyProfiler {
public static ConcurrentHashMap<Class<?>, AtomicInteger> data = new ConcurrentHashMap<>();
public static void fireAllocationEvent(Class<?> klass) {
data.computeIfAbsent(klass, kls -> new AtomicInteger())
.incrementAndGet();
}
public static void dump() {
data.forEach((kls, counter) -> {
System.err.printf("%s: %d\n", kls.getName(), counter.get());
});
}
static {
Runtime.getRuntime().addShutdownHook(new Thread(MyProfiler::dump));
}
}
举个例子上面这段代码便是一个运行时类。该类维护了一个HashMap用来统计每个类所新建实例的数目。当程序退出时我们将逐个打印出每个类的名字以及其新建实例的数目。
在 Java agent 中我们会截获正在加载的类并且在每条new字节码之后插入对fireAllocationEvent方法的调用以表示当前正在新建某个类的实例。具体的注入代码如下所示
package org.example;
import java.lang.instrument.*;
import java.security.ProtectionDomain;
import org.objectweb.asm.*;
import org.objectweb.asm.tree.*;
public class MyAgent {
public static void premain(String args, Instrumentation instrumentation) {
instrumentation.addTransformer(new MyTransformer());
}
static class MyTransformer implements ClassFileTransformer, Opcodes {
public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined,
ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException {
if (className.startsWith("java") ||
className.startsWith("javax") ||
className.startsWith("jdk") ||
className.startsWith("sun") ||
className.startsWith("com/sun") ||
className.startsWith("org/example")) {
// Skip JDK classes and profiler classes
return null;
}
ClassReader cr = new ClassReader(classfileBuffer);
ClassNode classNode = new ClassNode(ASM7);
cr.accept(classNode, ClassReader.SKIP_FRAMES);
for (MethodNode methodNode : classNode.methods) {
for (AbstractInsnNode node : methodNode.instructions.toArray()) {
if (node.getOpcode() == NEW) {
TypeInsnNode typeInsnNode = (TypeInsnNode) node;
InsnList instrumentation = new InsnList();
instrumentation.add(new LdcInsnNode(Type.getObjectType(typeInsnNode.desc)));
instrumentation.add(new MethodInsnNode(INVOKESTATIC, "org/example/MyProfiler", "fireAllocationEvent",
"(Ljava/lang/Class;)V", false));
methodNode.instructions.insert(node, instrumentation);
}
}
}
ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_FRAMES | ClassWriter.COMPUTE_MAXS);
classNode.accept(cw);
return cw.toByteArray();
}
}
}
你或许已经留意到,我们不得不排除对 JDK 类以及该运行时类的注入。这是因为对这些类的注入很可能造成死循环调用并最终抛出StackOverflowException异常。
举个例子假设我们在PrintStream.println方法入口处注入System.out.println("blahblah")由于out是PrintStream的实例因此当执行注入代码时我们又会调用PrintStream.println方法从而造成死循环。
解决这一问题的关键在于设置一个线程私有的标识位,用以区分应用代码的上下文以及注入代码的上下文。当即将执行注入代码时,我们将根据标识位判断是否已经位于注入代码的上下文之中。如果不是,则设置标识位并正常执行注入代码;如果是,则直接返回,不再执行注入代码。
字节码注入的另一个技术难点则是命名空间。举个例子,不少应用程序都依赖于字节码工程库 ASM。当我们的注入逻辑依赖于 ASM 时,便有可能出现注入使用最新版本的 ASM而应用程序使用较低版本的 ASM 的问题。
JDK 本身也使用了 ASM 库,如用来生成 Lambda 表达式的适配器类。JDK 的做法是重命名整个 ASM 库为所有类的包名添加jdk.internal前缀。我们显然不好直接更改 ASM 的包名,因此需要借助自定义类加载器来隔离命名空间。
除了上述技术难点之外基于字节码注入的工具还有另一个问题那便是观察者效应observer effect对所收集的数据造成的影响。
举个利用字节码注入收集每个方法的运行时间的例子。假设某个方法调用了另一个方法,而这两个方法都被注入了,那么统计被调用者运行时间的注入代码所耗费的时间,将不可避免地被计入至调用者方法的运行时间之中。
再举一个统计新建对象数目的例子。我们知道即时编译器中的逃逸分析可能会优化掉新建对象操作但它不会消除相应的统计操作比如上述例子中对fireAllocationEvent方法的调用。在这种情况下我们将统计没有实际发生的新建对象操作。
另一种情况则是我们所注入的对fireAllocationEvent方法的调用将影响到方法内联的决策。如果该新建对象的构造器调用恰好因此没有被内联从而造成对象逃逸。在这种情况下原本能够被逃逸分析优化掉的新建对象操作将无法优化我们也将统计到原本不会发生的新建对象操作。
总而言之,当使用字节码注入开发 profiler 时,需要辩证地看待所收集的数据。它仅能表示在被注入的情况下程序的执行状态,而非没有注入情况下的程序执行状态。
面向方面编程
说到字节码注入就不得不提面向方面编程Aspect-Oriented ProgrammingAOP。面向方面编程的核心理念是定义切入点pointcut以及通知advice。程序控制流中所有匹配该切入点的连接点joinpoint都将执行这段通知代码。
举个例子,我们定义一个指代所有方法入口的切入点,并指定在该切入点执行的“打印该方法的名字”这一通知。那么每个具体的方法入口便是一个连接点。
面向方面编程的其中一种实现方式便是字节码注入比如AspectJ。
在前面的例子中我们也相当于使用了面向方面编程在所有的new字节码之后执行了下面这样一段通知代码。
`MyProfiler.fireAllocationEvent(<Target>.class)`
我曾经参与开发过一个应用了面向方面编程思想的字节码注入框架DiSL。它支持用注解来定义切入点用普通 Java 方法来定义通知。例如,在方法入口处打印所在的方法名,可以简单表示为如下代码:
@Before(marker = BodyMarker.class)
static void onMethodEntry(MethodStaticContext msc) {
System.out.println(msc.thisMethodFullName());
}
如果有同学对这个工具感兴趣,或者有什么需求或者建议,欢迎你在留言中提出。
总结与实践
今天我介绍了 Java agent 以及字节码注入。
我们可以通过 Java agent 的类加载拦截功能,修改某个类所对应的 byte 数组,并利用这个修改过后的 byte 数组完成接下来的类加载。
基于字节码注入的 profiler可以统计程序运行过程中某些行为的出现次数。如果需要收集 Java 核心类库的数据,那么我们需要小心避免无限递归调用。另外,我们还需通过自定义类加载器来解决命名空间的问题。
由于字节码注入会产生观察者效应,因此基于该技术的 profiler 所收集到的数据并不能反映程序的真实运行状态。它所反映的是程序在被注入的情况下的执行状态。
今天的实践环节,请你思考如何注入方法出口。除了正常执行路径之外,你还需考虑异常执行路径。

View File

@ -0,0 +1,110 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
34 Graal用Java编译Java
最后这三篇文章,我将介绍 Oracle Labs 的 GraalVM 项目。
GraalVM 是一个高性能的、支持多种编程语言的执行环境。它既可以在传统的 OpenJDK 上运行,也可以通过 AOTAhead-Of-Time编译成可执行文件单独运行甚至可以集成至数据库中运行。
除此之外,它还移除了编程语言之间的边界,并且支持通过即时编译技术,将混杂了不同的编程语言的代码编译到同一段二进制码之中,从而实现不同语言之间的无缝切换。
今天这一篇,我们就来讲讲 GraalVM 的基石 Graal 编译器。
在之前的篇章中,特别是介绍即时编译技术的第二部分,我们反反复复提到了 Graal 编译器。这是一个用 Java 写就的即时编译器,它从 Java 9u 开始便被集成自 JDK 中,作为实验性质的即时编译器。
Graal 编译器可以通过 Java 虚拟机参数-XX:+UnlockExperimentalVMOptions -XX:+UseJVMCICompiler启用。当启用时它将替换掉 HotSpot 中的 C2 编译器,并响应原本由 C2 负责的编译请求。
在今天的文章中,我将详细跟你介绍一下 Graal 与 Java 虚拟机的交互、Graal 和 C2 的区别以及 Graal 的实现细节。
Graal 和 Java 虚拟机的交互
我们知道,即时编译器是 Java 虚拟机中相对独立的模块,它主要负责接收 Java 字节码,并生成可以直接运行的二进制码。
具体来说,即时编译器与 Java 虚拟机的交互可以分为如下三个方面。
响应编译请求;
获取编译所需的元数据(如类、方法、字段)和反映程序执行状态的 profile
将生成的二进制码部署至代码缓存code cache里。
即时编译器通过这三个功能组成了一个响应编译请求、获取编译所需的数据,完成编译并部署的完整编译周期。
传统情况下,即时编译器是与 Java 虚拟机紧耦合的。也就是说,对即时编译器的更改需要重新编译整个 Java 虚拟机。这对于开发相对活跃的 Graal 来说显然是不可接受的。
为了让 Java 虚拟机与 Graal 解耦合我们引入了Java 虚拟机编译器接口JVM Compiler InterfaceJVMCI将上述三个功能抽象成一个 Java 层面的接口。这样一来,在 Graal 所依赖的 JVMCI 版本不变的情况下,我们仅需要替换 Graal 编译器相关的 jar 包Java 9 以后的 jmod 文件),便可完成对 Graal 的升级。
JVMCI 的作用并不局限于完成由 Java 虚拟机发出的编译请求。实际上Java 程序可以直接调用 Graal编译并部署指定方法。
Graal 的单元测试便是基于这项技术。为了测试某项优化是否起作用,原本我们需要反复运行某一测试方法,直至 Graal 收到由 Java 虚拟机发出针对该方法的编译请求,而现在我们可以直接指定编译该方法,并进行测试。我们下一篇将介绍的 Truffle 语言实现框架,同样也是基于这项技术的。
Graal 和 C2 的区别
Graal 和 C2 最为明显的一个区别是Graal 是用 Java 写的,而 C2 是用 C++ 写的。相对来说Graal 更加模块化,也更容易开发与维护,毕竟,连 C2 的作者 Cliff Click 大神都不想重蹈用 C++ 开发 Java 虚拟机的覆辙。
许多开发者会觉得用 C++ 写的 C2 肯定要比 Graal 快。实际上在充分预热的情况下Java 程序中的热点代码早已经通过即时编译转换为二进制码,在执行速度上并不亚于静态编译的 C++ 程序。
再者,即便是解释执行 Graal也仅是会减慢编译效率而并不影响编译结果的性能。
换句话说,如果 C2 和 Graal 采用相同的优化手段,那么它们的编译结果是一样的。所以,程序达到稳定状态(即不再触发新的即时编译)的性能,也就是峰值性能,将也是一样的。
由于 Java 语言容易开发维护的优势,我们可以很方便地将 C2 的新优化移植到 Graal 中。反之则不然,比如,在 Graal 中被证实有效的部分逃逸分析partial escape analysis至今未被移植到 C2 中。
Graal 和 C2 另一个优化上的分歧则是方法内联算法。相对来说Graal 的内联算法对新语法、新语言更加友好,例如 Java 8 的 lambda 表达式以及 Scala 语言。
我们曾统计过数十个 Java 或 Scala 程序的峰值性能。总体而言Graal 编译结果的性能要优于 C2。对于 Java 程序来说Graal 的优势并不明显;对于 Scala 程序来说Graal 的性能优势达到了 10%。
大规模使用 Scala 的 Twitter 便在他们的生产环境中部署了 Graal 编译器,并取得了 11% 的性能提升。Slides, Video该数据基于 GraalVM 社区版。)
Graal 的实现
Graal 编译器将编译过程分为前端和后端两大部分。前端用于实现平台无关的优化(如方法内联),以及小部分平台相关的优化;而后端则负责大部分的平台相关优化(如寄存器分配),以及机器码的生成。
在介绍即时编译技术时我曾提到过Graal 和 C2 都采用了 Sea-of-Nodes IR。严格来说这里指的是 Graal 的前端,而后端采用的是另一种非 Sea-of-Nodes 的 IR。通常我们将前端的 IR 称之为 High-level IR或者 HIR后端的 IR 则称之为 Low-level IR或者 LIR。
Graal 的前端是由一个个单独的优化阶段optimization phase构成的。我们可以将每个优化阶段想象成一个图算法它会接收一个规则的图遍历图上的节点并做出优化并且返回另一个规则的图。前端中的编译阶段除了少数几个关键的之外其余均可以通过配置选项来开启或关闭。
Graal 编译器前端的优化阶段(局部)
感兴趣的同学可以阅读 Graal repo 里配置这些编译优化阶段的源文件
HighTier.javaMidTier.java以及LowTier.java。
我们知道Graal 和 C2 都采用了激进的投机性优化手段speculative optimization
通常这些优化都基于某种假设assumption。当假设出错的情况下Java 虚拟机会借助去优化deoptimization这项机制从执行即时编译器生成的机器码切换回解释执行在必要情况下它甚至会废弃这份机器码并在重新收集程序 profile 之后,再进行编译。
举个以前讲过的例子,类层次分析。在进行虚方法内联时(或者其他与类层次相关的优化),我们可能会发现某个接口仅有一个实现。
在即时编译过程中,我们可以假设在之后的执行过程中仍旧只有这一个实现,并根据这个假设进行编译优化。当之后加载了接口的另一实现时,我们便会废弃这份机器码。
Graal 与 C2 相比会更加激进。它从设计上便十分青睐这种基于假设的优化手段。在编译过程中Graal 支持自定义假设,并且直接与去优化节点相关联。
当对应的去优化被触发时Java 虚拟机将负责记录对应的自定义假设。而 Graal 在第二次编译同一方法时,便会知道该自定义假设有误,从而不再对该方法使用相同的激进优化。
Java 虚拟机的另一个能够大幅度提升性能的特性是 intrinsic 方法,我在之前的篇章中已经详细介绍过了。在 Graal 中,实现高性能的 intrinsic 方法也相对比较简单。Graal 提供了一种替换方法调用的机制,在解析 Java 字节码时会将匹配到的方法调用,替换成对另一个内部方法的调用,或者直接替换为特殊节点。
举例来说,我们可以把比较两个 byte 数组的方法java.util.Arrays.equals(byte[],byte[])替换成一个特殊节点,用来代表整个数组比较的逻辑。这样一来,当前编译方法所对应的图将被简化,因而其适用于其他优化的可能性也将提升。
总结与实践
Graal 是一个用 Java 写就的、并能够将 Java 字节码转换成二进制码的即时编译器。它通过 JVMCI 与 Java 虚拟机交互,响应由后者发出的编译请求、完成编译并部署编译结果。
对 Java 程序而言Graal 编译结果的性能略优于 OpenJDK 中的 C2对 Scala 程序而言,它的性能优势可达到 10%(企业版甚至可以达到 20%!)。这背后离不开 Graal 所采用的激进优化方式。
今天的实践环节,你可以尝试使用附带 Graal 编译器的 JDK。在 Java 1011 中,你可以通过添加虚拟机参数-XX:+UnlockExperimentalVMOptions -XX:+UseJVMCICompiler来启用或者下载我们部署在Oracle OTN上的基于 Java 8 的版本。
在刚开始运行的过程中Graal 编译器本身需要被即时编译,会抢占原本可用于编译应用代码的计算资源。因此,目前 Graal 编译器的启动性能会较差。最后一篇我会介绍解决方案。

View File

@ -0,0 +1,207 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
35 Truffle语言实现框架
今天我们来聊聊 GraalVM 中的语言实现框架 Truffle。
我们知道,实现一门新编程语言的传统做法是实现一个编译器,也就是把用该语言编写的程序转换成可直接在硬件上运行的机器码。
通常来说,编译器分为前端和后端:前端负责词法分析、语法分析、类型检查和中间代码生成,后端负责编译优化和目标代码生成。
不过,许多编译器教程只涉及了前端中的词法分析和语法分析,并没有真正生成可以运行的目标代码,更谈不上编译优化,因此在生产环境中并不实用。
另一种比较取巧的做法则是将新语言编译成某种已知语言,或者已知的中间形式,例如将 Scala、Kotlin 编译成 Java 字节码。
这样做的好处是可以直接享用 Java 虚拟机自带的各项优化,包括即时编译、自动内存管理等等。因此,这种做法对所生成的 Java 字节码的优化程度要求不高。
不过,不管是附带编译优化的编译器,还是生成中间形式并依赖于其他运行时的即时编译优化的编译器,它们所针对的都是编译型语言,在运行之前都需要这一额外的编译步骤。
与编译型语言相对应的则是解释型语言,例如 JavaScript、Ruby、Python 等。对于这些语言来说,它们无须额外的编译步骤,而是依赖于解释执行器进行解析并执行。
为了让该解释执行器能够高效地运行大型程序,语言实现开发人员通常会将其包装在虚拟机里,并实现诸如即时编译、垃圾回收等其他组件。这些组件对语言设计 本身并无太大贡献,仅仅是为了实用性而不得不进行的工程实现。
在理想情况下,我们希望在不同的语言实现中复用这些组件。也就是说,每当开发一门新语言时,我们只需要实现它的解释执行器,便能够直接复用即时编译、垃圾回收等组件,从而达到高性能的效果。这也是 Truffle 项目的目标。接下来,我们就来讲讲这个项目。
Truffle 项目简介
Truffle 是一个用 Java 写就的语言实现框架。基于 Truffle 的语言实现仅需用 Java 实现词法分析、语法分析以及针对语法分析所生成的抽象语法树Abstract Syntax TreeAST的解释执行器便可以享用由 Truffle 提供的各项运行时优化。
就一个完整的 Truffle 语言实现而言,由于实现本身以及其所依赖的 Truffle 框架部分都是用 Java 实现的,因此它可以运行在任何 Java 虚拟机之上。
当然,如果 Truffle 运行在附带了 Graal 编译器的 Java 虚拟机之上,那么它将调用 Graal 编译器所提供的 API主动触发对 Truffle 语言的即时编译,将对 AST 的解释执行转换为执行即时编译后的机器码。
在这种情况下Graal 编译器相当于一个提供了即时编译功能的库,宿主虚拟机本身仍可使用 C2 作为其唯一的即时编译器,或者分层编译模式下的 4 层编译器。
我们团队实现并且开源了多个 Truffle 语言例如JavaScriptRubyRPython以及可用来解释执行 LLVM bitcode 的Sulong。关于 Sulong 项目,任何能够编译为 LLVM bitcode 的编程语言,例如 C/C++,都能够在这上面运行。
下图展示了运行在 GraalVM EE 上的 Java 虚拟机语言,以及除 Python 外 Truffle 语言的峰值性能指标2017 年数据)。
这里我采用的基线是每个语言较有竞争力的语言实现。
对于 Java 虚拟机语言Java、Scala我比较的是使用 C2 的 HotSpot 虚拟机和使用 Graal 的 HotSpot 虚拟机。
对于 Ruby我比较的是运行在 HotSpot 虚拟机之上的 JRuby 和 Truffle Ruby。
对于 R我比较的是 GNU R 和基于 Truffle 的 FastR。
对于 C/C++,我比较的是利用 LLVM 编译器生成的二进制文件和基于 Truffle 的 Sulong。
对于 JavaScript我比较的是 Google 的 V8 和 Graal.js。
针对每种语言我们运行了上百个基准测试求出各个基准测试峰值性能的加速比并且汇总成图中所示的几何平均值Geo. mean
简单地说明一下,当 GraalVM 的加速比为 1 时,代表使用其他语言实现和使用 GraalVM 的性能相当。当 GraalVM 加速比超过 1 时,则代表 GraalVM 的性能较好;反之,则说明 GraalVM 的性能较差。
我们可以看到Java 跑在 Graal 上和跑在 C2 上的执行效率类似,而 Scala 跑在 Graal 上的执行效率则是跑在 C2 上的 1.2 倍。
对于 Ruby 或者 R 这类解释型语言,经由 Graal 编译器加速的 Truffle 语言解释器的性能十分优越,分别达到对应基线的 4.1x 和 4.5x。这里便可以看出使用专业即时编译器的 Truffle 框架的优势所在。
不过,对于同样拥有专业即时编译器的 V8 来说,基于 Truffle 的 Graal.js 仍处于追赶者的位置。考虑到我们团队中负责 Graal.js 的工程师仅有个位数,能够达到如此性能已属不易。现在 Graal.js 已经开源出来,我相信借助社区的贡献,它的性能能够得到进一步的提升。
Sulong 与传统的 C/C++ 相比,由于两者最终都将编译为机器码,因此原则上后者定义了前者的性能上限。
不过Sulong 将 C/C++ 代码放在托管环境中运行,所有代码中的内存访问都会在托管环境的监控之下。无论是会触发 Segfault 的异常访问,还是读取敏感数据的恶意访问,都能够被 Sulong 拦截下来并作出相应处理。
Partial Evaluation
如果要理解 Truffle 的原理,我们需要先了解 Partial Evaluation 这一个概念。
假设有一段程序P它将一系列输入I转换成输出O即P: I -> O。而这些输入又可以进一步划分为编译时已知的常量IS和编译时未知的ID。
那么我们可以将程序P: I -> O转换为等价的另一段程序P': ID -> O。这个新程序P'便是P的特化Specialization而从P转换到P'的这个过程便是所谓的 Partial Evaluation。
回到 Truffle 这边,我们可以将 Truffle 语言的解释执行器当成P将某段用 Truffle 语言写就的程序当作IS并通过 Partial Evaluation 特化为P'。由于 Truffle 语言的解释执行器是用 Java 写的,因此我们可以利用 Graal 编译器将P'编译为二进制码。
下面我将用一个具体例子来讲解。
假设有一门语言 X只支持读取整数参数和整数加法。这两种操作分别对应下面这段代码中的 AST 节点Arg和Add。
abstract class Node {
abstract int execute(int[] args);
}
class Arg extends Node {
final int index;
Arg(int i) { this.index = i; }
int execute(int[] args) {
return args[index];
}
}
class Add extends Node {
final Node left, right;
Add(Node left, Node right) {
this.left = left;
this.right = right;
}
int execute(int[] args) {
return left.execute(args) +
right.execute(args);
}
}
static int interpret(Node node, int[] args) {
return node.execute(args);
}
所谓 AST 节点的解释执行,便是调用这些 AST 节点的execute方法而一段程序的解释执行则是调用这段程序的 AST 根节点的execute方法。
我们可以看到Arg节点和Add节点均实现了execute方法接收一个用来指代程序输入的 int 数组参数并返回计算结果。其中Arg节点将返回 int 数组的第i个参数i是硬编码在程序之中的常量而Add节点将分别调用左右两个节点的execute方法 并将所返回的值相加后再返回。
下面我们将利用语言 X 实现一段程序计算三个输入参数之和arg0 + arg1 + arg2。这段程序解析生成的 AST 如下述代码所示:
// Sample program: arg0 + arg1 + arg2
sample = new Add(new Add(new Arg(0), new Arg(1)), new Arg(2));
这段程序对应的解释执行则是interpret(sample, args)其中args为代表传入参数的 int 数组。由于sample是编译时常量因此我们可以将其通过 Partial Evaluation特化为下面这段代码所示的interpret0方法
static final Node sample = new Add(new Add(new Arg(0), new Arg(1)), new Arg(2));
static int interpret0(int[] args) {
return sample.execute(args);
}
Truffle 的 Partial Evaluator 会不断进行方法内联(直至遇到被`@TruffleBoundary注解的方法。因此上面这段代码的interpret0方法在内联了对Add.execute方法的调用之后会转换成下述代码
static final Node sample = new Add(new Add(new Arg(0), new Arg(1)), new Arg(2));
static int interpret0(int[] args) {
return sample.left.execute(args) + sample.right.execute(args);
}
同样我们可以进一步内联对Add.execute方法的调用以及对Arg.execute方法的调用最终将interpret0转换成下述代码
static int interpret0(int[] args) {
return args[0] + args[1] + args[2];
}
至此,我们已成功地将一段 Truffle 语言代码的解释执行转换为上述 Java 代码。接下来,我们便可以让 Graal 编译器将这段 Java 代码编译为机器码,从而实现 Truffle 语言的即时编译。
节点重写
Truffle 的另一项关键优化是节点重写node rewriting
在动态语言中,许多变量的类型是在运行过程中方能确定的。以加法符号+为例,它既可以表示整数加法,还可以表示浮点数加法,甚至可以表示字符串加法。
如果是静态语言,我们可以通过推断加法的两个操作数的具体类型,来确定该加法的类型。但对于动态语言来说,我们需要在运行时动态确定操作数的具体类型,并据此选择对应的加法操作。这种在运行时选择语义的节点,会十分不利于即时编译,从而严重影响到程序的性能。
Truffle 语言解释器会收集每个 AST 节点所代表的操作的类型,并且在即时编译时,作出针对所收集得到的类型 profile 的特化specialization
还是以加法操作为例,如果所收集的类型 profile 显示这是一个整数加法操作,那么在即时编译时我们会将对应的 AST 节点当成整数加法;如果是一个字符串加法操作,那么我们会将对应的 AST 节点当成字符串加法。
当然,如果该加法操作既有可能是整数加法也可能是字符串加法,那么我们只好在运行过程中判断具体的操作类型,并选择相应的加法操作。
这种基于类型 profile 的优化,与我们以前介绍过的 Java 虚拟机中解释执行器以及三层 C1 编译代码十分类似,它们背后的核心都是基于假设的投机性优化,以及在假设失败时的去优化。
在即时编译过后,如果运行过程中发现 AST 节点的实际类型和所假设的类型不同Truffle 会主动调用 Graal 编译器提供的去优化 API返回至解释执行 AST 节点的状态,并且重新收集 AST 节点的类型信息。之后Truffle 会再次利用 Graal 编译器进行新一轮的即时编译。
当然,如果能够在第一次编译时便已达到稳定状态,不再触发去优化以及重新编译,那么,这会极大地减短程序到达峰值性能的时间。为此,我们统计了各个 Truffle 语言的方法在进行过多少次方法调用后,其 AST 节点的类型会固定下来。
据统计,在 JavaScript 方法和 Ruby 方法中80% 会在 5 次方法调用后稳定下来90% 会在 7 次调用后稳定下来99% 会在 19 次方法调用之后稳定下来。
R 语言的方法则比较特殊,即便是不进行任何调用,有 50% 的方法已经稳定下来了。这背后的原因也不难推测,这是因为 R 语言主要用于数值统计,几乎所有的操作都是浮点数类型的。
Polyglot
在开发过程中,我们通常会为工程项目选定一门语言,但问题也会接踵而至:一是这门语言没有实现我们可能需要用到的库,二是这门语言并不适用于某类问题。
Truffle 语言实现框架则支持 Polyglot允许在同一段代码中混用不同的编程语言从而使得开发人员能够自由地选择合适的语言来实现子组件。
与其他 Polyglot 框架不同的是Truffle 语言之间能够共用对象。也就是说在不对某个语言中的对象进行复制或者序列化反序列化的情况下Truffle 可以无缝地将该对象传递给另一门语言。因此Truffle 的 Polyglot 在切换语言时,性能开销非常小,甚至经常能够达到零开销。
Truffle 的 Polyglot 特性是通过 Polyglot API 来实现的。每个实现了 Polyglot API 的 Truffle 语言,其对象都能够被其他 Truffle 语言通过 Polyglot API 解析。实际上,当通过 Polyglot API 解析外来对象时,我们并不需要了解对方语言,便能够识别其数据结构,访问其中的数据,并进行进一步的计算。
总结与实践
今天我介绍了 GraalVM 中的 Truffle 项目。
Truffle 是一个语言实现框架,允许语言开发者在仅实现词法解析、语法解析以及 AST 解释器的情况下,达到极佳的性能。目前 Oracle Labs 已经实现并维护了 JavaScript、Ruby、R、Python 以及可用于解析 LLVM bitcode 的 Sulong。后者将支持在 GraalVM 上运行 C/C++ 代码。
Truffle 背后所依赖的技术是 Partial Evaluation 以及节点重写。Partial Evaluation 指的是将所要编译的目标程序解析生成的抽象语法树当做编译时常量,特化该 Truffle 语言的解释器,从而得到指代这段程序解释执行过程的 Java 代码。然后,我们可以借助 Graal 编译器将这段 Java 代码即时编译为机器码。
节点重写则是收集 AST 节点的类型,根据所收集的类型 profile 进行的特化,并在节点类型不匹配时进行去优化并重新收集、编译的一项技术。
Truffle 的 Polyglot 特性支持在一段代码中混用多种不同的语言。与其他 Polyglot 框架相比,它支持在不同的 Truffle 语言中复用内存中存储的同一个对象。
今天的实践环节,请你试用 GraalVM 中附带的各项语言实现。你可以运行我们官网上的各个示例程序。

View File

@ -0,0 +1,116 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
36 SubstrateVMAOT编译框架
今天我们来聊聊 GraalVM 中的 Ahead-Of-TimeAOT编译框架 SubstrateVM。
先来介绍一下 AOT 编译,所谓 AOT 编译,是与即时编译相对立的一个概念。我们知道,即时编译指的是在程序的运行过程中,将字节码转换为可在硬件上直接运行的机器码,并部署至托管环境中的过程。
而 AOT 编译指的则是,在程序运行之前,便将字节码转换为机器码的过程。它的成果可以是需要链接至托管环境中的动态共享库,也可以是独立运行的可执行文件。
狭义的 AOT 编译针对的目标代码需要与即时编译的一致,也就是针对那些原本可以被即时编译的代码。不过,我们也可以简单地将 AOT 编译理解为类似于 GCC 的静态编译器。
AOT 编译的优点显而易见:我们无须在运行过程中耗费 CPU 资源来进行即时编译,而程序也能够在启动伊始就达到理想的性能。
然而与即时编译相比AOT 编译无法得知程序运行时的信息,因此也无法进行基于类层次分析的完全虚方法内联,或者基于程序 profile 的投机性优化(并非硬性限制,我们可以通过限制运行范围,或者利用上一次运行的程序 profile 来绕开这两个限制)。这两者都会影响程序的峰值性能。
Java 9 引入了实验性 AOT 编译工具jaotc。它借助了 Graal 编译器,将所输入的 Java 类文件转换为机器码,并存放至生成的动态共享库之中。
在启动过程中Java 虚拟机将加载参数-XX:AOTLibrary所指定的动态共享库并部署其中的机器码。这些机器码的作用机理和即时编译生成的机器码作用机理一样都是在方法调用时切入并能够去优化至解释执行。
由于 Java 虚拟机可能通过 Java agent 或者 C agent 改动所加载的字节码,或者这份 AOT 编译生成的机器码针对的是旧版本的 Java 类,因此它需要额外的验证机制,来保证即将链接的机器码的语义与对应的 Java 类的语义是一致的。
jaotc 使用的机制便是类指纹class fingerprinting。它会在动态共享库中保存被 AOT 编译的 Java 类的摘要信息。在运行过程中Java 虚拟机负责将该摘要信息与已加载的 Java 类相比较,一旦不匹配,则直接舍弃这份 AOT 编译的机器码。
jaotc 的一大应用便是编译 java.base module也就是 Java 核心类库中最为基础的类。这些类很有可能会被应用程序所调用,但调用频率未必高到能够触发即时编译。
因此,如果 Java 虚拟机能够使用 AOT 编译技术,将它们提前编译为机器码,那么将避免在执行即时编译生成的机器码时,因为“不小心”调用到这些基础类,而需要切换至解释执行的性能惩罚。
不过,今天要介绍的主角并非 jaotc而是同样使用了 Graal 编译器的 AOT 编译框架 SubstrateVM。
SubstrateVM 的设计与实现
SubstrateVM 的设计初衷是提供一个高启动性能、低内存开销,并且能够无缝衔接 C 代码的 Java 运行时。它与 jaotc 的区别主要有两处。
第一SubstrateVM 脱离了 HotSpot 虚拟机,并拥有独立的运行时,包含异常处理,同步,线程管理,内存管理(垃圾回收)和 JNI 等组件。
第二SubstrateVM 要求目标程序是封闭的即不能动态加载其他类库等。基于这个假设SubstrateVM 将探索整个编译空间并通过静态分析推算出所有虚方法调用的目标方法。最终SubstrateVM 会将所有可能执行到的方法都纳入编译范围之中,从而免于实现额外的解释执行器。
有关 SubstrateVM 的其他限制,你可以参考这篇文档。
从执行时间上来划分SubstrateVM 可分为两部分native image generator 以及 SubstrateVM 运行时。后者 SubstrateVM 运行时便是前面提到的精简运行时,经过 AOT 编译的目标程序将跑在该运行时之上。
native image generator 则包含了真正的 AOT 编译逻辑。它本身是一个 Java 程序,将使用 Graal 编译器将 Java 类文件编译为可执行文件或者动态链接库。
在进行编译之前native image generator 将采用指针分析points-to analysis从用户提供的程序入口出发探索所有可达的代码。在探索的同时它还将执行初始化代码并在最终生成可执行文件时将已初始化的堆保存至一个堆快照之中。这样一来SubstrateVM 将直接从目标程序开始运行,而无须重复进行 Java 虚拟机的初始化。
SubstrateVM 主要用于 Java 虚拟机语言的 AOT 编译,例如 Java、Scala 以及 Kotlin。Truffle 语言实现本质上就是 Java 程序,而且它所有用到的类都是编译时已知的,因此也适合在 SubstrateVM 上运行。不过,它并不会 AOT 编译用 Truffle 语言写就的程序。
SubstrateVM 的启动时间与内存开销
SubstrateVM 的启动时间和内存开销非常少。我们曾比较过用 C 和用 Java 两种语言写就的 Hello World 程序。C 程序的执行时间在 10ms 以下,内存开销在 500KB 以下。在 HotSpot 虚拟机上运行的 Java 程序则需要 40ms内存开销为 24MB。
使用 SubstrateVM 的 Java 程序的执行时间则与 C 程序持平,内存开销在 850KB 左右。这得益于 SubstrateVM 所保存的堆快照,以及无须额外初始化,直接执行目标代码的特性。
同样,我们还比较了用 JavaScript 编写的 Hello World 程序。这里的测试对象是 Google 的 V8 以及基于 Truffle 的 Graal.js。这两个执行引擎都涉及了大量的解析代码以及执行代码因此可以当作大型应用程序来看待。
V8 的执行效率非常高,能够与 C 程序的 Hello World 相媲美,但是它使用了约 18MB 的内存。运行在 HotSpot 虚拟机上的 Graal.js 则需要 650ms 方能执行完这段 JavaScript 的 Hello World 程序,而且内存开销在 120MB 左右。
运行在 SubstrateVM 上的 Graal.js 无论是执行时间还是内存开销都十分优越,分别为 10ms 以下以及 4.2MB。我们可以看到,它在运行时间与 V8 持平的情况下,内存开销远小于 V8。
由于 SubstrateVM 的轻量特性它十分适合于嵌入至其他系统之中。Oracle Labs 的另一个团队便是将 Truffle 语言实现嵌入至 Oracle 数据库之中这样就可以在数据库中运行任意语言的预储程序stored procedure。如果你感兴趣的话可以搜索 Oracle Database Multilingual EngineMLE或者参阅这个网址。我们团队也在与 MySQL 合作,开发 MySQL MLE详情可留意我们在今年 Oracle Code One 的讲座。
Metropolis 项目
去年 OpenJDK 推出了Metropolis 项目他们希望可以实现“Java-on-Java”的远大目标。
我们知道,目前 HotSpot 虚拟机的绝大部分代码都是用 C++ 写的。这也造就了一个非常有趣的现象,那便是对 Java 语言本身的贡献需要精通 C++。此外,随着 HotSpot 项目日渐庞大,维护难度也逐渐上升。
由于上述种种原因,使用 Java 来开发 Java 虚拟机的呼声越来越高。Oracle 的架构师 John Rose 便提出了使用 Java 开发 Java 虚拟机的四大好处:
能够完全控制编译 Java 虚拟机时所使用的优化技术;
能够与 C++ 语言的更新解耦合;
能够减轻开发人员以及维护人员的负担;
能够以更为敏捷的方式实现 Java 的新功能。
当然Metropolis 项目并非第一个提出 Java-on-Java 概念的项目。实际上JikesRVM 项目和Maxine VM 项目都已用 Java 完整地实现了一套 Java 虚拟机(后者的即时编译器 C1X 便是 Graal 编译器的前身)。
然而Java-on-Java 技术通常会干扰应用程序的垃圾回收、即时编译优化,从而严重影响 Java 虚拟机的启动性能。
举例来说,目前使用了 Graal 编译器的 HotSpot 虚拟机会在即时编译过程中生成大量的 Java 对象,这些 Java 对象同样会占据应用程序的堆空间,从而使得垃圾回收更加频繁。
另外Graal 编译器本身也会触发即时编译,并与应用程序的即时编译竞争编译线程的 CPU 资源。这将造成应用程序从解释执行切换至即时编译生成的机器码的时间大大地增长,从而降低应用程序的启动性能。
Metropolis 项目的第一个子项目便是探索部署已 AOT 编译的 Graal 编译器的可能性。这个子项目将借助 SubstrateVM 技术,把整个 Graal 编译器 AOT 编译为机器码。
这样一来在运行过程中Graal 编译器不再需要被即时编译,因此也不会再占据可用于即时编译应用程序的 CPU 资源,使用 Graal 编译器的 HotSpot 虚拟机的启动性能将得到大幅度地提升。
此外,由于 SubstrateVM 编译得到的 Graal 编译器将使用独立的堆空间,因此 Graal 编译器在即时编译过程中生成的 Java 对象将不再干扰应用程序所使用的堆空间。
目前 Metropolis 项目仍处于前期验证阶段,如果你感兴趣的话,可以关注之后的发展情况。
总结与实践
今天我介绍了 GraalVM 中的 AOT 编译框架 SubstrateVM。
SubstrateVM 的设计初衷是提供一个高启动性能、低内存开销,和能够无缝衔接 C 代码的 Java 运行时。它是一个独立的运行时,拥有自己的内存管理等组件。
SubstrateVM 要求所要 AOT 编译的目标程序是封闭的,即不能动态加载其他类库等。在进行 AOT 编译时,它会探索所有可能运行到的方法,并全部纳入编译范围之内。
SubstrateVM 的启动时间和内存开销都非常少,这主要得益于在 AOT 编译时便已保存了已初始化好的堆快照并支持从程序入口直接开始运行。作为对比HotSpot 虚拟机在执行 main 方法前需要执行一系列的初始化操作,因此启动时间和内存开销都要远大于运行在 SubstrateVM 上的程序。
Metropolis 项目将运用 SubstrateVM 项目,逐步地将 HotSpot 虚拟机中的 C++ 代码替换成 Java 代码,从而提升 HotSpot 虚拟机的可维护性,也加快新 Java 功能的开发效率。
今天的实践环节请你参考我们官网的SubstrateVM 教程AOT 编译一段 Java-Kotlin 代码。

View File

@ -0,0 +1,43 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
尾声丨道阻且长,努力加餐.html
说句实话,我也不知道是怎么写完这 36 篇技术文章的。
一周三篇的文章接近近万字,说多不多,对我而言还是挺困难的一件事。基本上,我连续好几个月的业余时间都贡献给写作,甚至一度重温了博士阶段被论文支配的恐怖。我想,这大概也算是在工作相对清闲的国外环境下,体验了一把 997 的生活。
这一路下来,我感觉写专栏的最大问题,其实并不在于写作本身,而在于它对你精力的消耗,这种消耗甚至会让你无法专注于本职工作。因此,我也愈发地佩服能够持续分享技术的同行们。还好我的工作挺有趣的,每天开开心心地上班写代码,只是一到下班时间就蔫了,不得不应付编辑的催稿回家码字。
我在写作的中途,多次感受到存稿不足的压力,以致于需要请年假来填补写作的空缺。不过,最后做到了风雨无阻、节假无休地一周三更,也算是幸不辱命吧。
说回专栏吧。在思考专栏大纲时,我想着,最好能够和杨晓峰老师的 Java 核心技术专栏形成互补,呈现给大家的内容相对更偏向于技术实现。
因此,有读者曾反馈讲解的知识点是否太偏,不实用。当时我的回答是,我并不希望将专栏单纯写成一本工具书,这样的知识你可以从市面上任意买到一本书获得。
我更希望的是,能够通过介绍 Java 虚拟机各个组件的设计和实现,让你之后遇到虚拟机相关的问题时,能够联想到具体的模块,甚至是对于其他语言的运行时,也可以举一反三相互对照。
不过,当我看到 Aleksey Shipilev介绍 JMH 的讲座时,发现大部分的内容专栏里都有涉及。于是心想,我还能够在上述答复中加一句:看老外的技术讲座再也不费劲了。
还有一个想说的是关于专栏知识点的正确性。我认为虚拟机的设计可以写一些自己的理解,但是具体到目前 HotSpot 的工程实现则是确定的。
为此,几乎每篇专栏我都会大量阅读 HotSpot 的源代码,和同事讨论实现背后的设计理念,在这个过程中,我也发现了一些 HotSpot 中的 Bug或者年久失修的代码又或者是设计不合理的地方。这大概也能够算作写专栏和我本职工作重叠的地方吧。
我会仔细斟酌文章中每一句是否可以做到达意。即便是这样,文章肯定还有很多不足,比如叙述不够清楚,内容存在误导等问题。许多读者都热心地指了出来,在此感谢各位的宝贵意见。接下来一段时间,我会根据大家的建议,对前面的文章进行修订。
专栏虽然到此已经结束了,但是并不代表你对 Java 虚拟机学习的停止, 我想,专栏的内容仅仅是为你打开了 JVM 学习的大门,里面的风景,还是需要你自己来探索。在文章的后面,我列出了一系列的 Java 虚拟机技术的相关博客和阅读资料,你仍然可以继续加餐。
你可以关注国内几位 Java 虚拟机大咖的微信公众号R 大,个人认为是中文圈子里最了解 Java 虚拟机设计实现的人,你可以关注他的知乎账号;你假笨,原阿里 Java 虚拟机团队成员现PerfMa CEO江南白衣唯品会资深架构师占小狼美团基础架构部技术专家杨晓峰前甲骨文首席工程师。
如果英文阅读没问题的话你可以关注Cliff Click、Aleksey Shipilëv他的JVM Anatomy Park十分有趣和Nitsan Wakart的博客。你也可以关注Java Virtual Machine Language Submit和Oracle Code One前身是 JavaOne 大会)中关于 Java 虚拟机的演讲,以便掌握 Java 的最新发展动向。
当然,如果对 GraalVM 感兴趣的话,你可以订阅我们团队的博客。我会在之后考虑将文章逐一进行翻译。
其他的阅读材料,你可以参考 R 大的这份书单,或者这个汇总贴。
如果这个专栏激发了你对 Java 虚拟机的学习热情,那么我建议你着手去阅读 HotSpot 源代码,并且回馈给 OpenJDK 开源社区。这种回馈并不一定是提交 patch也可以是 Bug report 或者改进建议等等。

View File

@ -0,0 +1,492 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
工具篇 常用工具介绍
在前面的文章中,我曾使用了不少工具来辅助讲解,也收到了不少同学留言,说不了解这些工具,不知道都有什么用,应该怎么用。那么今天我便统一做一次具体的介绍。本篇代码较多,你可以点击文稿查看。
javap查阅 Java 字节码
javap 是一个能够将 class 文件反汇编成人类可读格式的工具。在本专栏中,我们经常借助这个工具来查阅 Java 字节码。
举个例子,在讲解异常处理那一篇中,我曾经展示过这么一段代码。
public class Foo {
private int tryBlock;
private int catchBlock;
private int finallyBlock;
private int methodExit;
public void test() {
try {
tryBlock = 0;
} catch (Exception e) {
catchBlock = 1;
} finally {
finallyBlock = 2;
}
methodExit = 3;
}
}
编译过后,我们便可以使用 javap 来查阅 Foo.test 方法的字节码。
$ javac Foo.java
$ javap -p -v Foo
Classfile ../Foo.class
Last modified ..; size 541 bytes
MD5 checksum 3828cdfbba56fea1da6c8d94fd13b20d
Compiled from "Foo.java"
public class Foo
minor version: 0
major version: 54
flags: (0x0021) ACC_PUBLIC, ACC_SUPER
this_class: #7 // Foo
super_class: #8 // java/lang/Object
interfaces: 0, fields: 4, methods: 2, attributes: 1
Constant pool:
#1 = Methodref #8.#23 // java/lang/Object."<init>":()V
#2 = Fieldref #7.#24 // Foo.tryBlock:I
#3 = Fieldref #7.#25 // Foo.finallyBlock:I
#4 = Class #26 // java/lang/Exception
#5 = Fieldref #7.#27 // Foo.catchBlock:I
#6 = Fieldref #7.#28 // Foo.methodExit:I
#7 = Class #29 // Foo
#8 = Class #30 // java/lang/Object
#9 = Utf8 tryBlock
#10 = Utf8 I
#11 = Utf8 catchBlock
#12 = Utf8 finallyBlock
#13 = Utf8 methodExit
#14 = Utf8 <init>
#15 = Utf8 ()V
#16 = Utf8 Code
#17 = Utf8 LineNumberTable
#18 = Utf8 test
#19 = Utf8 StackMapTable
#20 = Class #31 // java/lang/Throwable
#21 = Utf8 SourceFile
#22 = Utf8 Foo.java
#23 = NameAndType #14:#15 // "<init>":()V
#24 = NameAndType #9:#10 // tryBlock:I
#25 = NameAndType #12:#10 // finallyBlock:I
#26 = Utf8 java/lang/Exception
#27 = NameAndType #11:#10 // catchBlock:I
#28 = NameAndType #13:#10 // methodExit:I
#29 = Utf8 Foo
#30 = Utf8 java/lang/Object
#31 = Utf8 java/lang/Throwable
{
private int tryBlock;
descriptor: I
flags: (0x0002) ACC_PRIVATE
private int catchBlock;
descriptor: I
flags: (0x0002) ACC_PRIVATE
private int finallyBlock;
descriptor: I
flags: (0x0002) ACC_PRIVATE
private int methodExit;
descriptor: I
flags: (0x0002) ACC_PRIVATE
public Foo();
descriptor: ()V
flags: (0x0001) ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
LineNumberTable:
line 1: 0
public void test();
descriptor: ()V
flags: (0x0001) ACC_PUBLIC
Code:
stack=2, locals=3, args_size=1
0: aload_0
1: iconst_0
2: putfield #2 // Field tryBlock:I
5: aload_0
6: iconst_2
7: putfield #3 // Field finallyBlock:I
10: goto 35
13: astore_1
14: aload_0
15: iconst_1
16: putfield #5 // Field catchBlock:I
19: aload_0
20: iconst_2
21: putfield #3 // Field finallyBlock:I
24: goto 35
27: astore_2
28: aload_0
29: iconst_2
30: putfield #3 // Field finallyBlock:I
33: aload_2
34: athrow
35: aload_0
36: iconst_3
37: putfield #6 // Field methodExit:I
40: return
Exception table:
from to target type
0 5 13 Class java/lang/Exception
0 5 27 any
13 19 27 any
LineNumberTable:
line 9: 0
line 13: 5
line 14: 10
line 10: 13
line 11: 14
line 13: 19
line 14: 24
line 13: 27
line 14: 33
line 15: 35
line 16: 40
StackMapTable: number_of_entries = 3
frame_type = 77 /* same_locals_1_stack_item */
stack = [ class java/lang/Exception ]
frame_type = 77 /* same_locals_1_stack_item */
stack = [ class java/lang/Throwable ]
frame_type = 7 /* same */
}
SourceFile: "Foo.java"
这里面我用到了两个选项。第一个选项是 -p。默认情况下 javap 会打印所有非私有的字段和方法,当加了 -p 选项后,它还将打印私有的字段和方法。第二个选项是 -v。它尽可能地打印所有信息。如果你只需要查阅方法对应的字节码那么可以用 -c 选项来替换 -v。
javap 的 -v 选项的输出分为几大块。
\1. 基本信息,涵盖了原 class 文件的相关信息。
class 文件的版本号minor version: 0major version: 54该类的访问权限flags: (0x0021) ACC_PUBLIC, ACC_SUPER该类this_class: #7以及父类super_class: #8的名字所实现接口interfaces: 0、字段fields: 4、方法methods: 2以及属性attributes: 1的数目。
这里属性指的是 class 文件所携带的辅助信息,比如该 class 文件的源文件的名称。这类信息通常被用于 Java 虚拟机的验证和运行,以及 Java 程序的调试,一般无须深入了解。
Classfile ../Foo.class
Last modified ..; size 541 bytes
MD5 checksum 3828cdfbba56fea1da6c8d94fd13b20d
Compiled from "Foo.java"
public class Foo
minor version: 0
major version: 54
flags: (0x0021) ACC_PUBLIC, ACC_SUPER
this_class: #7 // Foo
super_class: #8 // java/lang/Object
interfaces: 0, fields: 4, methods: 2, attributes: 1
class 文件的版本号指的是编译生成该 class 文件时所用的 JRE 版本。由较新的 JRE 版本中的 javac 编译而成的 class 文件,不能在旧版本的 JRE 上跑否则会出现如下异常信息。Java 8 对应的版本号为 52Java 10 对应的版本号为 54。
Exception in thread "main" java.lang.UnsupportedClassVersionError: Foo has been compiled by a more recent version of the Java Runtime (class file version 54.0), this version of the Java Runtime only recognizes class file versions up to 52.0
类的访问权限通常为 ACC_ 开头的常量。具体每个常量的意义可以查阅 Java 虚拟机规范 4.1 小节 [1]。
\2. 常量池,用来存放各种常量以及符号引用。
常量池中的每一项都有一个对应的索引(如 #1),并且可能引用其他的常量池项(#1 = Methodref #8.#23)。
Constant pool:
#1 = Methodref #8.#23 // java/lang/Object."<init>":()V
...
#8 = Class #30 // java/lang/Object
...
#14 = Utf8 <init>
#15 = Utf8 ()V
...
#23 = NameAndType #14:#15 // "<init>":()V
...
#30 = Utf8 java/lang/Object
举例来说,上图中的 1 号常量池项是一个指向 Object 类构造器的符号引用。它是由另外两个常量池项所构成。如果将它看成一个树结构的话,那么它的叶节点会是字符串常量,如下图所示。
\3. 字段区域,用来列举该类中的各个字段。
这里最主要的信息便是该字段的类型descriptor: I以及访问权限flags: (0x0002) ACC_PRIVATE。对于声明为 final 的静态字段而言,如果它是基本类型或者字符串类型,那么字段区域还将包括它的常量值。
private int tryBlock;
descriptor: I
flags: (0x0002) ACC_PRIVATE
另外Java 虚拟机同样使用了“描述符”descriptor来描述字段的类型。具体的对照如下表所示。其中比较特殊的我已经高亮显示。
\4. 方法区域,用来列举该类中的各个方法。
除了方法描述符以及访问权限之外每个方法还包括最为重要的代码区域Code:)。
public void test();
descriptor: ()V
flags: (0x0001) ACC_PUBLIC
Code:
stack=2, locals=3, args_size=1
0: aload_0
...
10: goto 35
...
34: athrow
35: aload_0
...
40: return
Exception table:
from to target type
0 5 13 Class java/lang/Exception
0 5 27 any
13 19 27 any
LineNumberTable:
line 9: 0
...
line 16: 40
StackMapTable: number_of_entries = 3
frame_type = 77 /* same_locals_1_stack_item */
stack = [ class java/lang/Exception ]
...
代码区域一开始会声明该方法中的操作数栈stack=2和局部变量数目locals=3的最大值以及该方法接收参数的个数args_size=1。注意这里局部变量指的是字节码中的局部变量而非 Java 程序中的局部变量。
接下来则是该方法的字节码。每条字节码均标注了对应的偏移量bytecode indexBCI这是用来定位字节码的。比如说偏移量为 10 的跳转字节码 10: goto 35将跳转至偏移量为 35 的字节码 35: aload_0。
紧跟着的异常表Exception table:)也会使用偏移量来定位每个异常处理器所监控的范围(由 from 到 to 的代码区域以及异常处理器的起始位置target。除此之外它还会声明所捕获的异常类型type。其中any 指代任意异常类型。
再接下来的行数表LineNumberTable:)则是 Java 源程序到字节码偏移量的映射。如果你在编译时使用了 -g 参数javac -g Foo.java那么这里还将出现局部变量表LocalVariableTable:),展示 Java 程序中每个局部变量的名字、类型以及作用域。
行数表和局部变量表均属于调试信息。Java 虚拟机并不要求 class 文件必备这些信息。
LocalVariableTable:
Start Length Slot Name Signature
14 5 1 e Ljava/lang/Exception;
0 41 0 this LFoo;
最后则是字节码操作数栈的映射表StackMapTable: number_of_entries = 3。该表描述的是字节码跳转后操作数栈的分布情况一般被 Java 虚拟机用于验证所加载的类,以及即时编译相关的一些操作,正常情况下,你无须深入了解。
2.OpenJDK 项目 Code Tools实用小工具集
OpenJDK 的 Code Tools 项目 [2] 包含了好几个实用的小工具。
在第一篇的实践环节中,我们使用了其中的字节码汇编器反汇编器 ASMTools[3],当前 6.0 版本的下载地址位于 [4]。ASMTools 的反汇编以及汇编操作所对应的命令分别为:
$ java -cp /path/to/asmtools.jar org.openjdk.asmtools.jdis.Main Foo.class > Foo.jasm
$ java -cp /path/to/asmtools.jar org.openjdk.asmtools.jasm.Main Foo.jasm
该反汇编器的输出格式和 javap 的不尽相同。一般我只使用它来进行一些简单的字节码修改,以此生成无法直接由 Java 编译器生成的类,它在 HotSpot 虚拟机自身的测试中比较常见。
在第一篇的实践环节中,我们需要将整数 2 赋值到一个声明为 boolean 类型的局部变量中。我采取的做法是将编译生成的 class 文件反汇编至一个文本文件中,然后找到 boolean flag = true 对应的字节码序列,也就是下面的两个。
iconst_1;
istore_1;
将这里的 iconst_1 改为 iconst_2[5],保存后再汇编至 class 文件即可完成第一篇实践环节的需求。
除此之外,你还可以利用这一套工具来验证我之前文章中的一些结论。比如我说过 class 文件允许出现参数类型相同、而返回类型不同的方法,并且,在作为库文件时 Java 编译器将使用先定义的那一个,来决定具体的返回类型。
具体的验证方法便是在反汇编之后,利用文本编辑工具复制某一方法,并且更改该方法的描述符,保存后再汇编至 class 文件。
Code Tools 项目还包含另一个实用的小工具 JOL[6],当前 0.9 版本的下载地址位于 [7]。JOL 可用于查阅 Java 虚拟机中对象的内存分布,具体可通过如下两条指令来实现。
$ java -jar /path/to/jol-cli-0.9-full.jar internals java.util.HashMap
$ java -jar /path/to/jol-cli-0.9-full.jar estimates java.util.HashMap
3.ASMJava 字节码框架
ASM[8] 是一个字节码分析及修改框架。它被广泛应用于许多项目之中,例如 Groovy、Kotlin 的编译器,代码覆盖测试工具 Cobertura、JaCoCo以及各式各样通过字节码注入实现的程序行为监控工具。甚至是 Java 8 中 Lambda 表达式的适配器类,也是借助 ASM 来动态生成的。
ASM 既可以生成新的 class 文件,也可以修改已有的 class 文件。前者相对比较简单一些。ASM 甚至还提供了一个辅助类 ASMifier它将接收一个 class 文件并且输出一段生成该 class 文件原始字节数组的代码。如果你想快速上手 ASM 的话,那么你可以借助 ASMifier 生成的代码来探索各个 API 的用法。
下面我将借助 ASMifier来生成第一篇实践环节所用到的类。你可以通过该地址 [9] 下载 6.0-beta 版。)
$ echo '
public class Foo {
public static void main(String[] args) {
boolean flag = true;
if (flag) System.out.println("Hello, Java!");
if (flag == true) System.out.println("Hello, JVM!");
}
}' > Foo.java
# 这里的 javac 我使用的是 Java 8 版本的。ASM 6.0 可能暂不支持新版本的 javac 编译出来的 class 文件
$ javac Foo.java
$ java -cp /PATH/TO/asm-all-6.0_BETA.jar org.objectweb.asm.util.ASMifier Foo.class | tee FooDump.java
...
public class FooDump implements Opcodes {
public static byte[] dump () throws Exception {
ClassWriter cw = new ClassWriter(0);
FieldVisitor fv;
MethodVisitor mv;
AnnotationVisitor av0;
cw.visit(V1_8, ACC_PUBLIC + ACC_SUPER, "Foo", null, "java/lang/Object", null);
...
{
mv = cw.visitMethod(ACC_PUBLIC + ACC_STATIC, "main", "([Ljava/lang/String;)V", null, null);
mv.visitCode();
mv.visitInsn(ICONST_1);
mv.visitVarInsn(ISTORE, 1);
mv.visitVarInsn(ILOAD, 1);
...
mv.visitInsn(RETURN);
mv.visitMaxs(2, 2);
mv.visitEnd();
}
...
可以看到ASMifier 生成的代码中包含一个名为 FooDump 的类,其中定义了一个名为 dump 的方法。该方法将返回一个 byte 数组,其值为生成类的原始字节。
在 dump 方法中,我们新建了功能类 ClassWriter 的一个实例,并通过它来访问不同的成员,例如方法、字段等等。
每当访问一种成员,我们便会得到另一个访问者。在上面这段代码中,当我们访问方法时(即 visitMethod便会得到一个 MethodVisitor。在接下来的代码中我们会用这个 MethodVisitor 来访问(这里等同于生成)具体的指令。
这便是 ASM 所使用的访问者模式。当然,这段代码仅包含 ClassWriter 这一个访问者,因此看不出具体有什么好处。
我们暂且不管这个访问者模式先来看看如何实现第一篇课后实践的要求。首先main 方法中的 boolean flag = true; 语句对应的代码是:
mv.visitInsn(ICONST_1);
mv.visitVarInsn(ISTORE, 1);
也就是说,我们只需将这里的 ICONST_1 更改为 ICONST_2便可以满足要求。下面我用另一个类 Wrapper来调用修改过后的 FooDump.dump 方法。
$ echo 'import java.nio.file.*;
public class Wrapper {
public static void main(String[] args) throws Exception {
Files.write(Paths.get("Foo.class"), FooDump.dump());
}
}' > Wrapper.java
$ javac -cp /PATH/TO/asm-all-6.0_BETA.jar FooDump.java Wrapper.java
$ java -cp /PATH/TO/asm-all-6.0_BETA.jar:. Wrapper
$ java Foo
这里的输出结果应和通过 ASMTools 修改的结果一致。
通过 ASM 来修改已有 class 文件则相对复杂一些。不过我们可以从下面这段简单的代码来开始学起:
public static void main(String[] args) throws Exception {
ClassReader cr = new ClassReader("Foo");
ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_FRAMES);
cr.accept(cw, ClassReader.SKIP_FRAMES);
Files.write(Paths.get("Foo.class"), cw.toByteArray());
}
这段代码的功能便是读取一个 class 文件,将之转换为 ASM 的数据结构,然后再转换为原始字节数组。其中,我使用了两个功能类。除了已经介绍过的 ClassWriter 外,还有一个 ClassReader。
ClassReader 将读取“Foo”类的原始字节并且翻译成对应的访问请求。也就是说在上面 ASMifier 生成的代码中的各个访问操作,现在都交给 ClassReader.accept 这一方法来发出了。
那么,如何修改这个 class 文件的字节码呢?原理很简单,就是将 ClassReader 的访问请求发给另外一个访问者,再由这个访问者委派给 ClassWriter。
这样一来,新增操作可以通过在某一需要转发的请求后面附带新的请求来实现;删除操作可以通过不转发请求来实现;修改操作可以通过忽略原请求,新建并发出另外的请求来实现。
import java.nio.file.*;
import org.objectweb.asm.*;
public class ASMHelper implements Opcodes {
static class MyMethodVisitor extends MethodVisitor {
private MethodVisitor mv;
public MyMethodVisitor(int api, MethodVisitor mv) {
super(api, null);
this.mv = mv;
}
@Override
public void visitCode() {
mv.visitCode();
mv.visitFieldInsn(GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");
mv.visitLdcInsn("Hello, World!");
mv.visitMethodInsn(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false);
mv.visitInsn(RETURN);
mv.visitMaxs(2, 1);
mv.visitEnd();
}
}
static class MyClassVisitor extends ClassVisitor {
public MyClassVisitor(int api, ClassVisitor cv) {
super(api, cv);
}
@Override
public MethodVisitor visitMethod(int access, String name, String descriptor, String signature,
String[] exceptions) {
MethodVisitor visitor = super.visitMethod(access, name, descriptor, signature, exceptions);
if ("main".equals(name)) {
return new MyMethodVisitor(ASM6, visitor);
}
return visitor;
}
}
public static void main(String[] args) throws Exception {
ClassReader cr = new ClassReader("Foo");
ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_FRAMES);
ClassVisitor cv = new MyClassVisitor(ASM6, cw);
cr.accept(cv, ClassReader.SKIP_FRAMES);
Files.write(Paths.get("Foo.class"), cw.toByteArray());
}
}
这里我贴了一段代码,在 ClassReader 和 ClassWriter 中间插入了一个自定义的访问者 MyClassVisitor。它将截获由 ClassReader 发出的对名字为“main”的方法的访问请求并且替换为另一个自定义的 MethodVisitor。
这个 MethodVisitor 会忽略由 ClassReader 发出的任何请求,仅在遇到 visitCode 请求时生成一句“System.out.println(“Hello World!”);”。
由于篇幅的限制,我就不继续深入介绍下去了。如果你对 ASM 有浓厚的兴趣,可以参考这篇教程 [10]。
你对这些常用工具还有哪些问题呢?可以给我留言,我们一起讨论。感谢你的收听,我们下期再见。
[1]
https://docs.oracle.com/javase/specs/jvms/se10/html/jvms-4.html#jvms-4.1
[2]
http://openjdk.java.net/projects/code-tools/
[3]
https://wiki.openjdk.java.net/display/CodeTools/asmtools
[4]
https://adopt-openjdk.ci.cloudbees.com/view/OpenJDK/job/asmtools/lastSuccessfulBuild/artifact/asmtools-6.0.tar.gz
[5]
https://cs.au.dk/~mis/dOvs/jvmspec/ref21.html
[6]
http://openjdk.java.net/projects/code-tools/jol/
[7]
http://central.maven.org/maven2/org/openjdk/jol/jol-cli/0.9/jol-cli-0.9-full.jar
[8]
https://asm.ow2.io/
[9]
https://repository.ow2.org/nexus/content/repositories/releases/org/ow2/asm/asm-all/6.0_BETA/asm-all-6.0_BETA.jar
[10]
http://web.cs.ucla.edu/~msb/cs239-tutorial/

View File

@ -0,0 +1,59 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
00 开篇词 Java程序员如何快速成长
你好我是李号双很高兴你走进我的“深入拆解Tomcat & Jetty”专栏与我和其他同学一起探讨熟悉又陌生的Tomcat和Jetty。
如果你和我一样选择了Java Web开发这个方向并且正在学习和提高的路上你一定思考过这个问题
我怎样才能成长为一名高级程序员或者架构师?
对于这个问题每个人的答案都可能都不太一样我先来讲讲我的经历。十年前我在实习的时候是做嵌入式系统开发用的开发语言是C和C++。出于我个人的兴趣爱好当时我想转Java在学了一段时间的Java后发现Java上手还是挺快的API比较齐全而且也不需要自己来管理内存感觉比C语言高级。毕业后我也顺利地找到了一个Java开发的工作入职后我的工作主要是实现一些小模块很多时候通过代码的复制粘贴再稍微改改就能完成功能这样的状态大概持续了一年。
在这个过程中虽然我对Java语法更加熟悉了也“背”过一些设计模式用过一些Web框架但是我很少有机会将一些Java的高级特性运用到实际项目中因此我对它们的理解也是模糊的。那时候如果让我独立设计一个系统我会感到非常茫然不知道从哪里下手对于Web框架我也只是知道这样用是可以的不知道它背后的原理是什么。并且在我脑子里也没有一张Java Web开发的全景图比如我并不知道浏览器的请求是怎么跟Spring中的代码联系起来的。
后来我分析发现,我的知识体系在广度和深度上都有问题。为了突破这个瓶颈,我当时就想,为什么不站在巨人的肩膀上学习一些优秀的开源系统,看看大牛们是如何思考这些问题的呢。
于是我注意到了像Tomcat和Jetty这样的Web容器觉得它们很神奇只需要把Web应用打成WAR包放到它的目录下启动起来就能通过浏览器来访问了我非常好奇Web容器是如何工作的。此外Tomcat的设计非常经典并且运用了方方面面的Java技术而这些正好是我欠缺的于是我决定选择Tomcat来深入研究。
学习了Tomcat的原理之后我发现Servlet技术是Web开发的原点几乎所有的Java Web框架比如Spring都是基于Servlet的封装Spring应用本身就是一个Servlet而Tomcat和Jetty这样的Web容器负责加载和运行Servlet。你可以通过下面这张图来理解Tomcat和Jetty在Web开发中的位置。
随着学习的深入我还发现Tomcat和Jetty中用到不少Java高级技术比如Java多线程并发编程、Socket网络编程以及反射等等。之前我仅仅只是了解这些技术为了面试也背过一些题但是总感觉“知道”和“会用”之间存在一道鸿沟。通过对Tomcat和Jetty源码的学习我学会了在什么样的场景下去用这些技术这一点至关重要。
还有就是系统设计能力Tomcat和Jetty作为工业级的中间件它们的设计非常优秀比如面向接口编程、组件化、骨架抽象类、一键式启停、对象池技术以及各种设计模式比如模板方法、观察者模式、责任链模式等之后我也开始模仿它们并把这些设计思想运用到实际的工作中。
在理解了Web容器以及JVM的工作原理后我开始解决线上的疑难杂症并且尝试对线上的Tomcat进行调优。性能的提升也是实实在在的成果我也因此得到了同事们的认可。
总之在这个过程中,我逐渐建立起了自己的知识体系,也开始独立设计一个系统,独立解决技术难题,也就是说我渐渐具备了独当一面的能力,而这正是高级程序员或者架构师的特质。
概括一下,独当一面的能力,离不开技术的广度和深度。
技术的广度体现在你的知识是成体系的,从前端到后端、从应用层面到操作系统、从软件到硬件、从开发、测试、部署到运维…有些领域虽然你不需要挖得很深,但是你必须知道这其中的“门道”。
而技术的深度体现在对于某种技术,你不仅知道怎么用,还知道这项技术如何产生的、它背后的原理是什么,以及它为什么被设计成这样,甚至你还得知道如何去改进它。
但是人的精力是有限的广度和深度该如何权衡呢我建议找准一个点先突破深度而Tomcat和Jetty就是非常好的选择。但同时它们也是比较复杂的具体应该怎么学呢我想通过这个专栏来分享一些我的经验。
首先我们要学习一些基础知识比如操作系统、计算机网络、Java语言面向对象设计、HTTP协议以及Servlet规范等。
接下来我们会学习Tomcat和Jetty的总体架构并从全貌逐步深入到各个组件。在这个过程中我会重点关注组件的工作原理和设计思路比如这个组件为什么设计成这样设计者们当时是怎么考虑这个问题的。然后通过源码的剖析加深你的理解。更重要的是帮你学会在真实的场景下如何运用Java技术。
同时我还会通过Jetty与Tomcat的对比比较它们各自的设计特点让你对选型有更深的理解。并且通过思考和总结帮你从中提炼一些通用的设计原则以及实现高性能高并发的思路。
在深入了解Tomcat和Jetty的工作原理之后我会从实战出发带你看看如何监控Tomcat的性能以及怎么从内存、线程池和I/O三个方面进行调优同时我也还会分析和解决一些你在实际工作中可能会碰到的棘手问题。
在这个过程中我还会介绍Tomcat和Jetty支持的Servlet新技术比如WebSocket和异步Servlet等我会重点分析这些新技术是从何而来以及Tomcat和Jetty是如何支持的。这些都是Web技术的最新动向你可以在自己的工作中根据需要选用这些新技术。
总之弄懂了Tomcat和JettyJava Web开发对你来说就已经毫无“秘密”可言。并且你能体会到大神们是如何设计Tomcat和Jetty的体会他们如何思考问题、如何写代码。比如怎样设计服务端程序的I/O和线程模型、怎样写高性能高并发程序、Spring的IoC容器为什么设计成这个样子、设计一个中间件或者框架有哪些套路等…这些都能快速增加你的经验值。
成长的道路没有捷径不仅需要上进心和耐心还要保持对知识的好奇心。如果你也想在技术和视野上有所突破拥有独当一面的能力从Tomcat和Jetty入手是一个非常好的选择我也邀请你与我一起探究Tomcat和Jetty的设计精髓一起收获经验、享受成长。
最后如果你正在Java Web开发这条路上向着架构师的方向狂奔欢迎你给我留言讲讲你所付出的努力、遇到了哪些问题或者写写你对这个专栏的期待期待与你交流。

View File

@ -0,0 +1,91 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
01 Web容器学习路径
你好,我是李号双。在开篇词里我提到要成长为一名高级程序员或者架构师,我们需要提高自己知识的广度和深度。你可以先突破深度,再以点带面拓展广度,因此我建议通过深入学习一些优秀的开源系统来达到突破深度的目的。
我会跟你一起在这个专栏里深入学习Web容器Tomcat和Jetty而作为专栏更新的第1篇文章我想和你谈谈什么是Web容器以及怎么学习Web容器。根据我的经验在学习一门技术之前想一想这两个问题往往可以达到事半功倍的效果。
Web容器是什么
让我们先来简单回顾一下Web技术的发展历史可以帮助你理解Web容器的由来。
早期的Web应用主要用于浏览新闻等静态页面HTTP服务器比如Apache、Nginx向浏览器返回静态HTML浏览器负责解析HTML将结果呈现给用户。
随着互联网的发展我们已经不满足于仅仅浏览静态页面还希望通过一些交互操作来获取动态结果因此也就需要一些扩展机制能够让HTTP服务器调用服务端程序。
于是Sun公司推出了Servlet技术。你可以把Servlet简单理解为运行在服务端的Java小程序但是Servlet没有main方法不能独立运行因此必须把它部署到Servlet容器中由容器来实例化并调用Servlet。
而Tomcat和Jetty就是一个Servlet容器。为了方便使用它们也具有HTTP服务器的功能因此Tomcat或者Jetty就是一个“HTTP服务器 + Servlet容器”我们也叫它们Web容器。
其他应用服务器比如JBoss和WebLogic它们不仅仅有Servlet容器的功能也包含EJB容器是完整的Java EE应用服务器。从这个角度看Tomcat和Jetty算是一个轻量级的应用服务器。
在微服务架构日渐流行的今天开发人员更喜欢稳定的、轻量级的应用服务器并且应用程序用内嵌的方式来运行Servlet容器也逐渐流行起来。之所以选择轻量级是因为在微服务架构下我们把一个大而全的单体应用拆分成一个个功能单一的微服务在这个过程中服务的数量必然要增加但为了减少资源的消耗并且降低部署的成本我们希望运行服务的Web容器也是轻量级的Web容器本身应该消耗较少的内存和CPU资源并且由应用本身来启动一个嵌入式的Web容器而不是通过Web容器来部署和启动应用这样可以降低应用部署的复杂度。
因此轻量级的Tomcat和Jetty就是一个很好的选择并且Tomcat它本身也是Spring Boot默认的嵌入式Servlet容器。最新版本Tomcat和Jetty都支持Servlet 4.0规范。
读到这里我想你应该对Web容器有了基本的认识可以结合平时工作再去细细体会一下。如果你对HTTP协议和Servlet依然是一头雾水不用担心在预习模块中我还会和你聊聊你应该掌握的HTTP协议和Servlet的相关知识帮你打好学习的基础。
Web容器该怎么学
Java Web技术发展日新月异各种框架也是百花齐放。在从事Java Web开发相关的工作时面对这些眼花缭乱的技术时你是否会感到一丝迷茫可能有些初学者不知道从哪里开始我身边还有些已经进入了这个行业并且有了一定Java基础的人对于系统设计的体会可能还不够深刻编程的时候还停留在完成功能的层次。这样不仅业务上难有突破对于个人成长也很不利。
为了打破这个瓶颈就需要我们在深度上多下功夫找准一个点深挖下去彻底理解它的原理和设计精髓。并且在深入学习Tomcat和Jetty这样的Web容器之前你还需要掌握一定的基础知识这样才能达到事半功倍的效果。
下面我列举一些在学习Web容器之前需要掌握的关键点我建议你在学习专栏的同时再去复习一下这些基础知识。你可以把这些基础知识当作成为架构师的必经之路在专栏以外也要花时间深入进去。当然为了让你更好地理解专栏每期所讲的内容重点的基础知识我也会在文章里帮你再梳理一遍。
操作系统基础
Java语言其实是对操作系统API的封装上层应用包括Web容器都是通过操作系统来工作的因此掌握相关的操作系统原理是我们深刻理解Web容器的基础。
对于Web容器来说操作系统方面你应该掌握它的工作原理比如什么是进程、什么是内核、什么是内核空间和用户空间、进程间通信的方式、进程和线程的区别、线程同步的方式、什么是虚拟内存、内存分配的过程、什么是I/O、什么是I/O模型、阻塞与非阻塞的区别、同步与异步的区别、网络通信的原理、OSI七层网络模型以及TCP/IP、UDP和HTTP协议。
总之一句话基础扎实了你学什么都快。关于操作系统的学习我推荐你读一读《UNIX环境高级编程》这本经典书籍。
Java语言基础
Java的基础知识包括Java基本语法、面向对象设计的概念封装、继承、多态、接口、抽象类等、Java集合的使用、Java I/O体系、异常处理、基本的多线程并发编程包括线程同步、原子类、线程池、并发容器的使用和原理、Java网络编程I/O模型BIO、NIO、AIO的原理和相应的Java API、Java注解以及Java反射的原理等。
此外你还需要了解一些JVM的基本知识比如JVM的类加载机制、JVM内存模型、JVM内存空间分布、JVM内存和本地内存的区别以及JVM GC的原理等。
这方面我推荐的经典书籍有《Java核心技术》、《Java编程思想》、《Java并发编程实战》和《深入理解Java虚拟机JVM高级特性与最佳实践》等。
Java Web开发基础
具备了一定的操作系统和Java基础接下来就可以开始学习Java Web开发你可以开始学习一些通用的设计原则和设计模式。这个阶段的核心任务就是了解Web的工作原理同时提高你的设计能力注重代码的质量。我的建议是可以从学习Servlet和Servlet容器开始。我见过不少同学跳过这个阶段直接学Web框架这样做的话结果会事倍功半。
为什么这么说呢Web框架的本质是开发者在使用某种语言编写Web应用时总结出的一些经验和设计思路。很多Web框架都是从实际的Web项目抽取出来的其目的是用于简化Web应用程序开发。
我以Spring框架为例给你讲讲Web框架是怎么产生的。Web应用程序的开发主要是完成两方面的工作。
设计并实现类,包括定义类与类之间的关系,以及实现类的方法,方法对数据的操作就是具体的业务逻辑。
类设计好之后,需要创建这些类的实例并根据类与类的关系把它们组装在一起,这样类的实例才能一起协作完成业务功能。
就好比制造一辆汽车汽车是由零件组装而成的。第一步是画出各种零件的图纸以及定义零件之间的接口。第二步把把图纸交给工厂去生产零件并组装在一起。因此对于Web应用开发来说第一步工作是具体业务逻辑的实现每个应用都不一样。而第二步工作相对来说比较通用和标准化工厂拿到零件的图纸就知道怎么生产零件并按照零件之间的接口把它们组装起来因此这个工作就被抽取出来交给Spring框架来做。
Spring又是用容器来完成这个工作的的容器负责创建、组装和销毁这些类的实例而应用只需要通过配置文件或者注解来告诉Spring类与类之间的关系。但是容器的概念不是Spring发明的最开始来源于Servlet容器并且Servlet容器也是通过配置文件来加载Servlet的。你会发现它们的“元神”是相似的在Web应用的开发中有一些本质的东西是不变的而很多“元神”就藏在“老祖宗”那里藏在Servlet容器的设计里。
Spring框架就是对Servlet的封装Spring应用本身就是一个Servlet而Servlet容器是管理和运行Servlet的因此我们需要先理解Servlet和Servlet容器是怎样工作的才能更好地理解Spring。
本期精华
今天我谈了什么是Web容器以及该如何学习Web容器。在深入学习之前你需要掌握一些操作系统、Java和Web的基础知识。我希望你在学习专栏的过程中多温习一下这些基础知识有扎实的基础再结合专栏深入学习Web容器就比较容易了。
等你深刻理解了Web容器的工作原理和设计精髓以后你就可以把学到的知识扩展到其他领域你会发现它们的本质都是相通的这个时候你可以站在更高的角度来学习和审视各种Web框架。虽然Web框架的更新比较快但是抓住了框架的本质在学习的过程中往往会更得心应手。
不知道你有没有遇到过这样的场景,当你在看一个框架的技术细节时,会突然恍然大悟:对啊,就是应该这么设计!如果你有这种感觉,说明你的知识储备起到了作用,你对框架的运用也会更加自如。
课后思考
请你分享一下你对Web容器的理解或者你在学习、使用Web容器时遇到了哪些问题
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,也欢迎你把你的课后思考和心得记录下来,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。

View File

@ -0,0 +1,115 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
02 HTTP协议必知必会
在开始学习Web容器之前我想先问你一个问题HTTP和HTML有什么区别
为什么我会问这个问题你可以把它当作一个入门测试检测一下自己的对HTTP协议的理解。因为Tomcat和Jetty本身就是一个“HTTP服务器 + Servlet容器”如果你想深入理解Tomcat和Jetty的工作原理我认为理解HTTP协议的工作原理是学习的基础。
如果你对这个问题还稍有迟疑那么请跟我一起来回顾一下HTTP协议吧。
HTTP的本质
HTTP协议是浏览器与服务器之间的数据传送协议。作为应用层协议HTTP是基于TCP/IP协议来传递数据的HTML文件、图片、查询结果等HTTP协议不涉及数据包Packet传输主要规定了客户端和服务器之间的通信格式。
下面我通过一个例子来告诉你HTTP的本质是什么。
假如浏览器需要从远程HTTP服务器获取一个HTML文本在这个过程中浏览器实际上要做两件事情。
与服务器建立Socket连接。
生成请求数据并通过Socket发送出去。
第一步比较容易理解,浏览器从地址栏获取用户输入的网址和端口,去连接远端的服务器,这样就能通信了。
我们重点来看第二步,这个请求数据到底长什么样呢?都请求些什么内容呢?或者换句话说,浏览器需要告诉服务端什么信息呢?
首先最基本的是你要让服务端知道你的意图你是想获取内容还是提交内容其次你需要告诉服务端你想要哪个内容。那么要把这些信息以一种什么样的格式放到请求里去呢这就是HTTP协议要解决的问题。也就是说HTTP协议的本质就是一种浏览器与服务器之间约定好的通信格式。那浏览器与服务器之间具体是怎么工作的呢
HTTP工作原理
请你来看下面这张图我们过一遍一次HTTP的请求过程。
从图上你可以看到,这个过程是:
1.用户通过浏览器进行了一个操作,比如输入网址并回车,或者是点击链接,接着浏览器获取了这个事件。
2.浏览器向服务端发出TCP连接请求。
3.服务程序接受浏览器的连接请求并经过TCP三次握手建立连接。
4.浏览器将请求数据打包成一个HTTP协议格式的数据包。
5.浏览器将该数据包推入网络,数据包经过网络传输,最终达到端服务程序。
6.服务端程序拿到这个数据包后同样以HTTP协议格式解包获取到客户端的意图。
7.得知客户端意图后进行处理,比如提供静态文件或者调用服务端程序获得动态结果。
8.服务器将响应结果可能是HTML或者图片等按照HTTP协议格式打包。
9.服务器将响应数据包推入网络,数据包经过网络传输最终达到到浏览器。
10.浏览器拿到数据包后以HTTP协议的格式解包然后解析数据假设这里的数据是HTML。
11.浏览器将HTML文件展示在页面上。
那我们想要探究的Tomcat和Jetty作为一个HTTP服务器在这个过程中都做了些什么事情呢主要是接受连接、解析请求数据、处理请求和发送响应这几个步骤。这里请你注意可能有成千上万的浏览器同时请求同一个HTTP服务器因此Tomcat和Jetty为了提高服务的能力和并发度往往会将自己要做的几个事情并行化具体来说就是使用多线程的技术。这也是专栏所关注的一个重点我在后面会进行专门讲解。
HTTP请求响应实例
你有没有注意到在浏览器和HTTP服务器之间通信的过程中首先要将数据打包成HTTP协议的格式那HTTP协议的数据包具体长什么样呢这里我以极客时间的登陆请求为例用户在登陆页面输入用户名和密码点击登陆后浏览器发出了这样的HTTP请求
你可以看到HTTP请求数据由三部分组成分别是请求行、请求报头、请求正文。当这个HTTP请求数据到达Tomcat后Tomcat会把HTTP请求数据字节流解析成一个Request对象这个Request对象封装了HTTP所有的请求信息。接着Tomcat把这个Request对象交给Web应用去处理处理完后得到一个Response对象Tomcat会把这个Response对象转成HTTP格式的响应数据并发送给浏览器。
我们再来看看HTTP响应的格式HTTP的响应也是由三部分组成分别是状态行、响应报头、报文主体。同样我还以极客时间登陆请求的响应为例。
具体的HTTP协议格式你可以去网上搜索我就不再赘述了。为了更好地帮助你理解HTTP服务器比如Tomcat的工作原理接下来我想谈一谈Cookie跟Session的原理。
Cookie和Session
我们知道HTTP协议有个特点是无状态请求与请求之间是没有关系的。这样会出现一个很尴尬的问题Web应用不知道你是谁。比如你登陆淘宝后在购物车中添加了三件商品刷新一下网页这时系统提示你仍然处于未登录的状态购物车也空了很显然这种情况是不可接受的。因此HTTP协议需要一种技术让请求与请求之间建立起联系并且服务器需要知道这个请求来自哪个用户于是Cookie技术出现了。
1. Cookie技术
Cookie是HTTP报文的一个请求头Web应用可以将用户的标识信息或者其他一些信息用户名等存储在Cookie中。用户经过验证之后每次HTTP请求报文中都包含Cookie这样服务器读取这个Cookie请求头就知道用户是谁了。Cookie本质上就是一份存储在用户本地的文件里面包含了每次请求中都需要传递的信息。
2. Session技术
由于Cookie以明文的方式存储在本地而Cookie中往往带有用户信息这样就造成了非常大的安全隐患。而Session的出现解决了这个问题Session可以理解为服务器端开辟的存储空间里面保存了用户的状态用户信息以Session的形式存储在服务端。当用户请求到来时服务端可以把用户的请求和用户的Session对应起来。那么Session是怎么和请求对应起来的呢答案是通过Cookie浏览器在Cookie中填充了一个Session ID之类的字段用来标识请求。
具体工作过程是这样的服务器在创建Session的同时会为该Session生成唯一的Session ID当浏览器再次发送请求的时候会将这个Session ID带上服务器接受到请求之后就会依据Session ID找到相应的Session找到Session后就可以在Session中获取或者添加内容了。而这些内容只会保存在服务器中发到客户端的只有Session ID这样相对安全也节省了网络流量因为不需要在Cookie中存储大量用户信息。
3. Session创建与存储
那么Session在何时何地创建呢当然还是在服务器端程序运行的过程中创建的不同语言实现的应用程序有不同的创建Session的方法。在Java中是Web应用程序在调用HttpServletRequest的getSession方法时由Web容器比如Tomcat创建的。那HttpServletRequest又是什么呢别着急我们下一期再聊。
Tomcat的Session管理器提供了多种持久化方案来存储Session通常会采用高性能的存储方式比如Redis并且通过集群部署的方式防止单点故障从而提升高可用。同时Session有过期时间因此Tomcat会开启后台线程定期的轮询如果Session过期了就将Session失效。
本期精华
HTTP协议和其他应用层协议一样本质上是一种通信格式。回到文章开头我问你的问题其实答案很简单HTTP是通信的方式HTML才是通信的目的就好比HTTP是信封信封里面的信HTML才是内容但是没有信封信也没办法寄出去。HTTP协议就是浏览器与服务器之间的沟通语言具体交互过程是请求、处理和响应。
由于HTTP是无状态的协议为了识别请求是哪个用户发过来的出现了Cookie和Session技术。Cookie本质上就是一份存储在用户本地的文件里面包含了每次请求中都需要传递的信息Session可以理解为服务器端开辟的存储空间里面保存的信息用于保持状态。作为Web容器Tomcat负责创建和管理Session并提供了多种持久化方案来存储Session。
课后思考
在HTTP/1.0时期每次HTTP请求都会创建一个新的TCP连接请求完成后之后这个TCP连接就会被关闭。这种通信模式的效率不高所以在HTTP/1.1中引入了HTTP长连接的概念使用长连接的HTTP协议会在响应头加入Connection:keep-alive。这样当浏览器完成一次请求后浏览器和服务器之间的TCP连接不会关闭再次访问这个服务器上的网页时浏览器会继续使用这一条已经建立的连接也就是说两个请求可能共用一个TCP连接。
今天留给你的思考题是我在上面提到HTTP的特点是无状态的多个请求之间是没有关系的这是不是矛盾了
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,也欢迎你把你的课后思考和心得记录下来,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。

View File

@ -0,0 +1,105 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
03 你应该知道的Servlet规范和Servlet容器
通过专栏上一期的学习我们知道浏览器发给服务端的是一个HTTP格式的请求HTTP服务器收到这个请求后需要调用服务端程序来处理所谓的服务端程序就是你写的Java类一般来说不同的请求需要由不同的Java类来处理。
那么问题来了HTTP服务器怎么知道要调用哪个Java类的哪个方法呢。最直接的做法是在HTTP服务器代码里写一大堆if else逻辑判断如果是A请求就调X类的M1方法如果是B请求就调Y类的M2方法。但这样做明显有问题因为HTTP服务器的代码跟业务逻辑耦合在一起了如果新加一个业务方法还要改HTTP服务器的代码。
那该怎么解决这个问题呢我们知道面向接口编程是解决耦合问题的法宝于是有一伙人就定义了一个接口各种业务类都必须实现这个接口这个接口就叫Servlet接口有时我们也把实现了Servlet接口的业务类叫作Servlet。
但是这里还有一个问题对于特定的请求HTTP服务器如何知道由哪个Servlet来处理呢Servlet又是由谁来实例化呢显然HTTP服务器不适合做这个工作否则又和业务类耦合了。
于是还是那伙人又发明了Servlet容器Servlet容器用来加载和管理业务类。HTTP服务器不直接跟业务类打交道而是把请求交给Servlet容器去处理Servlet容器会将请求转发到具体的Servlet如果这个Servlet还没创建就加载并实例化这个Servlet然后调用这个Servlet的接口方法。因此Servlet接口其实是Servlet容器跟具体业务类之间的接口。下面我们通过一张图来加深理解。
图的左边表示HTTP服务器直接调用具体业务类它们是紧耦合的。再看图的右边HTTP服务器不直接调用业务类而是把请求交给容器来处理容器通过Servlet接口调用业务类。因此Servlet接口和Servlet容器的出现达到了HTTP服务器与业务类解耦的目的。
而Servlet接口和Servlet容器这一整套规范叫作Servlet规范。Tomcat和Jetty都按照Servlet规范的要求实现了Servlet容器同时它们也具有HTTP服务器的功能。作为Java程序员如果我们要实现新的业务功能只需要实现一个Servlet并把它注册到TomcatServlet容器剩下的事情就由Tomcat帮我们处理了。
接下来我们来看看Servlet接口具体是怎么定义的以及Servlet规范又有哪些要重点关注的地方呢
Servlet接口
Servlet接口定义了下面五个方法
public interface Servlet {
void init(ServletConfig config) throws ServletException;
ServletConfig getServletConfig();
void service(ServletRequest req, ServletResponse resthrows ServletException, IOException;
String getServletInfo();
void destroy();
}
其中最重要是的service方法具体业务类在这个方法里实现处理逻辑。这个方法有两个参数ServletRequest和ServletResponse。ServletRequest用来封装请求信息ServletResponse用来封装响应信息因此本质上这两个类是对通信协议的封装。
比如HTTP协议中的请求和响应就是对应了HttpServletRequest和HttpServletResponse这两个类。你可以通过HttpServletRequest来获取所有请求相关的信息包括请求路径、Cookie、HTTP头、请求参数等。此外我在专栏上一期提到过我们还可以通过HttpServletRequest来创建和获取Session。而HttpServletResponse是用来封装HTTP响应的。
你可以看到接口中还有两个跟生命周期有关的方法init和destroy这是一个比较贴心的设计Servlet容器在加载Servlet类的时候会调用init方法在卸载的时候会调用destroy方法。我们可能会在init方法里初始化一些资源并在destroy方法里释放这些资源比如Spring MVC中的DispatcherServlet就是在init方法里创建了自己的Spring容器。
你还会注意到ServletConfig这个类ServletConfig的作用就是封装Servlet的初始化参数。你可以在web.xml给Servlet配置参数并在程序里通过getServletConfig方法拿到这些参数。
我们知道有接口一般就有抽象类抽象类用来实现接口和封装通用的逻辑因此Servlet规范提供了GenericServlet抽象类我们可以通过扩展它来实现Servlet。虽然Servlet规范并不在乎通信协议是什么但是大多数的Servlet都是在HTTP环境中处理的因此Servet规范还提供了HttpServlet来继承GenericServlet并且加入了HTTP特性。这样我们通过继承HttpServlet类来实现自己的Servlet只需要重写两个方法doGet和doPost。
Servlet容器
我在前面提到为了解耦HTTP服务器不直接调用Servlet而是把请求交给Servlet容器来处理那Servlet容器又是怎么工作的呢接下来我会介绍Servlet容器大体的工作流程一起来聊聊我们非常关心的两个话题Web应用的目录格式是什么样的以及我该怎样扩展和定制化Servlet容器的功能。
工作流程
当客户请求某个资源时HTTP服务器会用一个ServletRequest对象把客户的请求信息封装起来然后调用Servlet容器的service方法Servlet容器拿到请求后根据请求的URL和Servlet的映射关系找到相应的Servlet如果Servlet还没有被加载就用反射机制创建这个Servlet并调用Servlet的init方法来完成初始化接着调用Servlet的service方法来处理请求把ServletResponse对象返回给HTTP服务器HTTP服务器会把响应发送给客户端。同样我通过一张图来帮助你理解。
Web应用
Servlet容器会实例化和调用Servlet那Servlet是怎么注册到Servlet容器中的呢一般来说我们是以Web应用程序的方式来部署Servlet的而根据Servlet规范Web应用程序有一定的目录结构在这个目录下分别放置了Servlet的类文件、配置文件以及静态资源Servlet容器通过读取配置文件就能找到并加载Servlet。Web应用的目录结构大概是下面这样的
| - MyWebApp
| - WEB-INF/web.xml -- 配置文件用来配置Servlet等
| - WEB-INF/lib/ -- 存放Web应用所需各种JAR包
| - WEB-INF/classes/ -- 存放你的应用类比如Servlet类
| - META-INF/ -- 目录存放工程的一些信息
Servlet规范里定义了ServletContext这个接口来对应一个Web应用。Web应用部署好后Servlet容器在启动时会加载Web应用并为每个Web应用创建唯一的ServletContext对象。你可以把ServletContext看成是一个全局对象一个Web应用可能有多个Servlet这些Servlet可以通过全局的ServletContext来共享数据这些数据包括Web应用的初始化参数、Web应用目录下的文件资源等。由于ServletContext持有所有Servlet实例你还可以通过它来实现Servlet请求的转发。
扩展机制
不知道你有没有发现引入了Servlet规范后你不需要关心Socket网络通信、不需要关心HTTP协议也不需要关心你的业务类是如何被实例化和调用的因为这些都被Servlet规范标准化了你只要关心怎么实现的你的业务逻辑。这对于程序员来说是件好事但也有不方便的一面。所谓规范就是说大家都要遵守就会千篇一律但是如果这个规范不能满足你的业务的个性化需求就有问题了因此设计一个规范或者一个中间件要充分考虑到可扩展性。Servlet规范提供了两种扩展机制Filter和Listener。
Filter是过滤器这个接口允许你对请求和响应做一些统一的定制化处理比如你可以根据请求的频率来限制访问或者根据国家地区的不同来修改响应内容。过滤器的工作原理是这样的Web应用部署完成后Servlet容器需要实例化Filter并把Filter链接成一个FilterChain。当请求进来时获取第一个Filter并调用doFilter方法doFilter方法负责调用这个FilterChain中的下一个Filter。
Listener是监听器这是另一种扩展机制。当Web应用在Servlet容器中运行时Servlet容器内部会不断的发生各种事件如Web应用的启动和停止、用户请求到达等。 Servlet容器提供了一些默认的监听器来监听这些事件当事件发生时Servlet容器会负责调用监听器的方法。当然你可以定义自己的监听器去监听你感兴趣的事件将监听器配置在web.xml中。比如Spring就实现了自己的监听器来监听ServletContext的启动事件目的是当Servlet容器启动时创建并初始化全局的Spring容器。
到这里相信你对Servlet容器的工作原理有了深入的了解只有理解了这些原理我们才能更好的理解Tomcat和Jetty因为它们都是Servlet容器的具体实现。后面我还会详细谈到Tomcat和Jetty是如何设计和实现Servlet容器的虽然它们的实现方法各有特点但是都遵守了Servlet规范因此你的Web应用可以在这两个Servlet容器中方便的切换。
本期精华
今天我们学习了什么是Servlet回顾一下Servlet本质上是一个接口实现了Servlet接口的业务类也叫Servlet。Servlet接口其实是Servlet容器跟具体Servlet业务类之间的接口。Servlet接口跟Servlet容器这一整套规范叫作Servlet规范而Servlet规范使得程序员可以专注业务逻辑的开发同时Servlet规范也给开发者提供了扩展的机制Filter和Listener。
最后我给你总结一下Filter和Listener的本质区别
Filter是干预过程的它是过程的一部分是基于过程行为的。
Listener是基于状态的任何行为改变同一个状态触发的事件是一致的。
课后思考
Servlet容器与Spring容器有什么关系
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,也欢迎你把你的课后思考和心得记录下来,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。

View File

@ -0,0 +1,250 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
04 实战纯手工打造和运行一个Servlet
作为Java程序员我们可能已经习惯了使用IDE和Web框架进行开发IDE帮我们做了编译、打包的工作而Spring框架在背后帮我们实现了Servlet接口并把Servlet注册到了Web容器这样我们可能很少有机会接触到一些底层本质的东西比如怎么开发一个Servlet如何编译Servlet如何在Web容器中跑起来
今天我们就抛弃IDE、拒绝框架自己纯手工编写一个Servlet并在Tomcat中运行起来。一方面进一步加深对Servlet的理解另一方面还可以熟悉一下Tomcat的基本功能使用。
主要的步骤有:
1.下载并安装Tomcat。-
2.编写一个继承HttpServlet的Java类。-
3.将Java类文件编译成Class文件。-
4.建立Web应用的目录结构并配置web.xml。-
5.部署Web应用。-
6.启动Tomcat。-
7.浏览器访问验证结果。-
8.查看Tomcat日志。
下面你可以跟我一起一步步操作来完成整个过程。Servlet 3.0规范支持用注解的方式来部署Servlet不需要在web.xml里配置最后我会演示怎么用注解的方式来部署Servlet。
1. 下载并安装Tomcat
最新版本的Tomcat可以直接在官网上下载根据你的操作系统下载相应的版本这里我使用的是Mac系统下载完成后直接解压解压后的目录结构如下。
下面简单介绍一下这些目录:
/bin存放Windows或Linux平台上启动和关闭Tomcat的脚本文件。-
/conf存放Tomcat的各种全局配置文件其中最重要的是server.xml。-
/lib存放Tomcat以及所有Web应用都可以访问的JAR文件。-
/logs存放Tomcat执行时产生的日志文件。-
/work存放JSP编译后产生的Class文件。-
/webappsTomcat的Web应用目录默认情况下把Web应用放在这个目录下。
2. 编写一个继承HttpServlet的Java类
我在专栏上一期提到javax.servlet包提供了实现Servlet接口的GenericServlet抽象类。这是一个比较方便的类可以通过扩展它来创建Servlet。但是大多数的Servlet都在HTTP环境中处理请求因此Servlet规范还提供了HttpServlet来扩展GenericServlet并且加入了HTTP特性。我们通过继承HttpServlet类来实现自己的Servlet只需要重写两个方法doGet和doPost。
因此今天我们创建一个Java类去继承HttpServlet类并重写doGet和doPost方法。首先新建一个名为MyServlet.java的文件敲入下面这些代码
import java.io.IOException;
import java.io.PrintWriter;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
public class MyServlet extends HttpServlet {
@Override
protected void doGet(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
System.out.println("MyServlet 在处理get请求...");
PrintWriter out = response.getWriter();
response.setContentType("text/html;charset=utf-8");
out.println("<strong>My Servlet!</strong><br>");
}
@Override
protected void doPost(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
System.out.println("MyServlet 在处理post请求...");
PrintWriter out = response.getWriter();
response.setContentType("text/html;charset=utf-8");
out.println("<strong>My Servlet!</strong><br>");
}
}
这个Servlet完成的功能很简单分别在doGet和doPost方法体里返回一段简单的HTML。
3. 将Java文件编译成Class文件
下一步我们需要把MyServlet.java文件编译成Class文件。你需要先安装JDK这里我使用的是JDK 10。接着你需要把Tomcat lib目录下的servlet-api.jar拷贝到当前目录下这是因为servlet-api.jar中定义了Servlet接口而我们的Servlet类实现了Servlet接口因此编译Servlet类需要这个JAR包。接着我们执行编译命令
javac -cp ./servlet-api.jar MyServlet.java
编译成功后你会在当前目录下找到一个叫MyServlet.class的文件。
4. 建立Web应用的目录结构
我们在上一期学到Servlet是放到Web应用部署到Tomcat的而Web应用具有一定的目录结构所有我们按照要求建立Web应用文件夹名字叫MyWebApp然后在这个目录下建立子文件夹像下面这样
MyWebApp/WEB-INF/web.xml
MyWebApp/WEB-INF/classes/MyServlet.class
然后在web.xml中配置Servlet内容如下
<?xml version="1.0" encoding="UTF-8"?>
<web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee
http://xmlns.jcp.org/xml/ns/javaee/web-app_4_0.xsd"
version="4.0"
metadata-complete="true">
<description> Servlet Example. </description>
<display-name> MyServlet Example </display-name>
<request-character-encoding>UTF-8</request-character-encoding>
<servlet>
<servlet-name>myServlet</servlet-name>
<servlet-class>MyServlet</servlet-class>
</servlet>
<servlet-mapping>
<servlet-name>myServlet</servlet-name>
<url-pattern>/myservlet</url-pattern>
</servlet-mapping>
</web-app>
你可以看到在web.xml配置了Servlet的名字和具体的类以及这个Servlet对应的URL路径。请你注意servlet和servlet-mapping这两个标签里的servlet-name要保持一致。
5. 部署Web应用
Tomcat应用的部署非常简单将这个目录MyWebApp拷贝到Tomcat的安装目录下的webapps目录即可。
6. 启动Tomcat
找到Tomcat安装目录下的bin目录根据操作系统的不同执行相应的启动脚本。如果是Windows系统执行startup.bat.如果是Linux系统则执行startup.sh。
7. 浏览访问验证结果
在浏览器里访问这个URLhttp://localhost:8080/MyWebApp/myservlet你会看到
My Servlet!
这里需要注意访问URL路径中的MyWebApp是Web应用的名字myservlet是在web.xml里配置的Servlet的路径。
8. 查看Tomcat日志
打开Tomcat的日志目录也就是Tomcat安装目录下的logs目录。Tomcat的日志信息分为两类 :一是运行日志,它主要记录运行过程中的一些信息,尤其是一些异常错误日志信息 二是访问日志它记录访问的时间、IP地址、访问的路径等相关信息。
这里简要介绍各个文件的含义。
catalina.***.log
主要是记录Tomcat启动过程的信息在这个文件可以看到启动的JVM参数以及操作系统等日志信息。
catalina.out
catalina.out是Tomcat的标准输出stdout和标准错误stderr这是在Tomcat的启动脚本里指定的如果没有修改的话stdout和stderr会重定向到这里。所以在这个文件里可以看到我们在MyServlet.java程序里打印出来的信息
MyServlet在处理get请求…
localhost.**.log
主要记录Web应用在初始化过程中遇到的未处理的异常会被Tomcat捕获而输出这个日志文件。
localhost_access_log.**.txt
存放访问Tomcat的请求日志包括IP地址以及请求的路径、时间、请求协议以及状态码等信息。
manager.***.log/host-manager.***.log
存放Tomcat自带的Manager项目的日志信息。
用注解的方式部署Servlet
为了演示用注解的方式来部署Servlet我们首先修改Java代码给Servlet类加上@WebServlet注解,修改后的代码如下。
import java.io.IOException;
import java.io.PrintWriter;
import javax.servlet.ServletException;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
@WebServlet("/myAnnotationServlet")
public class AnnotationServlet extends HttpServlet {
@Override
protected void doGet(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
System.out.println("AnnotationServlet 在处理get请求...");
PrintWriter out = response.getWriter();
response.setContentType("text/html; charset=utf-8");
out.println("<strong>Annotation Servlet!</strong><br>");
}
@Override
protected void doPost(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
System.out.println("AnnotationServlet 在处理post请求...");
PrintWriter out = response.getWriter();
response.setContentType("text/html; charset=utf-8");
out.println("<strong>Annotation Servlet!</strong><br>");
}
}
这段代码里最关键的就是这个注解它表明两层意思第一层意思是AnnotationServlet这个Java类是一个Servlet第二层意思是这个Servlet对应的URL路径是myAnnotationServlet。
@WebServlet("/myAnnotationServlet")
创建好Java类以后同样经过编译并放到MyWebApp的class目录下。这里要注意的是你需要删除原来的web.xml因为我们不需要web.xml来配置Servlet了。然后重启Tomcat接下来我们验证一下这个新的AnnotationServlet有没有部署成功。在浏览器里输入http://localhost:8080/MyWebApp/myAnnotationServlet得到结果
Annotation Servlet!
这说明我们的AnnotationServlet部署成功了。可以通过注解完成web.xml所有的配置功能包括Servlet初始化参数以及配置Filter和Listener等。
本期精华
通过今天的学习和实践相信你掌握了如何通过扩展HttpServlet来实现自己的Servlet知道了如何编译Servlet、如何通过web.xml来部署Servlet同时还练习了如何启动Tomcat、如何查看Tomcat的各种日志并且还掌握了如何通过注解的方式来部署Servlet。我相信通过专栏前面文章的学习加上今天的练习实践一定会加深你对Servlet工作原理的理解。之所以我设置今天的实战练习是希望你知道IDE和Web框架在背后为我们做了哪些事情这对于我们排查问题非常重要因为只有我们明白了IDE和框架在背后做的事情一旦出现问题的时候我们才能判断它们做得对不对否则可能开发环境里的一个小问题就会折腾我们半天。
课后思考
我在Servlet类里同时实现了doGet方法和doPost方法从浏览器的网址访问默认访问的是doGet方法今天的课后思考题是如何访问这个doPost方法。
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,也欢迎你把你的课后思考和心得记录下来,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。

View File

@ -0,0 +1,173 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
05 Tomcat系统架构 连接器是如何设计的
05 Tomcat系统架构 连接器是如何设计的?
在面试时我们可能经常被问到你做的XX项目的架构是如何设计的请讲一下实现的思路。对于面试官来说可以通过你对复杂系统设计的理解了解你的技术水平以及处理复杂问题的思路。
今天咱们就来一步一步分析Tomcat的设计思路看看Tomcat的设计者们当时是怎么回答这个问题的。一方面我们可以学到Tomcat的总体架构学会从宏观上怎么去设计一个复杂系统怎么设计顶层模块以及模块之间的关系另一方面也为我们深入学习Tomcat的工作原理打下基础。
Tomcat总体架构
我们知道如果要设计一个系统首先是要了解需求。通过专栏前面的文章我们已经了解了Tomcat要实现2个核心功能
处理Socket连接负责网络字节流与Request和Response对象的转化。
加载和管理Servlet以及具体处理Request请求。
因此Tomcat设计了两个核心组件连接器Connector和容器Container来分别做这两件事情。连接器负责对外交流容器负责内部处理。
所以连接器和容器可以说是Tomcat架构里最重要的两部分需要你花些精力理解清楚。这两部分内容我会分成两期今天我来分析连接器是如何设计的下一期我会介绍容器的设计。
在开始讲连接器前我先铺垫一下Tomcat支持的多种I/O模型和应用层协议。
Tomcat支持的I/O模型有
NIO非阻塞I/O采用Java NIO类库实现。
NIO.2异步I/O采用JDK 7最新的NIO.2类库实现。
APR采用Apache可移植运行库实现是C/C++编写的本地库。
Tomcat支持的应用层协议有
HTTP/1.1这是大部分Web应用采用的访问协议。
AJP用于和Web服务器集成如Apache
HTTP/2HTTP 2.0大幅度的提升了Web性能。
Tomcat为了实现支持多种I/O模型和应用层协议一个容器可能对接多个连接器就好比一个房间有多个门。但是单独的连接器或者容器都不能对外提供服务需要把它们组装起来才能工作组装后这个整体叫作Service组件。这里请你注意Service本身没有做什么重要的事情只是在连接器和容器外面多包了一层把它们组装在一起。Tomcat内可能有多个Service这样的设计也是出于灵活性的考虑。通过在Tomcat中配置多个Service可以实现通过不同的端口号来访问同一台机器上部署的不同应用。
到此我们得到这样一张关系图:
从图上你可以看到最顶层是Server这里的Server指的就是一个Tomcat实例。一个Server中有一个或者多个Service一个Service中有多个连接器和一个容器。连接器与容器之间通过标准的ServletRequest和ServletResponse通信。
连接器
连接器对Servlet容器屏蔽了协议及I/O模型等的区别无论是HTTP还是AJP在容器中获取到的都是一个标准的ServletRequest对象。
我们可以把连接器的功能需求进一步细化,比如:
监听网络端口。
接受网络连接请求。
读取网络请求字节流。
根据具体应用层协议HTTP/AJP解析字节流生成统一的Tomcat Request对象。
将Tomcat Request对象转成标准的ServletRequest。
调用Servlet容器得到ServletResponse。
将ServletResponse转成Tomcat Response对象。
将Tomcat Response转成网络字节流。
将响应字节流写回给浏览器。
需求列清楚后,我们要考虑的下一个问题是,连接器应该有哪些子模块?优秀的模块化设计应该考虑高内聚、低耦合。
高内聚是指相关度比较高的功能要尽可能集中,不要分散。
低耦合是指两个相关的模块要尽可能减少依赖的部分和降低依赖的程度,不要让两个模块产生强依赖。
通过分析连接器的详细功能列表我们发现连接器需要完成3个高内聚的功能
网络通信。
应用层协议解析。
Tomcat Request/Response与ServletRequest/ServletResponse的转化。
因此Tomcat的设计者设计了3个组件来实现这3个功能分别是Endpoint、Processor和Adapter。
组件之间通过抽象接口交互。这样做还有一个好处是封装变化。这是面向对象设计的精髓,将系统中经常变化的部分和稳定的部分隔离,有助于增加复用性,并降低系统耦合度。
网络通信的I/O模型是变化的可能是非阻塞I/O、异步I/O或者APR。应用层协议也是变化的可能是HTTP、HTTPS、AJP。浏览器端发送的请求信息也是变化的。
但是整体的处理逻辑是不变的Endpoint负责提供字节流给ProcessorProcessor负责提供Tomcat Request对象给AdapterAdapter负责提供ServletRequest对象给容器。
如果要支持新的I/O方案、新的应用层协议只需要实现相关的具体子类上层通用的处理逻辑是不变的。
由于I/O模型和应用层协议可以自由组合比如NIO + HTTP或者NIO.2 + AJP。Tomcat的设计者将网络通信和应用层协议解析放在一起考虑设计了一个叫ProtocolHandler的接口来封装这两种变化点。各种协议和通信模型的组合有相应的具体实现类。比如Http11NioProtocol和AjpNioProtocol。
除了这些变化点系统也存在一些相对稳定的部分因此Tomcat设计了一系列抽象基类来封装这些稳定的部分抽象基类AbstractProtocol实现了ProtocolHandler接口。每一种应用层协议有自己的抽象基类比如AbstractAjpProtocol和AbstractHttp11Protocol具体协议的实现类扩展了协议层抽象基类。下面我整理一下它们的继承关系。
通过上面的图你可以清晰地看到它们的继承和层次关系这样设计的目的是尽量将稳定的部分放到抽象基类同时每一种I/O模型和协议的组合都有相应的具体实现类我们在使用时可以自由选择。
小结一下连接器模块用三个核心组件Endpoint、Processor和Adapter来分别做三件事情其中Endpoint和Processor放在一起抽象成了ProtocolHandler组件它们的关系如下图所示。
下面我来详细介绍这两个顶层组件ProtocolHandler和Adapter。
ProtocolHandler组件
由上文我们知道连接器用ProtocolHandler来处理网络连接和应用层协议包含了2个重要部件Endpoint和Processor下面我来详细介绍它们的工作原理。
Endpoint
Endpoint是通信端点即通信监听的接口是具体的Socket接收和发送处理器是对传输层的抽象因此Endpoint是用来实现TCP/IP协议的。
Endpoint是一个接口对应的抽象实现类是AbstractEndpoint而AbstractEndpoint的具体子类比如在NioEndpoint和Nio2Endpoint中有两个重要的子组件Acceptor和SocketProcessor。
其中Acceptor用于监听Socket连接请求。SocketProcessor用于处理接收到的Socket请求它实现Runnable接口在run方法里调用协议处理组件Processor进行处理。为了提高处理能力SocketProcessor被提交到线程池来执行。而这个线程池叫作执行器Executor)我在后面的专栏会详细介绍Tomcat如何扩展原生的Java线程池。
Processor
如果说Endpoint是用来实现TCP/IP协议的那么Processor用来实现HTTP协议Processor接收来自Endpoint的Socket读取字节流解析成Tomcat Request和Response对象并通过Adapter将其提交到容器处理Processor是对应用层协议的抽象。
Processor是一个接口定义了请求的处理等方法。它的抽象实现类AbstractProcessor对一些协议共有的属性进行封装没有对方法进行实现。具体的实现有AjpProcessor、Http11Processor等这些具体实现类实现了特定协议的解析方法和请求处理方式。
我们再来看看连接器的组件图:
从图中我们看到Endpoint接收到Socket连接后生成一个SocketProcessor任务提交到线程池去处理SocketProcessor的run方法会调用Processor组件去解析应用层协议Processor通过解析生成Request对象后会调用Adapter的Service方法。
到这里我们学习了ProtocolHandler的总体架构和工作原理关于Endpoint的详细设计后面我还会专门介绍Endpoint是如何最大限度地利用Java NIO的非阻塞以及NIO.2的异步特性,来实现高并发。
Adapter组件
我在前面说过由于协议不同客户端发过来的请求信息也不尽相同Tomcat定义了自己的Request类来“存放”这些请求信息。ProtocolHandler接口负责解析请求并生成Tomcat Request类。但是这个Request对象不是标准的ServletRequest也就意味着不能用Tomcat Request作为参数来调用容器。Tomcat设计者的解决方案是引入CoyoteAdapter这是适配器模式的经典运用连接器调用CoyoteAdapter的sevice方法传入的是Tomcat Request对象CoyoteAdapter负责将Tomcat Request转成ServletRequest再调用容器的service方法。
本期精华
Tomcat的整体架构包含了两个核心组件连接器和容器。连接器负责对外交流容器负责内部处理。连接器用ProtocolHandler接口来封装通信协议和I/O模型的差异ProtocolHandler内部又分为Endpoint和Processor模块Endpoint负责底层Socket通信Processor负责应用层协议解析。连接器通过适配器Adapter调用容器。
通过对Tomcat整体架构的学习我们可以得到一些设计复杂系统的基本思路。首先要分析需求根据高内聚低耦合的原则确定子模块然后找出子模块中的变化点和不变点用接口和抽象基类去封装不变点在抽象基类中定义模板方法让子类自行实现抽象方法也就是具体子类去实现变化点。
课后思考
回忆一下你在工作中曾经独立设计过的系统,或者你碰到过的设计类面试题,结合今天专栏的内容,你有没有一些新的思路?
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,也欢迎你把你的课后思考和心得记录下来,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。

View File

@ -0,0 +1,136 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
06 Tomcat系统架构聊聊多层容器的设计
专栏上一期我们学完了连接器的设计今天我们一起来看一下Tomcat的容器设计。先复习一下上期我讲到了Tomcat有两个核心组件连接器和容器其中连接器负责外部交流容器负责内部处理。具体来说就是连接器处理Socket通信和应用层协议的解析得到Servlet请求而容器则负责处理Servlet请求。我们通过下面这张图来回忆一下。
容器顾名思义就是用来装载东西的器具在Tomcat里容器就是用来装载Servlet的。那Tomcat的Servlet容器是如何设计的呢
容器的层次结构
Tomcat设计了4种容器分别是Engine、Host、Context和Wrapper。这4种容器不是平行关系而是父子关系。下面我画了一张图帮你理解它们的关系。
你可能会问为什么要设计成这么多层次的容器这不是增加了复杂度吗其实这背后的考虑是Tomcat通过一种分层的架构使得Servlet容器具有很好的灵活性。
Context表示一个Web应用程序Wrapper表示一个Servlet一个Web应用程序中可能会有多个ServletHost代表的是一个虚拟主机或者说一个站点可以给Tomcat配置多个虚拟主机地址而一个虚拟主机下可以部署多个Web应用程序Engine表示引擎用来管理多个虚拟站点一个Service最多只能有一个Engine。
你可以再通过Tomcat的server.xml配置文件来加深对Tomcat容器的理解。Tomcat采用了组件化的设计它的构成组件都是可配置的其中最外层的是Server其他组件按照一定的格式要求配置在这个顶层容器中。
那么Tomcat是怎么管理这些容器的呢你会发现这些容器具有父子关系形成一个树形结构你可能马上就想到了设计模式中的组合模式。没错Tomcat就是用组合模式来管理这些容器的。具体实现方法是所有容器组件都实现了Container接口因此组合模式可以使得用户对单容器对象和组合容器对象的使用具有一致性。这里单容器对象指的是最底层的Wrapper组合容器对象指的是上面的Context、Host或者Engine。Container接口定义如下
public interface Container extends Lifecycle {
public void setName(String name);
public Container getParent();
public void setParent(Container container);
public void addChild(Container child);
public void removeChild(Container child);
public Container findChild(String name);
}
正如我们期望的那样我们在上面的接口看到了getParent、setParent、addChild和removeChild等方法。你可能还注意到Container接口扩展了Lifecycle接口Lifecycle接口用来统一管理各组件的生命周期后面我也用专门的篇幅去详细介绍。
请求定位Servlet的过程
你可能好奇设计了这么多层次的容器Tomcat是怎么确定请求是由哪个Wrapper容器里的Servlet来处理的呢答案是Tomcat是用Mapper组件来完成这个任务的。
Mapper组件的功能就是将用户请求的URL定位到一个Servlet它的工作原理是Mapper组件里保存了Web应用的配置信息其实就是容器组件与访问路径的映射关系比如Host容器里配置的域名、Context容器里的Web应用路径以及Wrapper容器里Servlet映射的路径你可以想象这些配置信息就是一个多层次的Map。
当一个请求到来时Mapper组件通过解析请求URL里的域名和路径再到自己保存的Map里去查找就能定位到一个Servlet。请你注意一个请求URL最后只会定位到一个Wrapper容器也就是一个Servlet。
读到这里你可能感到有些抽象,接下来我通过一个例子来解释这个定位的过程。
假如有一个网购系统有面向网站管理人员的后台管理系统还有面向终端客户的在线购物系统。这两个系统跑在同一个Tomcat上为了隔离它们的访问域名配置了两个虚拟域名manage.shopping.com和user.shopping.com网站管理人员通过manage.shopping.com域名访问Tomcat去管理用户和商品而用户管理和商品管理是两个单独的Web应用。终端客户通过user.shopping.com域名去搜索商品和下订单搜索功能和订单管理也是两个独立的Web应用。
针对这样的部署Tomcat会创建一个Service组件和一个Engine容器组件在Engine容器下创建两个Host子容器在每个Host容器下创建两个Context子容器。由于一个Web应用通常有多个ServletTomcat还会在每个Context容器里创建多个Wrapper子容器。每个容器都有对应的访问路径你可以通过下面这张图来帮助你理解。
假如有用户访问一个URL比如图中的http://user.shopping.com:8080/order/buyTomcat如何将这个URL定位到一个Servlet呢
首先根据协议和端口号选定Service和Engine。
我们知道Tomcat的每个连接器都监听不同的端口比如Tomcat默认的HTTP连接器监听8080端口、默认的AJP连接器监听8009端口。上面例子中的URL访问的是8080端口因此这个请求会被HTTP连接器接收而一个连接器是属于一个Service组件的这样Service组件就确定了。我们还知道一个Service组件里除了有多个连接器还有一个容器组件具体来说就是一个Engine容器因此Service确定了也就意味着Engine也确定了。
然后根据域名选定Host。
Service和Engine确定后Mapper组件通过URL中的域名去查找相应的Host容器比如例子中的URL访问的域名是user.shopping.com因此Mapper会找到Host2这个容器。
之后根据URL路径找到Context组件。
Host确定以后Mapper根据URL的路径来匹配相应的Web应用的路径比如例子中访问的是/order因此找到了Context4这个Context容器。
最后根据URL路径找到WrapperServlet
Context确定后Mapper再根据web.xml中配置的Servlet映射路径来找到具体的Wrapper和Servlet。
看到这里我想你应该已经了解了什么是容器以及Tomcat如何通过一层一层的父子容器找到某个Servlet来处理请求。需要注意的是并不是说只有Servlet才会去处理请求实际上这个查找路径上的父子容器都会对请求做一些处理。我在上一期说过连接器中的Adapter会调用容器的Service方法来执行Servlet最先拿到请求的是Engine容器Engine容器对请求做一些处理后会把请求传给自己子容器Host继续处理依次类推最后这个请求会传给Wrapper容器Wrapper会调用最终的Servlet来处理。那么这个调用过程具体是怎么实现的呢答案是使用Pipeline-Valve管道。
Pipeline-Valve是责任链模式责任链模式是指在一个请求处理的过程中有很多处理者依次对请求进行处理每个处理者负责做自己相应的处理处理完之后将再调用下一个处理者继续处理。
Valve表示一个处理点比如权限认证和记录日志。如果你还不太理解的话可以来看看Valve和Pipeline接口中的关键方法。
public interface Valve {
public Valve getNext();
public void setNext(Valve valve);
public void invoke(Request request, Response response)
}
由于Valve是一个处理点因此invoke方法就是来处理请求的。注意到Valve中有getNext和setNext方法因此我们大概可以猜到有一个链表将Valve链起来了。请你继续看Pipeline接口
public interface Pipeline extends Contained {
public void addValve(Valve valve);
public Valve getBasic();
public void setBasic(Valve valve);
public Valve getFirst();
}
没错Pipeline中有addValve方法。Pipeline中维护了Valve链表Valve可以插入到Pipeline中对请求做某些处理。我们还发现Pipeline中没有invoke方法因为整个调用链的触发是Valve来完成的Valve完成自己的处理后调用getNext.invoke来触发下一个Valve调用。
每一个容器都有一个Pipeline对象只要触发这个Pipeline的第一个Valve这个容器里Pipeline中的Valve就都会被调用到。但是不同容器的Pipeline是怎么链式触发的呢比如Engine中Pipeline需要调用下层容器Host中的Pipeline。
这是因为Pipeline中还有个getBasic方法。这个BasicValve处于Valve链表的末端它是Pipeline中必不可少的一个Valve负责调用下层容器的Pipeline里的第一个Valve。我还是通过一张图来解释。
整个调用过程由连接器中的Adapter触发的它会调用Engine的第一个Valve
// Calling the container
connector.getService().getContainer().getPipeline().getFirst().invoke(request, response);
Wrapper容器的最后一个Valve会创建一个Filter链并调用doFilter方法最终会调到Servlet的service方法。
你可能会问前面我们不是讲到了Filter似乎也有相似的功能那Valve和Filter有什么区别吗它们的区别是
Valve是Tomcat的私有机制与Tomcat的基础架构/API是紧耦合的。Servlet API是公有的标准所有的Web容器包括Jetty都支持Filter机制。
另一个重要的区别是Valve工作在Web容器级别拦截所有应用的请求而Servlet Filter工作在应用级别只能拦截某个Web应用的所有请求。如果想做整个Web容器的拦截器必须通过Valve来实现。
本期精华
今天我们学习了Tomcat容器的层次结构、根据请求定位Servlet的过程以及请求在容器中的调用过程。Tomcat设计了多层容器是为了灵活性的考虑灵活性具体体现在一个Tomcat实例Server可以有多个Service每个Service通过多个连接器监听不同的端口而一个Service又可以支持多个虚拟主机。一个URL网址可以用不同的主机名、不同的端口和不同的路径来访问特定的Servlet实例。
请求的链式调用是基于Pipeline-Valve责任链来完成的这样的设计使得系统具有良好的可扩展性如果需要扩展容器本身的功能只需要增加相应的Valve即可。
课后思考
Tomcat内的Context组件跟Servlet规范中的ServletContext接口有什么区别跟Spring中的ApplicationContext又有什么关系
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,也欢迎你把你的课后思考和心得记录下来,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。

View File

@ -0,0 +1,151 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
07 Tomcat如何实现一键式启停
通过前面的学习相信你对Tomcat的架构已经有所了解知道了Tomcat都有哪些组件组件之间是什么样的关系以及Tomcat是怎么处理一个HTTP请求的。下面我们通过一张简化的类图来回顾一下从图上你可以看到各种组件的层次关系图中的虚线表示一个请求在Tomcat中流转的过程。
上面这张图描述了组件之间的静态关系如果想让一个系统能够对外提供服务我们需要创建、组装并启动这些组件在服务停止的时候我们还需要释放资源销毁这些组件因此这是一个动态的过程。也就是说Tomcat需要动态地管理这些组件的生命周期。
在我们实际的工作中,如果你需要设计一个比较大的系统或者框架时,你同样也需要考虑这几个问题:如何统一管理组件的创建、初始化、启动、停止和销毁?如何做到代码逻辑清晰?如何方便地添加或者删除组件?如何做到组件启动和停止不遗漏、不重复?
今天我们就来解决上面的问题,在这之前,先来看看组件之间的关系。如果你仔细分析过这些组件,可以发现它们具有两层关系。
第一层关系是组件有大有小大组件管理小组件比如Server管理ServiceService又管理连接器和容器。
第二层关系是组件有外有内,外层组件控制内层组件,比如连接器是外层组件,负责对外交流,外层组件调用内层组件完成业务功能。也就是说,请求的处理过程是由外层组件来驱动的。
这两层关系决定了系统在创建组件时应该遵循一定的顺序。
第一个原则是先创建子组件,再创建父组件,子组件需要被“注入”到父组件中。
第二个原则是先创建内层组件,再创建外层组件,内层组件需要被“注入”到外层组件。
因此,最直观的做法就是将图上所有的组件按照先小后大、先内后外的顺序创建出来,然后组装在一起。不知道你注意到没有,这个思路其实很有问题!因为这样不仅会造成代码逻辑混乱和组件遗漏,而且也不利于后期的功能扩展。
为了解决这个问题,我们希望找到一种通用的、统一的方法来管理组件的生命周期,就像汽车“一键启动”那样的效果。
一键式启停Lifecycle接口
我在前面说到过,设计就是要找到系统的变化点和不变点。这里的不变点就是每个组件都要经历创建、初始化、启动这几个过程,这些状态以及状态的转化是不变的。而变化点是每个具体组件的初始化方法,也就是启动方法是不一样的。
因此我们把不变点抽象出来成为一个接口这个接口跟生命周期有关叫作Lifecycle。Lifecycle接口里应该定义这么几个方法init、start、stop和destroy每个具体的组件去实现这些方法。
理所当然在父组件的init方法里需要创建子组件并调用子组件的init方法。同样在父组件的start方法里也需要调用子组件的start方法因此调用者可以无差别的调用各组件的init方法和start方法这就是组合模式的使用并且只要调用最顶层组件也就是Server组件的init和start方法整个Tomcat就被启动起来了。下面是Lifecycle接口的定义。
可扩展性Lifecycle事件
我们再来考虑另一个问题那就是系统的可扩展性。因为各个组件init和start方法的具体实现是复杂多变的比如在Host容器的启动方法里需要扫描webapps目录下的Web应用创建相应的Context容器如果将来需要增加新的逻辑直接修改start方法这样会违反开闭原则那如何解决这个问题呢开闭原则说的是为了扩展系统的功能你不能直接修改系统中已有的类但是你可以定义新的类。
我们注意到组件的init和start调用是由它的父组件的状态变化触发的上层组件的初始化会触发子组件的初始化上层组件的启动会触发子组件的启动因此我们把组件的生命周期定义成一个个状态把状态的转变看作是一个事件。而事件是有监听器的在监听器里可以实现一些逻辑并且监听器也可以方便的添加和删除这就是典型的观察者模式。
具体来说就是在Lifecycle接口里加入两个方法添加监听器和删除监听器。除此之外我们还需要定义一个Enum来表示组件有哪些状态以及处在什么状态会触发什么样的事件。因此Lifecycle接口和LifecycleState就定义成了下面这样。
从图上你可以看到组件的生命周期有NEW、INITIALIZING、INITIALIZED、STARTING_PREP、STARTING、STARTED等而一旦组件到达相应的状态就触发相应的事件比如NEW状态表示组件刚刚被实例化而当init方法被调用时状态就变成INITIALIZING状态这个时候就会触发BEFORE_INIT_EVENT事件如果有监听器在监听这个事件它的方法就会被调用。
重用性LifecycleBase抽象基类
有了接口,我们就要用类去实现接口。一般来说实现类不止一个,不同的类在实现接口时往往会有一些相同的逻辑,如果让各个子类都去实现一遍,就会有重复代码。那子类如何重用这部分逻辑呢?其实就是定义一个基类来实现共同的逻辑,然后让各个子类去继承它,就达到了重用的目的。
而基类中往往会定义一些抽象方法,所谓的抽象方法就是说基类不会去实现这些方法,而是调用这些方法来实现骨架逻辑。抽象方法是留给各个子类去实现的,并且子类必须实现,否则无法实例化。
比如宝马和荣威的底盘和骨架其实是一样的,只是发动机和内饰等配套是不一样的。底盘和骨架就是基类,宝马和荣威就是子类。仅仅有底盘和骨架还不是一辆真正意义上的车,只能算是半成品,因此在底盘和骨架上会留出一些安装接口,比如安装发动机的接口、安装座椅的接口,这些就是抽象方法。宝马或者荣威上安装的发动机和座椅是不一样的,也就是具体子类对抽象方法有不同的实现。
回到Lifecycle接口Tomcat定义一个基类LifecycleBase来实现Lifecycle接口把一些公共的逻辑放到基类中去比如生命状态的转变与维护、生命事件的触发以及监听器的添加和删除等而子类就负责实现自己的初始化、启动和停止等方法。为了避免跟基类中的方法同名我们把具体子类的实现方法改个名字在后面加上Internal叫initInternal、startInternal等。我们再来看引入了基类LifecycleBase后的类图
从图上可以看到LifecycleBase实现了Lifecycle接口中所有的方法还定义了相应的抽象方法交给具体子类去实现这是典型的模板设计模式。
我们还是看一看代码可以帮你加深理解下面是LifecycleBase的init方法实现。
@Override
public final synchronized void init() throws LifecycleException {
//1. 状态检查
if (!state.equals(LifecycleState.NEW)) {
invalidTransition(Lifecycle.BEFORE_INIT_EVENT);
}
try {
//2.触发INITIALIZING事件的监听器
setStateInternal(LifecycleState.INITIALIZING, null, false);
//3.调用具体子类的初始化方法
initInternal();
//4. 触发INITIALIZED事件的监听器
setStateInternal(LifecycleState.INITIALIZED, null, false);
} catch (Throwable t) {
...
}
}
这个方法逻辑比较清楚,主要完成了四步:
第一步检查状态的合法性比如当前状态必须是NEW然后才能进行初始化。
第二步触发INITIALIZING事件的监听器
setStateInternal(LifecycleState.INITIALIZING, null, false);
在这个setStateInternal方法里会调用监听器的业务方法。
第三步调用具体子类实现的抽象方法initInternal方法。我在前面提到过为了实现一键式启动具体组件在实现initInternal方法时又会调用它的子组件的init方法。
第四步子组件初始化后触发INITIALIZED事件的监听器相应监听器的业务方法就会被调用。
setStateInternal(LifecycleState.INITIALIZED, null, false);
总之LifecycleBase调用了抽象方法来实现骨架逻辑。讲到这里 你可能好奇LifecycleBase负责触发事件并调用监听器的方法那是什么时候、谁把监听器注册进来的呢
分为两种情况:
Tomcat自定义了一些监听器这些监听器是父组件在创建子组件的过程中注册到子组件的。比如MemoryLeakTrackingListener监听器用来检测Context容器中的内存泄漏这个监听器是Host容器在创建Context容器时注册到Context中的。
我们还可以在server.xml中定义自己的监听器Tomcat在启动时会解析server.xml创建监听器并注册到容器组件。
生周期管理总体类图
通过上面的学习我相信你对Tomcat组件的生命周期的管理有了深入的理解我们再来看一张总体类图继续加深印象。
这里请你注意图中的StandardServer、StandardService等是Server和Service组件的具体实现类它们都继承了LifecycleBase。
StandardEngine、StandardHost、StandardContext和StandardWrapper是相应容器组件的具体实现类因为它们都是容器所以继承了ContainerBase抽象基类而ContainerBase实现了Container接口也继承了LifecycleBase类它们的生命周期管理接口和功能接口是分开的这也符合设计中接口分离的原则。
本期精华
Tomcat为了实现一键式启停以及优雅的生命周期管理并考虑到了可扩展性和可重用性将面向对象思想和设计模式发挥到了极致分别运用了组合模式、观察者模式、骨架抽象类和模板方法。
如果你需要维护一堆具有父子关系的实体,可以考虑使用组合模式。
观察者模式听起来“高大上”,其实就是当一个事件发生后,需要执行一连串更新操作。传统的实现方式是在事件响应代码里直接加更新逻辑,当更新逻辑加多了之后,代码会变得臃肿,并且这种方式是紧耦合的、侵入式的。而观察者模式实现了低耦合、非侵入式的通知与更新机制。
而模板方法在抽象基类中经常用到,用来实现通用逻辑。
课后思考
从文中最后的类图上你会看到所有的容器组件都扩展了ContainerBase跟LifecycleBase一样ContainerBase也是一个骨架抽象类请你思考一下各容器组件有哪些“共同的逻辑”需要ContainerBase由来实现呢
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,也欢迎你把你的课后思考和心得记录下来,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。

View File

@ -0,0 +1,255 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
08 Tomcat的“高层们”都负责做什么
使用过Tomcat的同学都知道我们可以通过Tomcat的/bin目录下的脚本startup.sh来启动Tomcat那你是否知道我们执行了这个脚本后发生了什么呢你可以通过下面这张流程图来了解一下。
1.Tomcat本质上是一个Java程序因此startup.sh脚本会启动一个JVM来运行Tomcat的启动类Bootstrap。
2.Bootstrap的主要任务是初始化Tomcat的类加载器并且创建Catalina。关于Tomcat为什么需要自己的类加载器我会在专栏后面详细介绍。
3.Catalina是一个启动类它通过解析server.xml、创建相应的组件并调用Server的start方法。
4.Server组件的职责就是管理Service组件它会负责调用Service的start方法。
5.Service组件的职责就是管理连接器和顶层容器Engine因此它会调用连接器和Engine的start方法。
这样Tomcat的启动就算完成了。下面我来详细介绍一下上面这个启动过程中提到的几个非常关键的启动类和组件。
你可以把Bootstrap看作是上帝它初始化了类加载器也就是创造万物的工具。
如果我们把Tomcat比作是一家公司那么Catalina应该是公司创始人因为Catalina负责组建团队也就是创建Server以及它的子组件。
Server是公司的CEO负责管理多个事业群每个事业群就是一个Service。
Service是事业群总经理它管理两个职能部门一个是对外的市场部也就是连接器组件另一个是对内的研发部也就是容器组件。
Engine则是研发部经理因为Engine是最顶层的容器组件。
你可以看到这些启动类或者组件不处理具体请求它们的任务主要是“管理”管理下层组件的生命周期并且给下层组件分配任务也就是把请求路由到负责“干活儿”的组件。因此我把它们比作Tomcat的“高层”。
今天我们就来看看这些“高层”的实现细节目的是让我们逐步理解Tomcat的工作原理。另一方面软件系统中往往都有一些起管理作用的组件你可以学习和借鉴Tomcat是如何实现这些组件的。
Catalina
Catalina的主要任务就是创建Server它不是直接new一个Server实例就完事了而是需要解析server.xml把在server.xml里配置的各种组件一一创建出来接着调用Server组件的init方法和start方法这样整个Tomcat就启动起来了。作为“管理者”Catalina还需要处理各种“异常”情况比如当我们通过“Ctrl + C”关闭Tomcat时Tomcat将如何优雅的停止并且清理资源呢因此Catalina在JVM中注册一个“关闭钩子”。
public void start() {
//1. 如果持有的Server实例为空就解析server.xml创建出来
if (getServer() == null) {
load();
}
//2. 如果创建失败,报错退出
if (getServer() == null) {
log.fatal(sm.getString("catalina.noServer"));
return;
}
//3.启动Server
try {
getServer().start();
} catch (LifecycleException e) {
return;
}
//创建并注册关闭钩子
if (useShutdownHook) {
if (shutdownHook == null) {
shutdownHook = new CatalinaShutdownHook();
}
Runtime.getRuntime().addShutdownHook(shutdownHook);
}
//用await方法监听停止请求
if (await) {
await();
stop();
}
}
那什么是“关闭钩子”它又是做什么的呢如果我们需要在JVM关闭时做一些清理工作比如将缓存数据刷到磁盘上或者清理一些临时文件可以向JVM注册一个“关闭钩子”。“关闭钩子”其实就是一个线程JVM在停止之前会尝试执行这个线程的run方法。下面我们来看看Tomcat的“关闭钩子”CatalinaShutdownHook做了些什么。
protected class CatalinaShutdownHook extends Thread {
@Override
public void run() {
try {
if (getServer() != null) {
Catalina.this.stop();
}
} catch (Throwable ex) {
...
}
}
}
从这段代码中你可以看到Tomcat的“关闭钩子”实际上就执行了Server的stop方法Server的stop方法会释放和清理所有的资源。
Server组件
Server组件的具体实现类是StandardServer我们来看下StandardServer具体实现了哪些功能。Server继承了LifecycleBase它的生命周期被统一管理并且它的子组件是Service因此它还需要管理Service的生命周期也就是说在启动时调用Service组件的启动方法在停止时调用它们的停止方法。Server在内部维护了若干Service组件它是以数组来保存的那Server是如何添加一个Service到数组中的呢
@Override
public void addService(Service service) {
service.setServer(this);
synchronized (servicesLock) {
//创建一个长度+1的新数组
Service results[] = new Service[services.length + 1];
//将老的数据复制过去
System.arraycopy(services, 0, results, 0, services.length);
results[services.length] = service;
services = results;
//启动Service组件
if (getState().isAvailable()) {
try {
service.start();
} catch (LifecycleException e) {
// Ignore
}
}
//触发监听事件
support.firePropertyChange("service", null, service);
}
}
从上面的代码你能看到它并没有一开始就分配一个很长的数组而是在添加的过程中动态地扩展数组长度当添加一个新的Service实例时会创建一个新数组并把原来数组内容复制到新数组这样做的目的其实是为了节省内存空间。
除此之外Server组件还有一个重要的任务是启动一个Socket来监听停止端口这就是为什么你能通过shutdown命令来关闭Tomcat。不知道你留意到没有上面Catalina的启动方法的最后一行代码就是调用了Server的await方法。
在await方法里会创建一个Socket监听8005端口并在一个死循环里接收Socket上的连接请求如果有新的连接到来就建立连接然后从Socket中读取数据如果读到的数据是停止命令“SHUTDOWN”就退出循环进入stop流程。
Service组件
Service组件的具体实现类是StandardService我们先来看看它的定义以及关键的成员变量。
public class StandardService extends LifecycleBase implements Service {
//名字
private String name = null;
//Server实例
private Server server = null;
//连接器数组
protected Connector connectors[] = new Connector[0];
private final Object connectorsLock = new Object();
//对应的Engine容器
private Engine engine = null;
//映射器及其监听器
protected final Mapper mapper = new Mapper();
protected final MapperListener mapperListener = new MapperListener(this);
StandardService继承了LifecycleBase抽象类此外StandardService中还有一些我们熟悉的组件比如Server、Connector、Engine和Mapper。
那为什么还有一个MapperListener这是因为Tomcat支持热部署当Web应用的部署发生变化时Mapper中的映射信息也要跟着变化MapperListener就是一个监听器它监听容器的变化并把信息更新到Mapper中这是典型的观察者模式。
作为“管理”角色的组件最重要的是维护其他组件的生命周期。此外在启动各种组件时要注意它们的依赖关系也就是说要注意启动的顺序。我们来看看Service启动方法
protected void startInternal() throws LifecycleException {
//1. 触发启动监听器
setState(LifecycleState.STARTING);
//2. 先启动EngineEngine会启动它子容器
if (engine != null) {
synchronized (engine) {
engine.start();
}
}
//3. 再启动Mapper监听器
mapperListener.start();
//4.最后启动连接器连接器会启动它子组件比如Endpoint
synchronized (connectorsLock) {
for (Connector connector: connectors) {
if (connector.getState() != LifecycleState.FAILED) {
connector.start();
}
}
}
}
从启动方法可以看到Service先启动了Engine组件再启动Mapper监听器最后才是启动连接器。这很好理解因为内层组件启动好了才能对外提供服务才能启动外层的连接器组件。而Mapper也依赖容器组件容器组件启动好了才能监听它们的变化因此Mapper和MapperListener在容器组件之后启动。组件停止的顺序跟启动顺序正好相反的也是基于它们的依赖关系。
Engine组件
最后我们再来看看顶层的容器组件Engine具体是如何实现的。Engine本质是一个容器因此它继承了ContainerBase基类并且实现了Engine接口。
public class StandardEngine extends ContainerBase implements Engine {
}
我们知道Engine的子容器是Host所以它持有了一个Host容器的数组这些功能都被抽象到了ContainerBase中ContainerBase中有这样一个数据结构
protected final HashMap<String, Container> children = new HashMap<>();
ContainerBase用HashMap保存了它的子容器并且ContainerBase还实现了子容器的“增删改查”甚至连子组件的启动和停止都提供了默认实现比如ContainerBase会用专门的线程池来启动子容器。
for (int i = 0; i < children.length; i++) {
results.add(startStopExecutor.submit(new StartChild(children[i])));
}
所以Engine在启动Host子容器时就直接重用了这个方法。
那Engine自己做了什么呢我们知道容器组件最重要的功能是处理请求而Engine容器对请求的“处理”其实就是把请求转发给某一个Host子容器来处理具体是通过Valve来实现的。
通过专栏前面的学习我们知道每一个容器组件都有一个Pipeline而Pipeline中有一个基础阀Basic Valve而Engine容器的基础阀定义如下
final class StandardEngineValve extends ValveBase {
public final void invoke(Request request, Response response)
throws IOException, ServletException {
//拿到请求中的Host容器
Host host = request.getHost();
if (host == null) {
return;
}
// 调用Host容器中的Pipeline中的第一个Valve
host.getPipeline().getFirst().invoke(request, response);
}
}
这个基础阀实现非常简单就是把请求转发到Host容器。你可能好奇从代码中可以看到处理请求的Host容器对象是从请求中拿到的请求对象中怎么会有Host容器呢这是因为请求到达Engine容器中之前Mapper组件已经对请求进行了路由处理Mapper组件通过请求的URL定位了相应的容器并且把容器对象保存到了请求对象中。
本期精华
今天我们学习了Tomcat启动过程具体是由启动类和“高层”组件来完成的它们都承担着“管理”的角色负责将子组件创建出来并把它们拼装在一起同时也掌握子组件的“生杀大权”。
所以当我们在设计这样的组件时,需要考虑两个方面:
首先要选用合适的数据结构来保存子组件比如Server用数组来保存Service组件并且采取动态扩容的方式这是因为数组结构简单占用内存小再比如ContainerBase用HashMap来保存子容器虽然Map占用内存会多一点但是可以通过Map来快速的查找子容器。因此在实际的工作中我们也需要根据具体的场景和需求来选用合适的数据结构。
其次还需要根据子组件依赖关系来决定它们的启动和停止顺序,以及如何优雅的停止,防止异常情况下的资源泄漏。这正是“管理者”应该考虑的事情。
课后思考
Server组件的在启动连接器和容器时都分别加了锁这是为什么呢
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,也欢迎你把你的课后思考和心得记录下来,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。

View File

@ -0,0 +1,212 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
09 比较Jetty架构特点之Connector组件
经过专栏前面几期的学习相信你对Tomcat的整体架构和工作原理有了基本了解。但是Servlet容器并非只有Tomcat一家还有别的架构设计思路吗今天我们就来看看Jetty的设计特点。
Jetty是Eclipse基金会的一个开源项目和Tomcat一样Jetty也是一个“HTTP服务器 + Servlet容器”并且Jetty和Tomcat在架构设计上有不少相似的地方。但同时Jetty也有自己的特点主要是更加小巧更易于定制化。Jetty作为一名后起之秀应用范围也越来越广比如Google App Engine就采用了Jetty来作为Web容器。Jetty和Tomcat各有特点所以今天我会和你重点聊聊Jetty在哪些地方跟Tomcat不同。通过比较它们的差异一方面希望可以继续加深你对Web容器架构设计的理解另一方面也让你更清楚它们的设计区别并根据它们的特点来选用这两款Web容器。
鸟瞰Jetty整体架构
简单来说Jetty Server就是由多个Connector连接器、多个Handler处理器以及一个线程池组成。整体结构请看下面这张图。
跟Tomcat一样Jetty也有HTTP服务器和Servlet容器的功能因此Jetty中的Connector组件和Handler组件分别来实现这两个功能而这两个组件工作时所需要的线程资源都直接从一个全局线程池ThreadPool中获取。
Jetty Server可以有多个Connector在不同的端口上监听客户请求而对于请求处理的Handler组件也可以根据具体场景使用不同的Handler。这样的设计提高了Jetty的灵活性需要支持Servlet则可以使用ServletHandler需要支持Session则再增加一个SessionHandler。也就是说我们可以不使用Servlet或者Session只要不配置这个Handler就行了。
为了启动和协调上面的核心组件工作Jetty提供了一个Server类来做这个事情它负责创建并初始化Connector、Handler、ThreadPool组件然后调用start方法启动它们。
我们对比一下Tomcat的整体架构图你会发现Tomcat在整体上跟Jetty很相似它们的第一个区别是Jetty中没有Service的概念Tomcat中的Service包装了多个连接器和一个容器组件一个Tomcat实例可以配置多个Service不同的Service通过不同的连接器监听不同的端口而Jetty中Connector是被所有Handler共享的。
它们的第二个区别是在Tomcat中每个连接器都有自己的线程池而在Jetty中所有的Connector共享一个全局的线程池。
讲完了Jetty的整体架构接下来我来详细分析Jetty的Connector组件的设计下一期我将分析Handler组件的设计。
Connector组件
跟Tomcat一样Connector的主要功能是对I/O模型和应用层协议的封装。I/O模型方面最新的Jetty 9版本只支持NIO因此Jetty的Connector设计有明显的Java NIO通信模型的痕迹。至于应用层协议方面跟Tomcat的Processor一样Jetty抽象出了Connection组件来封装应用层协议的差异。
Java NIO早已成为程序员的必备技能并且也经常出现在面试题中。接下来我们一起来看看Jetty是如何实现NIO模型的以及它是怎么用Java NIO的。
Java NIO回顾
关于Java NIO编程如果你还不太熟悉可以先学习这一系列文章。Java NIO的核心组件是Channel、Buffer和Selector。Channel表示一个连接可以理解为一个Socket通过它可以读取和写入数据但是并不能直接操作数据需要通过Buffer来中转。
Selector可以用来检测Channel上的I/O事件比如读就绪、写就绪、连接就绪一个Selector可以同时处理多个Channel因此单个线程可以监听多个Channel这样会大量减少线程上下文切换的开销。下面我们通过一个典型的服务端NIO程序来回顾一下如何使用这些组件。
首先创建服务端Channel绑定监听端口并把Channel设置为非阻塞方式。
ServerSocketChannel server = ServerSocketChannel.open();
server.socket().bind(new InetSocketAddress(port));
server.configureBlocking(false);
然后创建Selector并在Selector中注册Channel感兴趣的事件OP_ACCEPT告诉Selector如果客户端有新的连接请求到这个端口就通知我。
Selector selector = Selector.open();
server.register(selector, SelectionKey.OP_ACCEPT);
接下来Selector会在一个死循环里不断地调用select去查询I/O状态select会返回一个SelectionKey列表Selector会遍历这个列表看看是否有“客户”感兴趣的事件如果有就采取相应的动作。
比如下面这个例子如果有新的连接请求就会建立一个新的连接。连接建立后再注册Channel的可读事件到Selector中告诉Selector我对这个Channel上是否有新的数据到达感兴趣。
while (true) {
selector.select();//查询I/O事件
for (Iterator<SelectionKey> i = selector.selectedKeys().iterator(); i.hasNext();) {
SelectionKey key = i.next();
i.remove();
if (key.isAcceptable()) {
// 建立一个新连接
SocketChannel client = server.accept();
client.configureBlocking(false);
//连接建立后告诉Selector我现在对I/O可读事件感兴趣
client.register(selector, SelectionKey.OP_READ);
}
}
}
简单回顾完服务端NIO编程之后你会发现服务端在I/O通信上主要完成了三件事情监听连接、I/O事件查询以及数据读写。因此Jetty设计了Acceptor、SelectorManager和Connection来分别做这三件事情下面我分别来说说这三个组件。
Acceptor
顾名思义Acceptor用于接受请求跟Tomcat一样Jetty也有独立的Acceptor线程组用于处理连接请求。在Connector的实现类ServerConnector中有一个_acceptors的数组在Connector启动的时候, 会根据_acceptors数组的长度创建对应数量的Acceptor而Acceptor的个数可以配置。
for (int i = 0; i < _acceptors.length; i++)
{
Acceptor a = new Acceptor(i);
getExecutor().execute(a);
}
Acceptor是ServerConnector中的一个内部类同时也是一个RunnableAcceptor线程是通过getExecutor得到的线程池来执行的前面提到这是一个全局的线程池。
Acceptor通过阻塞的方式来接受连接这一点跟Tomcat也是一样的。
public void accept(int acceptorID) throws IOException
{
ServerSocketChannel serverChannel = _acceptChannel;
if (serverChannel != null && serverChannel.isOpen())
{
// 这里是阻塞的
SocketChannel channel = serverChannel.accept();
// 执行到这里时说明有请求进来了
accepted(channel);
}
}
接受连接成功后会调用accepted函数accepted函数中会将SocketChannel设置为非阻塞模式然后交给Selector去处理因此这也就到了Selector的地界了。
private void accepted(SocketChannel channel) throws IOException
{
channel.configureBlocking(false);
Socket socket = channel.socket();
configure(socket);
// _manager是SelectorManager实例里面管理了所有的Selector实例
_manager.accept(channel);
}
SelectorManager
Jetty的Selector由SelectorManager类管理而被管理的Selector叫作ManagedSelector。SelectorManager内部有一个ManagedSelector数组真正干活的是ManagedSelector。咱们接着上面分析看看在SelectorManager在accept方法里做了什么。
public void accept(SelectableChannel channel, Object attachment)
{
//选择一个ManagedSelector来处理Channel
final ManagedSelector selector = chooseSelector();
//提交一个任务Accept给ManagedSelector
selector.submit(selector.new Accept(channel, attachment));
}
SelectorManager从本身的Selector数组中选择一个Selector来处理这个Channel并创建一个任务Accept交给ManagedSelectorManagedSelector在处理这个任务主要做了两步
第一步调用Selector的register方法把Channel注册到Selector上拿到一个SelectionKey。
_key = _channel.register(selector, SelectionKey.OP_ACCEPT, this);
第二步创建一个EndPoint和Connection并跟这个SelectionKeyChannel绑在一起
private void createEndPoint(SelectableChannel channel, SelectionKey selectionKey) throws IOException
{
//1. 创建EndPoint
EndPoint endPoint = _selectorManager.newEndPoint(channel, this, selectionKey);
//2. 创建Connection
Connection connection = _selectorManager.newConnection(channel, endPoint, selectionKey.attachment());
//3. 把EndPoint、Connection和SelectionKey绑在一起
endPoint.setConnection(connection);
selectionKey.attach(endPoint);
}
上面这两个过程是什么意思呢打个比方你到餐厅吃饭先点菜注册I/O事件服务员ManagedSelector给你一个单子SelectionKey等菜做好了I/O事件到了服务员根据单子就知道是哪桌点了这个菜于是喊一嗓子某某桌的菜做好了调用了绑定在SelectionKey上的EndPoint的方法
这里需要你特别注意的是ManagedSelector并没有调用直接EndPoint的方法去处理数据而是通过调用EndPoint的方法返回一个Runnable然后把这个Runnable扔给线程池执行所以你能猜到这个Runnable才会去真正读数据和处理请求。
Connection
这个Runnable是EndPoint的一个内部类它会调用Connection的回调方法来处理请求。Jetty的Connection组件类比就是Tomcat的Processor负责具体协议的解析得到Request对象并调用Handler容器进行处理。下面我简单介绍一下它的具体实现类HttpConnection对请求和响应的处理过程。
请求处理HttpConnection并不会主动向EndPoint读取数据而是向在EndPoint中注册一堆回调方法
getEndPoint().fillInterested(_readCallback);
这段代码就是告诉EndPoint数据到了你就调我这些回调方法_readCallback吧有点异步I/O的感觉也就是说Jetty在应用层面模拟了异步I/O模型。
而在回调方法_readCallback里会调用EndPoint的接口去读数据读完后让HTTP解析器去解析字节流HTTP解析器会将解析后的数据包括请求行、请求头相关信息存到Request对象里。
响应处理Connection调用Handler进行业务处理Handler会通过Response对象来操作响应流向流里面写入数据HttpConnection再通过EndPoint把数据写到Channel这样一次响应就完成了。
到此你应该了解了Connector的工作原理下面我画张图再来回顾一下Connector的工作流程。
1.Acceptor监听连接请求当有连接请求到达时就接受连接一个连接对应一个ChannelAcceptor将Channel交给ManagedSelector来处理。
2.ManagedSelector把Channel注册到Selector上并创建一个EndPoint和Connection跟这个Channel绑定接着就不断地检测I/O事件。
3.I/O事件到了就调用EndPoint的方法拿到一个Runnable并扔给线程池执行。
4.线程池中调度某个线程执行Runnable。
5.Runnable执行时调用回调函数这个回调函数是Connection注册到EndPoint中的。
6.回调函数内部实现其实就是调用EndPoint的接口方法来读数据。
7.Connection解析读到的数据生成请求对象并交给Handler组件去处理。
本期精华
Jetty Server就是由多个Connector、多个Handler以及一个线程池组成在设计上简洁明了。
Jetty的Connector只支持NIO模型跟Tomcat的NioEndpoint组件一样它也是通过Java的NIO API实现的。我们知道Java NIO编程有三个关键组件Channel、Buffer和Selector而核心是Selector。为了方便使用Jetty在原生Selector组件的基础上做了一些封装实现了ManagedSelector组件。
在线程模型设计上Tomcat的NioEndpoint跟Jetty的Connector是相似的都是用一个Acceptor数组监听连接用一个Selector数组侦测I/O事件用一个线程池执行请求。它们的不同点在于Jetty使用了一个全局的线程池所有的线程资源都是从线程池来分配。
Jetty Connector设计中的一大特点是使用了回调函数来模拟异步I/O比如Connection向EndPoint注册了一堆回调函数。它的本质将函数当作一个参数来传递告诉对方你准备好了就调这个回调函数。
课后思考
Jetty的Connector主要完成了三件事件接收连接、I/O事件查询以及数据读写。因此Jetty设计了Acceptor、SelectorManager和Connection来做这三件事情。今天的思考题是为什么要把这些组件跑在不同的线程里呢
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,也欢迎你把你的课后思考和心得记录下来,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。

View File

@ -0,0 +1,127 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
10 比较Jetty架构特点之Handler组件
在专栏上一期我们学习了Jetty的整体架构。先来回顾一下Jetty 就是由多个Connector连接器、多个Handler处理器以及一个线程池组成整体结构图如下。
上一期我们分析了Jetty Connector组件的设计Connector会将Servlet请求交给Handler去处理那Handler又是如何处理请求的呢
Jetty的Handler在设计上非常有意思可以说是Jetty的灵魂Jetty通过Handler实现了高度可定制化那具体是如何实现的呢我们能从中学到怎样的设计方法呢接下来我就来聊聊这些问题。
Handler是什么
Handler就是一个接口它有一堆实现类Jetty的Connector组件调用这些接口来处理Servlet请求我们先来看看这个接口定义成什么样子。
public interface Handler extends LifeCycle, Destroyable
{
//处理请求的方法
public void handle(String target, Request baseRequest, HttpServletRequest request, HttpServletResponse response)
throws IOException, ServletException;
//每个Handler都关联一个Server组件被Server管理
public void setServer(Server server);
public Server getServer();
//销毁方法相关的资源
public void destroy();
}
你会看到Handler接口的定义非常简洁主要就是用handle方法用来处理请求跟Tomcat容器组件的service方法一样它有ServletRequest和ServletResponse两个参数。除此之外这个接口中还有setServer和getServer方法因为任何一个Handler都需要关联一个Server组件也就是说Handler需要被Server组件来管理。一般来说Handler会加载一些资源到内存因此通过设置destroy方法来销毁。
Handler继承关系
Handler只是一个接口完成具体功能的还是它的子类。那么Handler有哪些子类呢它们的继承关系又是怎样的这些子类是如何实现Servlet容器功能的呢
Jetty中定义了一些默认Handler类并且这些Handler类之间的继承关系比较复杂我们先通过一个全景图来了解一下。为了避免让你感到不适我对类图进行了简化。
从图上你可以看到Handler的种类和层次关系还是比较复杂的
Handler接口之下有抽象类AbstractHandler这一点并不意外因为有接口一般就有抽象实现类。
在AbstractHandler之下有AbstractHandlerContainer为什么需要这个类呢这其实是个过渡为了实现链式调用一个Handler内部必然要有其他Handler的引用所以这个类的名字里才有Container意思就是这样的Handler里包含了其他Handler的引用。
理解了上面的AbstractHandlerContainer我们就能理解它的两个子类了HandlerWrapper和HandlerCollection。简单来说就是HandlerWrapper和HandlerCollection都是Handler但是这些Handler里还包括其他Handler的引用。不同的是HandlerWrapper只包含一个其他Handler的引用而HandlerCollection中有一个Handler数组的引用。
接着来看左边的HandlerWrapper它有两个子类Server和ScopedHandler。Server比较好理解它本身是Handler模块的入口必然要将请求传递给其他Handler来处理为了触发其他Handler的调用所以它是一个HandlerWrapper。
再看ScopedHandler它也是一个比较重要的Handler实现了“具有上下文信息”的责任链调用。为什么我要强调“具有上下文信息”呢那是因为Servlet规范规定Servlet在执行过程中是有上下文的。那么这些Handler在执行过程中如何访问这个上下文呢这个上下文又存在什么地方呢答案就是通过ScopedHandler来实现的。
而ScopedHandler有一堆的子类这些子类就是用来实现Servlet规范的比如ServletHandler、ContextHandler、SessionHandler、ServletContextHandler和WebAppContext。接下来我会详细介绍它们但我们先把总体类图看完。
请看类图的右边跟HandlerWrapper对等的还有HandlerCollectionHandlerCollection其实维护了一个Handler数组。你可能会问为什么要发明一个这样的Handler这是因为Jetty可能需要同时支持多个Web应用如果每个Web应用有一个Handler入口那么多个Web应用的Handler就成了一个数组比如Server中就有一个HandlerCollectionServer会根据用户请求的URL从数组中选取相应的Handler来处理就是选择特定的Web应用来处理请求。
Handler的类型
虽然从类图上看Handler有很多但是本质上这些Handler分成三种类型
第一种是协调Handler这种Handler负责将请求路由到一组Handler中去比如上图中的HandlerCollection它内部持有一个Handler数组当请求到来时它负责将请求转发到数组中的某一个Handler。
第二种是过滤器Handler这种Handler自己会处理请求处理完了后再把请求转发到下一个Handler比如图上的HandlerWrapper它内部持有下一个Handler的引用。需要注意的是所有继承了HandlerWrapper的Handler都具有了过滤器Handler的特征比如ContextHandler、SessionHandler和WebAppContext等。
第三种是内容Handler说白了就是这些Handler会真正调用Servlet来处理请求生成响应的内容比如ServletHandler。如果浏览器请求的是一个静态资源也有相应的ResourceHandler来处理这个请求返回静态页面。
如何实现Servlet规范
上文提到ServletHandler、ContextHandler以及WebAppContext等它们实现了Servlet规范那具体是怎么实现的呢为了帮助你理解在这之前我们还是来看看如何使用Jetty来启动一个Web应用。
//新建一个WebAppContextWebAppContext是一个Handler
WebAppContext webapp = new WebAppContext();
webapp.setContextPath("/mywebapp");
webapp.setWar("mywebapp.war");
//将Handler添加到Server中去
server.setHandler(webapp);
//启动Server
server.start();
server.join();
上面的过程主要分为两步:
第一步创建一个WebAppContext接着设置一些参数到这个Handler中就是告诉WebAppContext你的WAR包放在哪Web应用的访问路径是什么。
第二步就是把新创建的WebAppContext添加到Server中然后启动Server。
WebAppContext对应一个Web应用。我们回忆一下Servlet规范中有Context、Servlet、Filter、Listener和Session等Jetty要支持Servlet规范就需要有相应的Handler来分别实现这些功能。因此Jetty设计了3个组件ContextHandler、ServletHandler和SessionHandler来实现Servlet规范中规定的功能而WebAppContext本身就是一个ContextHandler另外它还负责管理ServletHandler和SessionHandler。
我们再来看一下什么是ContextHandler。ContextHandler会创建并初始化Servlet规范里的ServletContext对象同时ContextHandler还包含了一组能够让你的Web应用运行起来的Handler可以这样理解Context本身也是一种Handler它里面包含了其他的Handler这些Handler能处理某个特定URL下的请求。比如ContextHandler包含了一个或者多个ServletHandler。
再来看ServletHandler它实现了Servlet规范中的Servlet、Filter和Listener的功能。ServletHandler依赖FilterHolder、ServletHolder、ServletMapping、FilterMapping这四大组件。FilterHolder和ServletHolder分别是Filter和Servlet的包装类每一个Servlet与路径的映射会被封装成ServletMapping而Filter与拦截URL的映射会被封装成FilterMapping。
SessionHandler从名字就知道它的功能用来管理Session。除此之外WebAppContext还有一些通用功能的Handler比如SecurityHandler和GzipHandler同样从名字可以知道这些Handler的功能分别是安全控制和压缩/解压缩。
WebAppContext会将这些Handler构建成一个执行链通过这个链会最终调用到我们的业务Servlet。我们通过一张图来理解一下。
通过对比Tomcat的架构图你可以看到Jetty的Handler组件和Tomcat中的容器组件是大致是对等的概念Jetty中的WebAppContext相当于Tomcat的Context组件都是对应一个Web应用而Jetty中的ServletHandler对应Tomcat中的Wrapper组件它负责初始化和调用Servlet并实现了Filter的功能。
对于一些通用组件比如安全和解压缩在Jetty中都被做成了Handler这是Jetty Handler架构的特点。
因此对于Jetty来说请求处理模块就被抽象成Handler不管是实现了Servlet规范的Handler还是实现通用功能的Handler比如安全、解压缩等我们可以任意添加或者裁剪这些“功能模块”从而实现高度的可定制化。
本期精华
Jetty Server就是由多个Connector、多个Handler以及一个线程池组成。
Jetty的Handler设计是它的一大特色Jetty本质就是一个Handler管理器Jetty本身就提供了一些默认Handler来实现Servlet容器的功能你也可以定义自己的Handler来添加到Jetty中这体现了“微内核 + 插件”的设计思想。
课后思考
通过今天的学习我们知道各种Handler都会对请求做一些处理再将请求传给下一个Handler而Servlet也是用来处理请求的那Handler跟Servlet有什么区别呢
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,也欢迎你把你的课后思考和心得记录下来,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。

View File

@ -0,0 +1,62 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
11 总结从Tomcat和Jetty中提炼组件化设计规范
在当今的互联网时代,我们每个人获取信息的机会基本上都是平等的,但是为什么有些人对信息理解得更深,并且有自己独到的见解呢?我认为是因为他们养成了思考和总结的好习惯。当我们学习一门技术的时候,如果可以勤于思考、善于总结,可以帮助我们看到现象背后更本质的东西,让我们在成长之路上更快“脱颖而出”。
我们经常谈敏捷、快速迭代和重构这些都是为了应对需求的快速变化也因此我们在开始设计一个系统时就要考虑可扩展性。那究竟该怎样设计才能适应变化呢或者要设计成什么样后面才能以最小的成本进行重构呢今天我来总结一些Tomcat和Jetty组件化的设计思想或许从中我们可以得到一些启发。
组件化及可配置
Tomcat和Jetty的整体架构都是基于组件的你可以通过XML文件或者代码的方式来配置这些组件比如我们可以在server.xml配置Tomcat的连接器以及容器组件。相应的你也可以在jetty.xml文件里组装Jetty的Connector组件以及各种Handler组件。也就是说Tomcat和Jetty提供了一堆积木怎么搭建这些积木由你来决定你可以根据自己的需要灵活选择组件来搭建你的Web容器并且也可以自定义组件这样的设计为Web容器提供了深度可定制化。
那Web容器如何实现这种组件化设计呢我认为有两个要点
第一个是面向接口编程。我们需要对系统的功能按照“高内聚、低耦合”的原则进行拆分,每个组件都有相应的接口,组件之间通过接口通信,这样就可以方便地替换组件了。比如我们可以选择不同连接器类型,只要这些连接器组件实现同一个接口就行。
第二个是Web容器提供一个载体把组件组装在一起工作。组件的工作无非就是处理请求因此容器通过责任链模式把请求依次交给组件去处理。对于用户来说我只需要告诉Web容器由哪些组件来处理请求。把组件组织起来需要一个“管理者”这就是为什么Tomcat和Jetty都有一个Server的概念Server就是组件的载体Server里包含了连接器组件和容器组件容器还需要把请求交给各个子容器组件去处理Tomcat和Jetty都是责任链模式来实现的。
用户通过配置来组装组件跟Spring中Bean的依赖注入相似。Spring的用户可以通过配置文件或者注解的方式来组装BeanBean与Bean的依赖关系完全由用户自己来定义。这一点与Web容器不同Web容器中组件与组件之间的关系是固定的比如Tomcat中Engine组件下有Host组件、Host组件下有Context组件等但你不能在Host组件里“注入”一个Wrapper组件这是由于Web容器本身的功能来决定的。
组件的创建
由于组件是可以配置的Web容器在启动之前并不知道要创建哪些组件也就是说不能通过硬编码的方式来实例化这些组件而是需要通过反射机制来动态地创建。具体来说Web容器不是通过new方法来实例化组件对象的而是通过Class.forName来创建组件。无论哪种方式在实例化一个类之前Web容器需要把组件类加载到JVM这就涉及一个类加载的问题Web容器设计了自己类加载器我会在专栏后面的文章详细介绍Tomcat的类加载器。
Spring也是通过反射机制来动态地实例化Bean那么它用到的类加载器是从哪里来的呢Web容器给每个Web应用创建了一个类加载器Spring用到的类加载器是Web容器传给它的。
组件的生命周期管理
不同类型的组件具有父子层次关系父组件处理请求后再把请求传递给某个子组件。你可能会感到疑惑Jetty的中Handler不是一条链吗看上去像是平行关系其实不然Jetty中的Handler也是分层次的比如WebAppContext中包含ServletHandler和SessionHandler。因此你也可以把ContextHandler和它所包含的Handler看作是父子关系。
而Tomcat通过容器的概念把小容器放到大容器来实现父子关系其实它们的本质都是一样的。这其实涉及如何统一管理这些组件如何做到一键式启停。
Tomcat和Jetty都采用了类似的办法来管理组件的生命周期主要有两个要点一是父组件负责子组件的创建、启停和销毁。这样只要启动最上层组件整个Web容器就被启动起来了也就实现了一键式启停二是Tomcat和Jetty都定义了组件的生命周期状态并且把组件状态的转变定义成一个事件一个组件的状态变化会触发子组件的变化比如Host容器的启动事件里会触发Web应用的扫描和加载最终会在Host容器下创建相应的Context容器而Context组件的启动事件又会触发Servlet的扫描进而创建Wrapper组件。那么如何实现这种联动呢答案是观察者模式。具体来说就是创建监听器去监听容器的状态变化在监听器的方法里去实现相应的动作这些监听器其实是组件生命周期过程中的“扩展点”。
Spring也采用了类似的设计Spring给Bean生命周期状态提供了很多的“扩展点”。这些扩展点被定义成一个个接口只要你的Bean实现了这些接口Spring就会负责调用这些接口这样做的目的就是当Bean的创建、初始化和销毁这些控制权交给Spring后Spring让你有机会在Bean的整个生命周期中执行你的逻辑。下面我通过一张图帮你理解Spring Bean的生命周期过程
组件的骨架抽象类和模板模式
具体到组件的设计的与实现Tomcat和Jetty都大量采用了骨架抽象类和模板模式。比如说Tomcat中ProtocolHandler接口ProtocolHandler有抽象基类AbstractProtocol它实现了协议处理层的骨架和通用逻辑而具体协议也有抽象基类比如HttpProtocol和AjpProtocol。对于Jetty来说Handler接口之下有AbstractHandlerConnector接口之下有AbstractConnector这些抽象骨架类实现了一些通用逻辑并且会定义一些抽象方法这些抽象方法由子类实现抽象骨架类调用抽象方法来实现骨架逻辑。
这是一个通用的设计规范不管是Web容器还是Spring甚至JDK本身都到处使用这种设计比如Java集合中的AbstractSet、AbstractMap等。 值得一提的是从Java 8开始允许接口有default方法这样我们可以把抽象骨架类的通用逻辑放到接口中去。
本期精华
今天我总结了Tomcat和Jetty的组件化设计我们可以通过搭积木的方式来定制化自己的Web容器。Web容器为了支持这种组件化设计遵循了一些规范比如面向接口编程用“管理者”去组装这些组件用反射的方式动态的创建组件、统一管理组件的生命周期并且给组件生命状态的变化提供了扩展点组件的具体实现一般遵循骨架抽象类和模板模式。
通过今天的学习你会发现Tomcat和Jetty有很多共同点并且Spring框架的设计也有不少相似的的地方这正好说明了Web开发中有一些本质的东西是相通的只要你深入理解了一个技术也就是在一个点上突破了深度再扩展广度就不是难事。并且我建议在学习一门技术的时候可以回想一下之前学过的东西是不是有相似的地方有什么不同的地方通过对比理解它们的本质这样我们才能真正掌握这些技术背后的精髓。
课后思考
在我们的实际项目中,可能经常遇到改变需求,那如果采用组件化设计,当需求更改时是不是会有一些帮助呢?
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,也欢迎你把你的课后思考和心得记录下来,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。

View File

@ -0,0 +1,130 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
12 实战优化并提高Tomcat启动速度
到目前为止我们学习了Tomcat和Jetty的整体架构还知道了Tomcat是如何启动起来的今天我们来聊一个比较轻松的话题如何优化并提高Tomcat的启动速度。
我们在使用Tomcat时可能会碰到启动比较慢的问题比如我们的系统发布新版本上线时可能需要重启服务这个时候我们希望Tomcat能快速启动起来提供服务。其实关于如何让Tomcat启动变快官方网站有专门的文章来介绍这个话题。下面我也针对Tomcat 8.5和9.0版本,给出几条非常明确的建议,可以现学现用。
清理你的Tomcat
1. 清理不必要的Web应用
首先我们要做的是删除掉webapps文件夹下不需要的工程一般是host-manager、example、doc等这些默认的工程可能还有以前添加的但现在用不着的工程最好把这些全都删除掉。如果你看过Tomcat的启动日志可以发现每次启动Tomcat都会重新布署这些工程。
2. 清理XML配置文件
我们知道Tomcat在启动的时候会解析所有的XML配置文件但XML解析的代价可不小因此我们要尽量保持配置文件的简洁需要解析的东西越少速度自然就会越快。
3. 清理JAR文件
我们还可以删除所有不需要的JAR文件。JVM的类加载器在加载类时需要查找每一个JAR文件去找到所需要的类。如果删除了不需要的JAR文件查找的速度就会快一些。这里请注意Web应用中的lib目录下不应该出现Servlet API或者Tomcat自身的JAR这些JAR由Tomcat负责提供。如果你是使用Maven来构建你的应用对Servlet API的依赖应该指定为<scope>provided</scope>
4. 清理其他文件
及时清理日志删除logs文件夹下不需要的日志文件。同样还有work文件夹下的catalina文件夹它其实是Tomcat把JSP转换为Class文件的工作目录。有时候我们也许会遇到修改了代码重启了Tomcat但是仍没效果这时候便可以删除掉这个文件夹Tomcat下次启动的时候会重新生成。
禁止Tomcat TLD扫描
Tomcat为了支持JSP在应用启动的时候会扫描JAR包里面的TLD文件加载里面定义的标签库所以在Tomcat的启动日志里你可能会碰到这种提示
At least one JAR was scanned for TLDs yet contained no TLDs. Enable debug logging for this logger for a complete list of JARs that were scanned but no TLDs were found in them. Skipping unneeded JARs during scanning can improve startup time and JSP compilation time.
Tomcat的意思是我扫描了你Web应用下的JAR包发现JAR包里没有TLD文件。我建议配置一下Tomcat不要去扫描这些JAR包这样可以提高Tomcat的启动速度并节省JSP编译时间。
那如何配置不去扫描这些JAR包呢这里分两种情况
如果你的项目没有使用JSP作为Web页面模板而是使用Velocity之类的模板引擎你完全可以把TLD扫描禁止掉。方法是找到Tomcat的conf/目录下的context.xml文件在这个文件里Context标签下加上JarScanner和JarScanFilter子标签像下面这样。
如果你的项目使用了JSP作为Web页面模块意味着TLD扫描无法避免但是我们可以通过配置来告诉Tomcat只扫描那些包含TLD文件的JAR包。方法是找到Tomcat的conf/目录下的catalina.properties文件在这个文件里的jarsToSkip配置项中加上你的JAR包。
tomcat.util.scan.StandardJarScanFilter.jarsToSkip=xxx.jar
关闭WebSocket支持
Tomcat会扫描WebSocket注解的API实现比如@ServerEndpoint注解的类。我们知道注解扫描一般是比较慢的如果不需要使用WebSocket就可以关闭它。具体方法是找到Tomcat的conf/目录下的context.xml文件给Context标签加一个containerSciFilter的属性像下面这样。
更进一步如果你不需要WebSocket这个功能你可以把Tomcat lib目录下的websocket-api.jar和tomcat-websocket.jar这两个JAR文件删除掉进一步提高性能。
关闭JSP支持
跟关闭WebSocket一样如果你不需要使用JSP可以通过类似方法关闭JSP功能像下面这样。
我们发现关闭JSP用的也是containerSciFilter属性如果你想把WebSocket和JSP都关闭那就这样配置
禁止Servlet注解扫描
Servlet 3.0引入了注解ServletTomcat为了支持这个特性会在Web应用启动时扫描你的类文件因此如果你没有使用Servlet注解这个功能可以告诉Tomcat不要去扫描Servlet注解。具体配置方法是在你的Web应用的web.xml文件中设置<web-app>元素的属性metadata-complete="true",像下面这样。
metadata-complete的意思是web.xml里配置的Servlet是完整的不需要再去库类中找Servlet的定义。
配置Web-Fragment扫描
Servlet 3.0还引入了“Web模块部署描述符片段”的web-fragment.xml这是一个部署描述文件可以完成web.xml的配置功能。而这个web-fragment.xml文件必须存放在JAR文件的META-INF目录下而JAR包通常放在WEB-INF/lib目录下因此Tomcat需要对JAR文件进行扫描才能支持这个功能。
你可以通过配置web.xml里面的<absolute-ordering>元素直接指定了哪些JAR包需要扫描web fragment如果<absolute-ordering/>元素是空的, 则表示不需要扫描,像下面这样。
随机数熵源优化
这是一个比较有名的问题。Tomcat 7以上的版本依赖Java的SecureRandom类来生成随机数比如Session ID。而JVM 默认使用阻塞式熵源(/dev/random 在某些情况下就会导致Tomcat启动变慢。当阻塞时间较长时 你会看到这样一条警告日志:
<DATE> org.apache.catalina.util.SessionIdGenerator createSecureRandom-
INFO: Creation of SecureRandom instance for session ID generation using [SHA1PRNG] took [8152] milliseconds.
这其中的原理我就不展开了你可以阅读资料获得更多信息。解决方案是通过设置让JVM使用非阻塞式的熵源。
我们可以设置JVM的参数
-Djava.security.egd=file:/dev/./urandom
或者是设置java.security文件位于$JAVA_HOME/jre/lib/security目录之下 securerandom.source=file:/dev/./urandom
这里请你注意,/dev/./urandom中间有个./的原因是Oracle JRE中的BugJava 8里面的 SecureRandom类已经修正这个Bug。 阻塞式的熵源(/dev/random安全性较高 非阻塞式的熵源(/dev/./urandom安全性会低一些因为如果你对随机数的要求比较高 可以考虑使用硬件方式生成熵源。
并行启动多个Web应用
Tomcat启动的时候默认情况下Web应用都是一个一个启动的等所有Web应用全部启动完成Tomcat才算启动完毕。如果在一个Tomcat下你有多个Web应用为了优化启动速度你可以配置多个应用程序并行启动可以通过修改server.xml中Host元素的startStopThreads属性来完成。startStopThreads的值表示你想用多少个线程来启动你的Web应用如果设成0表示你要并行启动Web应用像下面这样的配置。
这里需要注意的是Engine元素里也配置了这个参数这意味着如果你的Tomcat配置了多个Host虚拟主机Tomcat会以并行的方式启动多个Host。
本期精华
今天我讲了不少提高优化Tomcat启动速度的小贴士现在你就可以把它们用在项目中了。不管是在开发环境还是生产环境你都可以打开Tomcat的启动日志看看目前你们的应用启动需要多长时间然后尝试去调优再看看Tomcat的启动速度快了多少。
如果你是用嵌入式的方式运行Tomcat比如Spring Boot你也可以通过Spring Boot的方式去修改Tomcat的参数调优的原理都是一样的。
课后思考
在Tomcat启动速度优化上你都遇到了哪些问题或者你还有自己的“独门秘籍”欢迎把它们分享给我和其他同学。
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,也欢迎你把你的课后思考和心得记录下来,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。

View File

@ -0,0 +1,88 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
13 热点问题答疑1如何学习源码
不知道你有没有留意到,不少高端开发岗位在招聘要求里往往会写这么一条:研究过框架和中间件源码的优先考虑。这是因为一切秘密都藏在源码之中,阅读源码会让我们对框架或者中间件的理解更加深刻。有时候即使你阅读了大量原理性的文档,但如果不看源码,可能仍然会觉得还没有理解透。另外如果你能深入源码,招聘者从侧面也能感觉到你的学习热情和探索精神。
今天我们就来聊聊源码学习这个话题。对于Java后端开发来说有不少经典的开源框架和中间件下面我帮你按照后端的分层架构整理出来供你参考。
服务接入层反向代理NginxAPI网关Node.js。
业务逻辑层Web容器Tomcat、Jetty应用层框架Spring、Spring MVC和Spring BootORM框架MyBatis
数据缓存层内存数据库Redis消息中间件Kafka。
数据存储层关系型数据库MySQL非关系型数据库MongoDB文件存储HDFS搜索分析引擎Elasticsearch。
这其中每一层都要支持水平扩展和高可用比如业务层普遍采用微服务架构微服务之间需要互相调用于是就出现了RPC框架Spring Cloud和Dubbo。
除此之外还有两个非常重要的基础组件Netty和ZooKeeper其中Netty用于网络通信ZooKeeper用于分布式协调。其实很多中间件都用到了这两个基础组件并且ZooKeeper的网络通信模块也是通过Netty来实现的。
而这些框架或者中间件并不是凭空产生的,它们是在互联网的演化过程中,为了解决各种具体业务的痛点,一点一点积累进化而来的。很多时候我们把这些“零件”按照成熟的模式组装在一起,就能搭建出一个互联网后台系统。一般来说大厂都会对这些框架或者中间件进行改造,或者完全靠自己来实现。这就对后台程序员提出了更高的要求。
那这么多中间件和框架从哪里入手呢先学哪个后学哪个呢我觉得可以先学一些你熟悉的或者相对来说比较简单的树立起信心后再学复杂的。比如可以先学Tomcat、Jetty和Spring核心容器弄懂了这些以后再扩展到Spring的其他组件。
在这个过程中,我们就会积累一些通用的技术,比如网络编程、多线程、反射和类加载技术等,这些通用的技术在不少中间件和框架中会用到。
先说网络通信在分布式环境下信息要在各个实体之间流动到处都是网络通信的场景比如浏览器要将HTTP请求发给Web容器一个微服务要调用另一个微服务Web应用读写缓存服务器、消息队列或者数据库等都需要网络通信。
尽管网络通信的场景很多,但无外乎都要考虑这么几个问题:
I/O模型同步还是异步是阻塞还是非阻塞
通信协议是二进制gRPC还是文本HTTP
数据怎么序列化是JSON还是Protocol Buffer
此外服务端的线程模型也是一个重点。我们知道多线程可以把要做的事情“并行化”提高并发度和吞吐量但是线程可能会阻塞一旦阻塞线程资源就闲置了并且会有线程上下文切换的开销浪费CPU资源。而有些任务执行会发生阻塞有些则不会阻塞因此线程模型就是要决定哪几件事情放到一个线程来做哪几件事情放到另一个线程来做并设置合理的线程数量目的就是要让CPU忙起来并且不是白忙活也就是不做无用功。
我们知道服务端处理一个网络连接的过程是:
accept、select、read、decode、process、encode、send。
一般来说服务端程序有几个角色Acceptor、Selector和Processor。
Acceptor负责接收新连接也就是accept
Selector负责检测连接上的I/O事件也就是select
Processor负责数据读写、编解码和业务处理也就是read、decode、process、encode、send。
Acceptor在接收连接时可能会阻塞为了不耽误其他工作一般跑在单独的线程里而Selector在侦测I/O事件时也可能阻塞但是它一次可以检测多个Channel连接其实就是用阻塞它一个来换取大量业务线程的不阻塞那Selector检测I/O事件到了是用同一个线程来执行Processor还是另一个线程来执行呢不同的场景又有相应的策略。
比如Netty通过EventLoop将Selector和Processor跑在同一个线程。一个EventLoop绑定了一个线程并且持有一个Selector。而Processor的处理过程被封装成一个个任务一个EventLoop负责处理多个Channel上的所有任务而一个Channel只能由一个EventLoop来处理这就保证了任务执行的线程安全并且用同一个线程来侦测I/O事件和读写数据可以充分利用CPU缓存。我们通过一张图来理解一下
请你注意这要求Processor中的任务能在短时间完成否则会阻塞这个EventLoop上其他Channel的处理。因此在Netty中可以设置业务处理和I/O处理的时间比率超过这个比率则将任务扔到专门的业务线程池来执行这一点跟Jetty的EatWhatYouKill线程策略有异曲同工之妙。
而Kafka把Selector和Processor跑在不同的线程里因为Kafka的业务逻辑大多涉及与磁盘读写处理时间不确定所以Kafka有专门的业务处理线程池来运行Processor。与此类似Tomcat也采用了这样的策略同样我们还是通过一张图来理解一下。
我们再来看看Java反射机制几乎所有的框架都用到了反射和类加载技术这是为了保证框架的通用性需要根据配置文件在运行时加载不同的类并调用其方法。比如Web容器Tomcat和Jetty通过反射来加载Servlet、Filter和Listener而Spring的两大核心功能IOC和AOP都用到了反射技术再比如MyBatis将数据从数据库读出后也是通过反射机制来创建Java对象并设置对象的值。
因此你会发现通过学习一个中间件熟悉了这些通用的技术以后再学习其他的中间件或者框架就容易多了。比如学透了Tomcat的I/O线程模型以及高并发高性能设计思路再学Netty的源码就轻车熟路了Tomcat的组件化设计和类加载机制理解透彻了再学Spring容器的源码就会轻松很多。
接下来我再来聊聊具体如何学习源码,有很多同学在专栏里问这个问题,我在专栏的留言中也提到过,但我觉得有必要展开详细讲讲我是如何学习源码的。
学习的第一步首先我们要弄清楚中间件的核心功能是什么我以专栏所讲的Tomcat为例。Tomcat的核心功能是HTTP服务器和Servlet容器因此就抓住请求处理这条线通过什么样的方式接收连接接收到连接后以什么样的方式来读取数据读到数据后怎么解析数据HTTP协议请求数据解析出来后怎么调用Servlet容器Servlet容器又怎么调到Spring中的业务代码。
为了完成这些功能Tomcat中有一些起骨架作用的核心类其他类都是在这个骨架上进行扩展或补充细节来实现。因此在学习前期就要紧紧抓住这些类先不要深入到其他细节你可以先画出一张骨架类图。
在此之后我们还需要将源码跑起来打打断点看看变量的值和调用栈。我建议用内嵌式的方式来启动和调试Tomcat体会一下Spring Boot是如何使用Tomcat的这里有示例源码。在源码阅读过程中要充分利用IDE的功能比如通过快捷键查找某个接口的所有实现类、查找某个类或者函数在哪些地方被用到。
我们还要带着问题去学习源码比如你想弄清楚Tomcat如何启停、类加载器是如何设计的、Spring Boot是如何启动Tomcat的、Jetty是如何通过Handler链实现高度定制化的如果要你来设计这些功能会怎么做呢带着这些问题去分析相关的源码效率会更高同时你在寻找答案的过程中也会碰到更多问题等你把这些问题都弄清楚了你获得的不仅仅是知识更重要的是你会树立起攻克难关的信心。同时我还建议在你弄清楚一些细节后要及时记录下来画画流程图或者类图再加上一些关键备注以防遗忘。
当然在这个过程中,你还可以看看产品的官方文档,熟悉一下大概的设计思路。在遇到难题时,你还可以看看网上的博客,参考一下别人的分析。但最终还是需要你自己去实践和摸索,因为网上的分析也不一定对,只有你自己看了源码后才能真正理解它,印象才更加深刻。
今天说了这么多,就是想告诉你如果理解透彻一两个中间件,有了一定的积累,这时再来学一个新的系统,往往你只需要瞧上几眼,就能明白它所用的架构,而且你会自然联想到系统存在哪些角色,以及角色之间的关系,包括静态的依赖关系和动态的协作关系,甚至你会不由自主带着审视的眼光,来发现一些可以改进的地方。如果你现在就是这样的状态,那么恭喜你,你的技术水平已经成长到一个新的层面了。
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,也欢迎你把你的课后思考和心得记录下来,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。

View File

@ -0,0 +1,185 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
14 NioEndpoint组件Tomcat如何实现非阻塞I_O
UNIX系统下的I/O模型有5种同步阻塞I/O、同步非阻塞I/O、I/O多路复用、信号驱动I/O和异步I/O。这些名词我们好像都似曾相识但这些I/O通信模型有什么区别同步和阻塞似乎是一回事到底有什么不同等一下在这之前你是不是应该问自己一个终极问题什么是I/O为什么需要这些I/O模型
所谓的I/O就是计算机内存与外部设备之间拷贝数据的过程。我们知道CPU访问内存的速度远远高于外部设备因此CPU是先把外部设备的数据读到内存里然后再进行处理。请考虑一下这个场景当你的程序通过CPU向外部设备发出一个读指令时数据从外部设备拷贝到内存往往需要一段时间这个时候CPU没事干了你的程序是主动把CPU让给别人还是让CPU不停地查数据到了吗数据到了吗……
这就是I/O模型要解决的问题。今天我会先说说各种I/O模型的区别然后重点分析Tomcat的NioEndpoint组件是如何实现非阻塞I/O模型的。
Java I/O模型
对于一个网络I/O通信过程比如网络数据读取会涉及两个对象一个是调用这个I/O操作的用户线程另外一个就是操作系统内核。一个进程的地址空间分为用户空间和内核空间用户线程不能直接访问内核空间。
当用户线程发起I/O操作后网络数据读取操作会经历两个步骤
用户线程等待内核将数据从网卡拷贝到内核空间。
内核将数据从内核空间拷贝到用户空间。
各种I/O模型的区别就是它们实现这两个步骤的方式是不一样的。
同步阻塞I/O用户线程发起read调用后就阻塞了让出CPU。内核等待网卡数据到来把数据从网卡拷贝到内核空间接着把数据拷贝到用户空间再把用户线程叫醒。
同步非阻塞I/O用户线程不断的发起read调用数据没到内核空间时每次都返回失败直到数据到了内核空间这一次read调用后在等待数据从内核空间拷贝到用户空间这段时间里线程还是阻塞的等数据到了用户空间再把线程叫醒。
I/O多路复用用户线程的读取操作分成两步了线程先发起select调用目的是问内核数据准备好了吗等内核把数据准备好了用户线程再发起read调用。在等待数据从内核空间拷贝到用户空间这段时间里线程还是阻塞的。那为什么叫I/O多路复用呢因为一次select调用可以向内核查多个数据通道Channel的状态所以叫多路复用。
异步I/O用户线程发起read调用的同时注册一个回调函数read立即返回等内核将数据准备好后再调用指定的回调函数完成处理。在这个过程中用户线程一直没有阻塞。
NioEndpoint组件
Tomcat的NioEndpoint组件实现了I/O多路复用模型接下来我会介绍NioEndpoint的实现原理下一期我会介绍Tomcat如何实现异步I/O模型。
总体工作流程
我们知道对于Java的多路复用器的使用无非是两步
创建一个Selector在它身上注册各种感兴趣的事件然后调用select方法等待感兴趣的事情发生。
感兴趣的事情发生了比如可以读了这时便创建一个新的线程从Channel中读数据。
Tomcat的NioEndpoint组件虽然实现比较复杂但基本原理就是上面两步。我们先来看看它有哪些组件它一共包含LimitLatch、Acceptor、Poller、SocketProcessor和Executor共5个组件它们的工作过程如下图所示。
LimitLatch是连接控制器它负责控制最大连接数NIO模式下默认是10000达到这个阈值后连接请求被拒绝。
Acceptor跑在一个单独的线程里它在一个死循环里调用accept方法来接收新连接一旦有新的连接请求到来accept方法返回一个Channel对象接着把Channel对象交给Poller去处理。
Poller的本质是一个Selector也跑在单独线程里。Poller在内部维护一个Channel数组它在一个死循环里不断检测Channel的数据就绪状态一旦有Channel可读就生成一个SocketProcessor任务对象扔给Executor去处理。
Executor就是线程池负责运行SocketProcessor任务类SocketProcessor的run方法会调用Http11Processor来读取和解析请求数据。我们知道Http11Processor是应用层协议的封装它会调用容器获得响应再把响应通过Channel写出。
接下来我详细介绍一下各组件的设计特点。
LimitLatch
LimitLatch用来控制连接个数当连接数到达最大时阻塞线程直到后续组件处理完一个连接后将连接数减1。请你注意到达最大连接数后操作系统底层还是会接收客户端连接但用户层已经不再接收。LimitLatch的核心代码如下
public class LimitLatch {
private class Sync extends AbstractQueuedSynchronizer {
@Override
protected int tryAcquireShared() {
long newCount = count.incrementAndGet();
if (newCount > limit) {
count.decrementAndGet();
return -1;
} else {
return 1;
}
}
@Override
protected boolean tryReleaseShared(int arg) {
count.decrementAndGet();
return true;
}
}
private final Sync sync;
private final AtomicLong count;
private volatile long limit;
//线程调用这个方法来获得接收新连接的许可,线程可能被阻塞
public void countUpOrAwait() throws InterruptedException {
sync.acquireSharedInterruptibly(1);
}
//调用这个方法来释放一个连接许可,那么前面阻塞的线程可能被唤醒
public long countDown() {
sync.releaseShared(0);
long result = getCount();
return result;
}
}
从上面的代码我们看到LimitLatch内步定义了内部类Sync而Sync扩展了AQSAQS是Java并发包中的一个核心类它在内部维护一个状态和一个线程队列可以用来控制线程什么时候挂起什么时候唤醒。我们可以扩展它来实现自己的同步器实际上Java并发包里的锁和条件变量等等都是通过AQS来实现的而这里的LimitLatch也不例外。
理解上面的代码时有两个要点:
用户线程通过调用LimitLatch的countUpOrAwait方法来拿到锁如果暂时无法获取这个线程会被阻塞到AQS的队列中。那AQS怎么知道是阻塞还是不阻塞用户线程呢其实这是由AQS的使用者来决定的也就是内部类Sync来决定的因为Sync类重写了AQS的tryAcquireShared()方法。它的实现逻辑是如果当前连接数count小于limit线程能获取锁返回1否则返回-1。
如何用户线程被阻塞到了AQS的队列那什么时候唤醒呢同样是由Sync内部类决定Sync重写了AQS的tryReleaseShared()方法,其实就是当一个连接请求处理完了,这时又可以接收一个新连接了,这样前面阻塞的线程将会被唤醒。
其实你会发现AQS就是一个骨架抽象类它帮我们搭了个架子用来控制线程的阻塞和唤醒。具体什么时候阻塞、什么时候唤醒由你来决定。我们还注意到当前线程数被定义成原子变量AtomicLong而limit变量用volatile关键字来修饰这些并发编程的实际运用。
Acceptor
Acceptor实现了Runnable接口因此可以跑在单独线程里。一个端口号只能对应一个ServerSocketChannel因此这个ServerSocketChannel是在多个Acceptor线程之间共享的它是Endpoint的属性由Endpoint完成初始化和端口绑定。初始化过程如下
serverSock = ServerSocketChannel.open();
serverSock.socket().bind(addr,getAcceptCount());
serverSock.configureBlocking(true);
从上面的初始化代码我们可以看到两个关键信息:
bind方法的第二个参数表示操作系统的等待队列长度我在上面提到当应用层面的连接数到达最大值时操作系统可以继续接收连接那么操作系统能继续接收的最大连接数就是这个队列长度可以通过acceptCount参数配置默认是100。
ServerSocketChannel被设置成阻塞模式也就是说它是以阻塞的方式接收连接的。
ServerSocketChannel通过accept()接受新的连接accept()方法返回获得SocketChannel对象然后将SocketChannel对象封装在一个PollerEvent对象中并将PollerEvent对象压入Poller的Queue里这是个典型的“生产者-消费者”模式Acceptor与Poller线程之间通过Queue通信。
Poller
Poller本质是一个Selector它内部维护一个Queue这个Queue定义如下
private final SynchronizedQueue<PollerEvent> events = new SynchronizedQueue<>();
SynchronizedQueue的方法比如offer、poll、size和clear方法都使用了synchronized关键字进行修饰用来保证同一时刻只有一个Acceptor线程对Queue进行读写。同时有多个Poller线程在运行每个Poller线程都有自己的Queue。每个Poller线程可能同时被多个Acceptor线程调用来注册PollerEvent。同样Poller的个数可以通过pollers参数配置。
Poller不断的通过内部的Selector对象向内核查询Channel的状态一旦可读就生成任务类SocketProcessor交给Executor去处理。Poller的另一个重要任务是循环遍历检查自己所管理的SocketChannel是否已经超时如果有超时就关闭这个SocketChannel。
SocketProcessor
我们知道Poller会创建SocketProcessor任务类交给线程池处理而SocketProcessor实现了Runnable接口用来定义Executor中线程所执行的任务主要就是调用Http11Processor组件来处理请求。Http11Processor读取Channel的数据来生成ServletRequest对象这里请你注意
Http11Processor并不是直接读取Channel的。这是因为Tomcat支持同步非阻塞I/O模型和异步I/O模型在Java API中相应的Channel类也是不一样的比如有AsynchronousSocketChannel和SocketChannel为了对Http11Processor屏蔽这些差异Tomcat设计了一个包装类叫作SocketWrapperHttp11Processor只调用SocketWrapper的方法去读写数据。
Executor
Executor是Tomcat定制版的线程池它负责创建真正干活的工作线程干什么活呢就是执行SocketProcessor的run方法也就是解析请求并通过容器来处理请求最终会调用到我们的Servlet。后面我会用专门的篇幅介绍Tomcat怎么扩展和使用Java原生的线程池。
高并发思路
在弄清楚NioEndpoint的实现原理后我们来考虑一个重要的问题怎么把这个过程做到高并发呢
高并发就是能快速地处理大量的请求需要合理设计线程模型让CPU忙起来尽量不要让线程阻塞因为一阻塞CPU就闲下来了。另外就是有多少任务就用相应规模的线程数去处理。我们注意到NioEndpoint要完成三件事情接收连接、检测I/O事件以及处理请求那么最核心的就是把这三件事情分开用不同规模的线程数去处理比如用专门的线程组去跑Acceptor并且Acceptor的个数可以配置用专门的线程组去跑PollerPoller的个数也可以配置最后具体任务的执行也由专门的线程池来处理也可以配置线程池的大小。
本期精华
I/O模型是为了解决内存和外部设备速度差异的问题。我们平时说的阻塞或非阻塞是指应用程序在发起I/O操作时是立即返回还是等待。而同步和异步是指应用程序在与内核通信时数据从内核空间到应用空间的拷贝是由内核主动发起还是由应用程序来触发。
在Tomcat中Endpoint组件的主要工作就是处理I/O而NioEndpoint利用Java NIO API实现了多路复用I/O模型。其中关键的一点是读写数据的线程自己不会阻塞在I/O等待上而是把这个工作交给Selector。同时Tomcat在这个过程中运用到了很多Java并发编程技术比如AQS、原子类、并发容器线程池等都值得我们去细细品味。
课后思考
Tomcat的NioEndpoint组件的名字中有NIONIO是非阻塞的意思似乎说的是同步非阻塞I/O模型但是NioEndpoint又是调用Java的的Selector来实现的我们知道Selector指的是I/O多路复用器也就是我们说的I/O多路复用模型这不是矛盾了吗
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,也欢迎你把你的课后思考和心得记录下来,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。

View File

@ -0,0 +1,217 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
15 Nio2Endpoint组件Tomcat如何实现异步I_O
我在专栏上一期里提到了5种I/O模型相应的Java提供了BIO、NIO和NIO.2这些API来实现这些I/O模型。BIO是我们最熟悉的同步阻塞NIO是同步非阻塞那NIO.2又是什么呢NIO已经足够好了为什么还要NIO.2呢?
NIO和NIO.2最大的区别是,一个是同步一个是异步。我在上期提到过,异步最大的特点是,应用程序不需要自己去触发数据从内核空间到用户空间的拷贝。
为什么是应用程序去“触发”数据的拷贝,而不是直接从内核拷贝数据呢?这是因为应用程序是不能访问内核空间的,因此数据拷贝肯定是由内核来做,关键是谁来触发这个动作。
是内核主动将数据拷贝到用户空间并通知应用程序。还是等待应用程序通过Selector来查询当数据就绪后应用程序再发起一个read调用这时内核再把数据从内核空间拷贝到用户空间。
需要注意的是,数据从内核空间拷贝到用户空间这段时间,应用程序还是阻塞的。所以你会看到异步的效率是高于同步的,因为异步模式下应用程序始终不会被阻塞。下面我以网络数据读取为例,来说明异步模式的工作过程。
首先应用程序在调用read API的同时告诉内核两件事情数据准备好了以后拷贝到哪个Buffer以及调用哪个回调函数去处理这些数据。
之后内核接到这个read指令后等待网卡数据到达数据到了后产生硬件中断内核在中断程序里把数据从网卡拷贝到内核空间接着做TCP/IP协议层面的数据解包和重组再把数据拷贝到应用程序指定的Buffer最后调用应用程序指定的回调函数。
你可能通过下面这张图来回顾一下同步与异步的区别:
我们可以看到在异步模式下应用程序当了“甩手掌柜”内核则忙前忙后但最大限度提高了I/O通信的效率。Windows的IOCP和Linux内核2.6的AIO都提供了异步I/O的支持Java的NIO.2 API就是对操作系统异步I/O API的封装。
Java NIO.2回顾
今天我们会重点关注Tomcat是如何实现异步I/O模型的但在这之前我们先来简单回顾下如何用Java的NIO.2 API来编写一个服务端程序。
public class Nio2Server {
void listen(){
//1.创建一个线程池
ExecutorService es = Executors.newCachedThreadPool();
//2.创建异步通道群组
AsynchronousChannelGroup tg = AsynchronousChannelGroup.withCachedThreadPool(es, 1);
//3.创建服务端异步通道
AsynchronousServerSocketChannel assc = AsynchronousServerSocketChannel.open(tg);
//4.绑定监听端口
assc.bind(new InetSocketAddress(8080));
//5. 监听连接,传入回调类处理连接请求
assc.accept(this, new AcceptHandler());
}
}
上面的代码主要做了5件事情
创建一个线程池,这个线程池用来执行来自内核的回调请求。
创建一个AsynchronousChannelGroup并绑定一个线程池。
创建AsynchronousServerSocketChannel并绑定到AsynchronousChannelGroup。
绑定一个监听端口。
调用accept方法开始监听连接请求同时传入一个回调类去处理连接请求。请你注意accept方法的第一个参数是this对象就是Nio2Server对象本身我在下文还会讲为什么要传入这个参数。
你可能会问为什么需要创建一个线程池呢其实在异步I/O模型里应用程序不知道数据在什么时候到达因此向内核注册回调函数当数据到达时内核就会调用这个回调函数。同时为了提高处理速度会提供一个线程池给内核使用这样不会耽误内核线程的工作内核只需要把工作交给线程池就立即返回了。
我们再来看看处理连接的回调类AcceptHandler是什么样的。
//AcceptHandler类实现了CompletionHandler接口的completed方法。它还有两个模板参数第一个是异步通道第二个就是Nio2Server本身
public class AcceptHandler implements CompletionHandler<AsynchronousSocketChannel, Nio2Server> {
//具体处理连接请求的就是completed方法它有两个参数第一个是异步通道第二个就是上面传入的NioServer对象
@Override
public void completed(AsynchronousSocketChannel asc, Nio2Server attachment) {
//调用accept方法继续接收其他客户端的请求
attachment.assc.accept(attachment, this);
//1. 先分配好Buffer告诉内核数据拷贝到哪里去
ByteBuffer buf = ByteBuffer.allocate(1024);
//2. 调用read函数读取数据除了把buf作为参数传入还传入读回调类
channel.read(buf, buf, new ReadHandler(asc));
}
我们看到它实现了CompletionHandler接口下面我们先来看看CompletionHandler接口的定义。
public interface CompletionHandler<V,A> {
void completed(V result, A attachment);
void failed(Throwable exc, A attachment);
}
CompletionHandler接口有两个模板参数V和A分别表示I/O调用的返回值和附件类。比如accept的返回值就是AsynchronousSocketChannel而附件类由用户自己决定在accept的调用中我们传入了一个Nio2Server。因此AcceptHandler带有了两个模板参数AsynchronousSocketChannel和Nio2Server。
CompletionHandler有两个方法completed和failed分别在I/O操作成功和失败时调用。completed方法有两个参数其实就是前面说的两个模板参数。也就是说Java的NIO.2在调用回调方法时会把返回值和附件类当作参数传给NIO.2的使用者。
下面我们再来看看处理读的回调类ReadHandler长什么样子。
public class ReadHandler implements CompletionHandler<Integer, ByteBuffer> {
//读取到消息后的处理
@Override
public void completed(Integer result, ByteBuffer attachment) {
//attachment就是数据调用flip操作其实就是把读的位置移动最前面
attachment.flip();
//读取数据
...
}
void failed(Throwable exc, A attachment){
...
}
}
read调用的返回值是一个整型数所以我们回调方法里的第一个参数就是一个整型表示有多少数据被读取到了Buffer中。第二个参数是一个ByteBuffer这是因为我们在调用read方法时把用来存放数据的ByteBuffer当作附件类传进去了所以在回调方法里有ByteBuffer类型的参数我们直接从这个ByteBuffer里获取数据。
Nio2Endpoint
掌握了Java NIO.2 API的使用以及服务端程序的工作原理之后再来理解Tomcat的异步I/O实现就不难了。我们先通过一张图来看看Nio2Endpoint有哪些组件。
从图上看总体工作流程跟NioEndpoint是相似的。
LimitLatch是连接控制器它负责控制最大连接数。
Nio2Acceptor扩展了Acceptor用异步I/O的方式来接收连接跑在一个单独的线程里也是一个线程组。Nio2Acceptor接收新的连接后得到一个AsynchronousSocketChannelNio2Acceptor把AsynchronousSocketChannel封装成一个Nio2SocketWrapper并创建一个SocketProcessor任务类交给线程池处理并且SocketProcessor持有Nio2SocketWrapper对象。
Executor在执行SocketProcessor时SocketProcessor的run方法会调用Http11Processor来处理请求Http11Processor会通过Nio2SocketWrapper读取和解析请求数据请求经过容器处理后再把响应通过Nio2SocketWrapper写出。
需要你注意Nio2Endpoint跟NioEndpoint的一个明显不同点是Nio2Endpoint中没有Poller组件也就是没有Selector。这是为什么呢因为在异步I/O模式下Selector的工作交给内核来做了。
接下来我详细介绍一下Nio2Endpoint各组件的设计。
Nio2Acceptor
和NioEndpint一样Nio2Endpoint的基本思路是用LimitLatch组件来控制连接数但是Nio2Acceptor的监听连接的过程不是在一个死循环里不断地调accept方法而是通过回调函数来完成的。我们来看看它的连接监听方法
serverSock.accept(null, this);
其实就是调用了accept方法注意它的第二个参数是this表明Nio2Acceptor自己就是处理连接的回调类因此Nio2Acceptor实现了CompletionHandler接口。那么它是如何实现CompletionHandler接口的呢
protected class Nio2Acceptor extends Acceptor<AsynchronousSocketChannel>
implements CompletionHandler<AsynchronousSocketChannel, Void> {
@Override
public void completed(AsynchronousSocketChannel socket,
Void attachment) {
if (isRunning() && !isPaused()) {
if (getMaxConnections() == -1) {
//如果没有连接限制,继续接收新的连接
serverSock.accept(null, this);
} else {
//如果有连接限制就在线程池里跑run方法run方法会检查连接数
getExecutor().execute(this);
}
//处理请求
if (!setSocketOptions(socket)) {
closeSocket(socket);
}
}
}
可以看到CompletionHandler的两个模板参数分别是AsynchronousServerSocketChannel和Void我在前面说过第一个参数就是accept方法的返回值第二个参数是附件类由用户自己决定这里为Void。completed方法的处理逻辑比较简单
如果没有连接限制继续在本线程中调用accept方法接收新的连接。
如果有连接限制就在线程池里跑run方法去接收新的连接。那为什么要跑run方法呢因为在run方法里会检查连接数当连接达到最大数时线程可能会被LimitLatch阻塞。为什么要放在线程池里跑呢这是因为如果放在当前线程里执行completed方法可能被阻塞会导致这个回调方法一直不返回。
接着completed方法会调用setSocketOptions方法在这个方法里会创建Nio2SocketWrapper和SocketProcessor并交给线程池处理。
Nio2SocketWrapper
Nio2SocketWrapper的主要作用是封装Channel并提供接口给Http11Processor读写数据。讲到这里你是不是有个疑问Http11Processor是不能阻塞等待数据的按照异步I/O的套路Http11Processor在调用Nio2SocketWrapper的read方法时需要注册回调类read调用会立即返回问题是立即返回后Http11Processor还没有读到数据怎么办呢这个请求的处理不就失败了吗
为了解决这个问题Http11Processor是通过2次read调用来完成数据读取操作的。
第一次read调用连接刚刚建立好后Acceptor创建SocketProcessor任务类交给线程池去处理Http11Processor在处理请求的过程中会调用Nio2SocketWrapper的read方法发出第一次读请求同时注册了回调类readCompletionHandler因为数据没读到Http11Processor把当前的Nio2SocketWrapper标记为数据不完整。接着SocketProcessor线程被回收Http11Processor并没有阻塞等待数据。这里请注意Http11Processor维护了一个Nio2SocketWrapper列表也就是维护了连接的状态。
第二次read调用当数据到达后内核已经把数据拷贝到Http11Processor指定的Buffer里同时回调类readCompletionHandler被调用在这个回调处理方法里会重新创建一个新的SocketProcessor任务来继续处理这个连接而这个新的SocketProcessor任务类持有原来那个Nio2SocketWrapper这一次Http11Processor可以通过Nio2SocketWrapper读取数据了因为数据已经到了应用层的Buffer。
这个回调类readCompletionHandler的源码如下最关键的一点是Nio2SocketWrapper是作为附件类来传递的这样在回调函数里能拿到所有的上下文。
this.readCompletionHandler = new CompletionHandler<Integer, SocketWrapperBase<Nio2Channel>>() {
public void completed(Integer nBytes, SocketWrapperBase<Nio2Channel> attachment) {
...
//通过附件类SocketWrapper拿到所有的上下文
Nio2SocketWrapper.this.getEndpoint().processSocket(attachment, SocketEvent.OPEN_READ, false);
}
public void failed(Throwable exc, SocketWrapperBase<Nio2Channel> attachment) {
...
}
}
本期精华
在异步I/O模型里内核做了很多事情它把数据准备好并拷贝到用户空间再通知应用程序去处理也就是调用应用程序注册的回调函数。Java在操作系统 异步IO API的基础上进行了封装提供了Java NIO.2 API而Tomcat的异步I/O模型就是基于Java NIO.2 实现的。
由于NIO和NIO.2的API接口和使用方法完全不同可以想象一个系统中如果已经支持同步I/O要再支持异步I/O改动是比较大的很有可能不得不重新设计组件之间的接口。但是Tomcat通过充分的抽象比如SocketWrapper对Channel的封装再加上Http11Processor的两次read调用巧妙地解决了这个问题使得协议处理器Http11Processor和I/O通信处理器Endpoint之间的接口保持不变。
课后思考
我在文章开头介绍Java NIO.2的使用时提到过要创建一个线程池来处理异步I/O的回调那么这个线程池跟Tomcat的工作线程池Executor是同一个吗如果不是它们有什么关系
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,也欢迎你把你的课后思考和心得记录下来,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。

View File

@ -0,0 +1,162 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
16 AprEndpoint组件Tomcat APR提高I_O性能的秘密
我们在使用Tomcat时会在启动日志里看到这样的提示信息
The APR based Apache Tomcat Native library which allows optimal performance in production environments was not found on the java.library.path: ***
这句话的意思就是推荐你去安装APR库可以提高系统性能。那什么是APR呢
APRApache Portable Runtime Libraries是Apache可移植运行时库它是用C语言实现的其目的是向上层应用程序提供一个跨平台的操作系统接口库。Tomcat可以用它来处理包括文件和网络I/O从而提升性能。我在专栏前面提到过Tomcat支持的连接器有NIO、NIO.2和APR。跟NioEndpoint一样AprEndpoint也实现了非阻塞I/O它们的区别是NioEndpoint通过调用Java的NIO API来实现非阻塞I/O而AprEndpoint是通过JNI调用APR本地库而实现非阻塞I/O的。
那同样是非阻塞I/O为什么Tomcat会提示使用APR本地库的性能会更好呢这是因为在某些场景下比如需要频繁与操作系统进行交互Socket网络通信就是这样一个场景特别是如果你的Web应用使用了TLS来加密传输我们知道TLS协议在握手过程中有多次网络交互在这种情况下Java跟C语言程序相比还是有一定的差距而这正是APR的强项。
Tomcat本身是Java编写的为了调用C语言编写的APR需要通过JNI方式来调用。JNIJava Native Interface 是JDK提供的一个编程接口它允许Java程序调用其他语言编写的程序或者代码库其实JDK本身的实现也大量用到JNI技术来调用本地C程序库。
在今天这一期文章首先我会讲AprEndpoint组件的工作过程接着我会在原理的基础上分析APR提升性能的一些秘密。在今天的学习过程中会涉及到一些操作系统的底层原理毫无疑问掌握这些底层知识对于提高你的内功非常有帮助。
AprEndpoint工作过程
下面我还是通过一张图来帮你理解AprEndpoint的工作过程。
你会发现它跟NioEndpoint的图很像从左到右有LimitLatch、Acceptor、Poller、SocketProcessor和Http11Processor只是Acceptor和Poller的实现和NioEndpoint不同。接下来我分别来讲讲这两个组件。
Acceptor
Accpetor的功能就是监听连接接收并建立连接。它的本质就是调用了四个操作系统APISocket、Bind、Listen和Accept。那Java语言如何直接调用C语言API呢答案就是通过JNI。具体来说就是两步先封装一个Java类在里面定义一堆用native关键字修饰的方法像下面这样。
public class Socket {
...
//用native修饰这个方法表明这个函数是C语言实现
public static native long create(int family, int type,
int protocol, long cont)
public static native int bind(long sock, long sa);
public static native int listen(long sock, int backlog);
public static native long accept(long sock)
}
接着用C代码实现这些方法比如Bind函数就是这样实现的
//注意函数的名字要符合JNI规范的要求
JNIEXPORT jint JNICALL
Java_org_apache_tomcat_jni_Socket_bind(JNIEnv *e, jlong sock,jlong sa)
{
jint rv = APR_SUCCESS;
tcn_socket_t *s = (tcn_socket_t *sock;
apr_sockaddr_t *a = (apr_sockaddr_t *) sa;
//调用APR库自己实现的bind函数
rv = (jint)apr_socket_bind(s->sock, a);
return rv;
}
专栏里我就不展开JNI的细节了你可以扩展阅读获得更多信息和例子。我们要注意的是函数名字要符合JNI的规范以及Java和C语言如何互相传递参数比如在C语言有指针Java没有指针的概念所以在Java中用long类型来表示指针。AprEndpoint的Acceptor组件就是调用了APR实现的四个API。
Poller
Acceptor接收到一个新的Socket连接后按照NioEndpoint的实现它会把这个Socket交给Poller去查询I/O事件。AprEndpoint也是这样做的不过AprEndpoint的Poller并不是调用Java NIO里的Selector来查询Socket的状态而是通过JNI调用APR中的poll方法而APR又是调用了操作系统的epoll API来实现的。
这里有个特别的地方是在AprEndpoint中我们可以配置一个叫deferAccept的参数它对应的是TCP协议中的TCP_DEFER_ACCEPT设置这个参数后当TCP客户端有新的连接请求到达时TCP服务端先不建立连接而是再等等直到客户端有请求数据发过来时再建立连接。这样的好处是服务端不需要用Selector去反复查询请求数据是否就绪。
这是一种TCP协议层的优化不是每个操作系统内核都支持因为Java作为一种跨平台语言需要屏蔽各种操作系统的差异因此并没有把这个参数提供给用户但是对于APR来说它的目的就是尽可能提升性能因此它向用户暴露了这个参数。
APR提升性能的秘密
APR连接器之所以能提高Tomcat的性能除了APR本身是C程序库之外还有哪些提速的秘密呢
JVM堆 VS 本地内存
我们知道Java的类实例一般在JVM堆上分配而Java是通过JNI调用C代码来实现Socket通信的那么C代码在运行过程中需要的内存又是从哪里分配的呢C代码能否直接操作Java堆
为了回答这些问题我先来说说JVM和用户进程的关系。如果你想运行一个Java类文件可以用下面的Java命令来执行。
java my.class
这个命令行中的java其实是一个可执行程序这个程序会创建JVM来加载和运行你的Java类。操作系统会创建一个进程来执行这个java可执行程序而每个进程都有自己的虚拟地址空间JVM用到的内存包括堆、栈和方法区就是从进程的虚拟地址空间上分配的。请你注意的是JVM内存只是进程空间的一部分除此之外进程空间内还有代码段、数据段、内存映射区、内核空间等。从JVM的角度看JVM内存之外的部分叫作本地内存C程序代码在运行过程中用到的内存就是本地内存中分配的。下面我们通过一张图来理解一下。
Tomcat的Endpoint组件在接收网络数据时需要预先分配好一块Buffer所谓的Buffer就是字节数组byte[]Java通过JNI调用把这块Buffer的地址传给C代码C代码通过操作系统API读取Socket并把数据填充到这块Buffer。Java NIO API提供了两种Buffer来接收数据HeapByteBuffer和DirectByteBuffer下面的代码演示了如何创建两种Buffer。
//分配HeapByteBuffer
ByteBuffer buf = ByteBuffer.allocate(1024);
//分配DirectByteBuffer
ByteBuffer buf = ByteBuffer.allocateDirect(1024);
创建好Buffer后直接传给Channel的read或者write函数最终这块Buffer会通过JNI调用传递给C程序。
//将buf作为read函数的参数
int bytesRead = socketChannel.read(buf);
那HeapByteBuffer和DirectByteBuffer有什么区别呢HeapByteBuffer对象本身在JVM堆上分配并且它持有的字节数组byte[]也是在JVM堆上分配。但是如果用HeapByteBuffer来接收网络数据需要把数据从内核先拷贝到一个临时的本地内存再从临时本地内存拷贝到JVM堆而不是直接从内核拷贝到JVM堆上。这是为什么呢这是因为数据从内核拷贝到JVM堆的过程中JVM可能会发生GCGC过程中对象可能会被移动也就是说JVM堆上的字节数组可能会被移动这样的话Buffer地址就失效了。如果这中间经过本地内存中转从本地内存到JVM堆的拷贝过程中JVM可以保证不做GC。
如果使用HeapByteBuffer你会发现JVM堆和内核之间多了一层中转而DirectByteBuffer用来解决这个问题DirectByteBuffer对象本身在JVM堆上但是它持有的字节数组不是从JVM堆上分配的而是从本地内存分配的。DirectByteBuffer对象中有个long类型字段address记录着本地内存的地址这样在接收数据的时候直接把这个本地内存地址传递给C程序C程序会将网络数据从内核拷贝到这个本地内存JVM可以直接读取这个本地内存这种方式比HeapByteBuffer少了一次拷贝因此一般来说它的速度会比HeapByteBuffer快好几倍。你可以通过上面的图加深理解。
Tomcat中的AprEndpoint就是通过DirectByteBuffer来接收数据的而NioEndpoint和Nio2Endpoint是通过HeapByteBuffer来接收数据的。你可能会问NioEndpoint和Nio2Endpoint为什么不用DirectByteBuffer呢这是因为本地内存不好管理发生内存泄漏难以定位从稳定性考虑NioEndpoint和Nio2Endpoint没有去冒这个险。
sendfile
我们再来考虑另一个网络通信的场景也就是静态文件的处理。浏览器通过Tomcat来获取一个HTML文件而Tomcat的处理逻辑无非是两步
从磁盘读取HTML到内存。
将这段内存的内容通过Socket发送出去。
但是在传统方式下,有很多次的内存拷贝:
读取文件时,首先是内核把文件内容读取到内核缓冲区。
如果使用HeapByteBuffer文件数据从内核到JVM堆内存需要经过本地内存中转。
同样在将文件内容推入网络时从JVM堆到内核缓冲区需要经过本地内存中转。
最后还需要把文件从内核缓冲区拷贝到网卡缓冲区。
从下面的图你会发现这个过程有6次内存拷贝并且read和write等系统调用将导致进程从用户态到内核态的切换会耗费大量的CPU和内存资源。
而Tomcat的AprEndpoint通过操作系统层面的sendfile特性解决了这个问题sendfile系统调用方式非常简洁。
sendfile(socket, file, len);
它带有两个关键参数Socket和文件句柄。将文件从磁盘写入Socket的过程只有两步
第一步:将文件内容读取到内核缓冲区。
第二步数据并没有从内核缓冲区复制到Socket关联的缓冲区只有记录数据位置和长度的描述符被添加到Socket缓冲区中接着把数据直接从内核缓冲区传递给网卡。这个过程你可以看下面的图。
本期精华
对于一些需要频繁与操作系统进行交互的场景比如网络通信Java的效率没有C语言高特别是TLS协议握手过程中需要多次网络交互这种情况下使用APR本地库能够显著提升性能。
除此之外APR提升性能的秘密还有通过DirectByteBuffer避免了JVM堆与本地内存之间的内存拷贝通过sendfile特性避免了内核与应用之间的内存拷贝以及用户态和内核态的切换。其实很多高性能网络通信组件比如Netty都是通过DirectByteBuffer来收发网络数据的。由于本地内存难于管理Netty采用了本地内存池技术感兴趣的同学可以深入了解一下。
课后思考
为什么不同的操作系统比如Linux和Windows都有自己的Java虚拟机
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,也欢迎你把你的课后思考和心得记录下来,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。

View File

@ -0,0 +1,213 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
17 Executor组件Tomcat如何扩展Java线程池
在开发中我们经常会碰到“池”的概念比如数据库连接池、内存池、线程池、常量池等。为什么需要“池”呢程序运行的本质就是通过使用系统资源CPU、内存、网络、磁盘等来完成信息的处理比如在JVM中创建一个对象实例需要消耗CPU和内存资源如果你的程序需要频繁创建大量的对象并且这些对象的存活时间短就意味着需要进行频繁销毁那么很有可能这部分代码会成为性能的瓶颈。
而“池”就是用来解决这个问题的简单来说对象池就是把用过的对象保存起来等下一次需要这种对象的时候直接从对象池中拿出来重复使用避免频繁地创建和销毁。在Java中万物皆对象线程也是一个对象Java线程是对操作系统线程的封装创建Java线程也需要消耗系统资源因此就有了线程池。JDK中提供了线程池的默认实现我们也可以通过扩展Java原生线程池来实现自己的线程池。
同样为了提高处理能力和并发度Web容器一般会把处理请求的工作放到线程池里来执行Tomcat扩展了原生的Java线程池来满足Web容器高并发的需求下面我们就来学习一下Java线程池的原理以及Tomcat是如何扩展Java线程池的。
Java线程池
简单的说Java线程池里内部维护一个线程数组和一个任务队列当任务处理不过来的时就把任务放到队列里慢慢处理。
ThreadPoolExecutor
我们先来看看Java线程池核心类ThreadPoolExecutor的构造函数你需要知道ThreadPoolExecutor是如何使用这些参数的这是理解Java线程工作原理的关键。
public ThreadPoolExecutor(int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue,
ThreadFactory threadFactory,
RejectedExecutionHandler handler)
每次提交任务时如果线程数还没达到核心线程数corePoolSize线程池就创建新线程来执行。当线程数达到corePoolSize后新增的任务就放到工作队列workQueue里而线程池中的线程则努力地从workQueue里拉活来干也就是调用poll方法来获取任务。
如果任务很多并且workQueue是个有界队列队列可能会满此时线程池就会紧急创建新的临时线程来救场如果总的线程数达到了最大线程数maximumPoolSize则不能再创建新的临时线程了转而执行拒绝策略handler比如抛出异常或者由调用者线程来执行任务等。
如果高峰过去了线程池比较闲了怎么办临时线程使用pollkeepAliveTime, unit方法从工作队列中拉活干请注意poll方法设置了超时时间如果超时了仍然两手空空没拉到活表明它太闲了这个线程会被销毁回收。
那还有一个参数threadFactory是用来做什么的呢通过它你可以扩展原生的线程工厂比如给创建出来的线程取个有意义的名字。
FixedThreadPool/CachedThreadPool
Java提供了一些默认的线程池实现比如FixedThreadPool和CachedThreadPool它们的本质就是给ThreadPoolExecutor设置了不同的参数是定制版的ThreadPoolExecutor。
public static ExecutorService newFixedThreadPool(int nThreads) {
return new ThreadPoolExecutor(nThreads, nThreads,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<Runnable>());
}
public static ExecutorService newCachedThreadPool() {
return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
60L, TimeUnit.SECONDS,
new SynchronousQueue<Runnable>());
}
从上面的代码你可以看到:
FixedThreadPool有固定长度nThreads的线程数组忙不过来时会把任务放到无限长的队列里这是因为LinkedBlockingQueue默认是一个无界队列。
CachedThreadPool的maximumPoolSize参数值是Integer.MAX_VALUE因此它对线程个数不做限制忙不过来时无限创建临时线程闲下来时再回收。它的任务队列是SynchronousQueue表明队列长度为0。
Tomcat线程池
跟FixedThreadPool/CachedThreadPool一样Tomcat的线程池也是一个定制版的ThreadPoolExecutor。
定制版的ThreadPoolExecutor
通过比较FixedThreadPool和CachedThreadPool我们发现它们传给ThreadPoolExecutor的参数有两个关键点
是否限制线程个数。
是否限制队列长度。
对于Tomcat来说这两个资源都需要限制也就是说要对高并发进行控制否则CPU和内存有资源耗尽的风险。因此Tomcat传入的参数是这样的
//定制版的任务队列
taskqueue = new TaskQueue(maxQueueSize);
//定制版的线程工厂
TaskThreadFactory tf = new TaskThreadFactory(namePrefix,daemon,getThreadPriority());
//定制版的线程池
executor = new ThreadPoolExecutor(getMinSpareThreads(), getMaxThreads(), maxIdleTime, TimeUnit.MILLISECONDS,taskqueue, tf);
你可以看到其中的两个关键点:
Tomcat有自己的定制版任务队列和线程工厂并且可以限制任务队列的长度它的最大长度是maxQueueSize。
Tomcat对线程数也有限制设置了核心线程数minSpareThreads和最大线程池数maxThreads
除了资源限制以外Tomcat线程池还定制自己的任务处理流程。我们知道Java原生线程池的任务处理逻辑比较简单
前corePoolSize个任务时来一个任务就创建一个新线程。
后面再来任务,就把任务添加到任务队列里让所有的线程去抢,如果队列满了就创建临时线程。
如果总线程数达到maximumPoolSize执行拒绝策略。
Tomcat线程池扩展了原生的ThreadPoolExecutor通过重写execute方法实现了自己的任务处理逻辑
前corePoolSize个任务时来一个任务就创建一个新线程。
再来任务的话,就把任务添加到任务队列里让所有的线程去抢,如果队列满了就创建临时线程。
如果总线程数达到maximumPoolSize则继续尝试把任务添加到任务队列中去。
如果缓冲队列也满了,插入失败,执行拒绝策略。
观察Tomcat线程池和Java原生线程池的区别其实就是在第3步Tomcat在线程总数达到最大数时不是立即执行拒绝策略而是再尝试向任务队列添加任务添加失败后再执行拒绝策略。那具体如何实现呢其实很简单我们来看一下Tomcat线程池的execute方法的核心代码。
public class ThreadPoolExecutor extends java.util.concurrent.ThreadPoolExecutor {
...
public void execute(Runnable command, long timeout, TimeUnit unit) {
submittedCount.incrementAndGet();
try {
//调用Java原生线程池的execute去执行任务
super.execute(command);
} catch (RejectedExecutionException rx) {
//如果总线程数达到maximumPoolSizeJava原生线程池执行拒绝策略
if (super.getQueue() instanceof TaskQueue) {
final TaskQueue queue = (TaskQueue)super.getQueue();
try {
//继续尝试把任务放到任务队列中去
if (!queue.force(command, timeout, unit)) {
submittedCount.decrementAndGet();
//如果缓冲队列也满了,插入失败,执行拒绝策略。
throw new RejectedExecutionException("...");
}
}
}
}
}
从这个方法你可以看到Tomcat线程池的execute方法会调用Java原生线程池的execute去执行任务如果总线程数达到maximumPoolSizeJava原生线程池的execute方法会抛出RejectedExecutionException异常但是这个异常会被Tomcat线程池的execute方法捕获到并继续尝试把这个任务放到任务队列中去如果任务队列也满了再执行拒绝策略。
定制版的任务队列
细心的你有没有发现在Tomcat线程池的execute方法最开始有这么一行
submittedCount.incrementAndGet();
这行代码的意思把submittedCount这个原子变量加一并且在任务执行失败抛出拒绝异常时将这个原子变量减一
submittedCount.decrementAndGet();
其实Tomcat线程池是用这个变量submittedCount来维护已经提交到了线程池但是还没有执行完的任务个数。Tomcat为什么要维护这个变量呢这跟Tomcat的定制版的任务队列有关。Tomcat的任务队列TaskQueue扩展了Java中的LinkedBlockingQueue我们知道LinkedBlockingQueue默认情况下长度是没有限制的除非给它一个capacity。因此Tomcat给了它一个capacityTaskQueue的构造函数中有个整型的参数capacityTaskQueue将capacity传给父类LinkedBlockingQueue的构造函数。
public class TaskQueue extends LinkedBlockingQueue<Runnable> {
public TaskQueue(int capacity) {
super(capacity);
}
...
}
这个capacity参数是通过Tomcat的maxQueueSize参数来设置的但问题是默认情况下maxQueueSize的值是Integer.MAX_VALUE等于没有限制这样就带来一个问题当前线程数达到核心线程数之后再来任务的话线程池会把任务添加到任务队列并且总是会成功这样永远不会有机会创建新线程了。
为了解决这个问题TaskQueue重写了LinkedBlockingQueue的offer方法在合适的时机返回false返回false表示任务添加失败这时线程池会创建新的线程。那什么是合适的时机呢请看下面offer方法的核心源码
public class TaskQueue extends LinkedBlockingQueue<Runnable> {
...
@Override
//线程池调用任务队列的方法时,当前线程数肯定已经大于核心线程数了
public boolean offer(Runnable o) {
//如果线程数已经到了最大值,不能创建新线程了,只能把任务添加到任务队列。
if (parent.getPoolSize() == parent.getMaximumPoolSize())
return super.offer(o);
//执行到这里,表明当前线程数大于核心线程数,并且小于最大线程数。
//表明是可以创建新线程的,那到底要不要创建呢?分两种情况:
//1. 如果已提交的任务数小于当前线程数,表示还有空闲线程,无需创建新线程
if (parent.getSubmittedCount()<=(parent.getPoolSize()))
return super.offer(o);
//2. 如果已提交的任务数大于当前线程数线程不够用了返回false去创建新线程
if (parent.getPoolSize()<parent.getMaximumPoolSize())
return false;
//默认情况下总是把任务添加到任务队列
return super.offer(o);
}
}
从上面的代码我们看到只有当前线程数大于核心线程数、小于最大线程数并且已提交的任务个数大于当前线程数时也就是说线程不够用了但是线程数又没达到极限才会去创建新的线程。这就是为什么Tomcat需要维护已提交任务数这个变量它的目的就是在任务队列的长度无限制的情况下让线程池有机会创建新的线程。
当然默认情况下Tomcat的任务队列是没有限制的你可以通过设置maxQueueSize参数来限制任务队列的长度。
本期精华
池化的目的是为了避免频繁地创建和销毁对象减少对系统资源的消耗。Java提供了默认的线程池实现我们也可以扩展Java原生的线程池来实现定制自己的线程池Tomcat就是这么做的。Tomcat扩展了Java线程池的核心类ThreadPoolExecutor并重写了它的execute方法定制了自己的任务处理流程。同时Tomcat还实现了定制版的任务队列重写了offer方法使得在任务队列长度无限制的情况下线程池仍然有机会创建新的线程。
课后思考
请你再仔细看看Tomcat的定制版任务队列TaskQueue的offer方法它多次调用了getPoolSize方法但是这个方法是有锁的锁会引起线程上下文切换而损耗性能请问这段代码可以如何优化呢
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,也欢迎你把你的课后思考和心得记录下来,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。

View File

@ -0,0 +1,205 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
18 新特性Tomcat如何支持WebSocket
我们知道HTTP协议是“请求-响应”模式,浏览器必须先发请求给服务器,服务器才会响应这个请求。也就是说,服务器不会主动发送数据给浏览器。
对于实时性要求比较的高的应用比如在线游戏、股票基金实时报价和在线协同编辑等浏览器需要实时显示服务器上最新的数据因此出现了Ajax和Comet技术。Ajax本质上还是轮询而Comet是在HTTP长连接的基础上做了一些hack但是它们的实时性不高另外频繁的请求会给服务器带来压力也会浪费网络流量和带宽。于是HTML5推出了WebSocket标准使得浏览器和服务器之间任何一方都可以主动发消息给对方这样服务器有新数据时可以主动推送给浏览器。
今天我会介绍WebSocket的工作原理以及作为服务器端的Tomcat是如何支持WebSocket的。更重要的是希望你在学完之后可以灵活地选用WebSocket技术来解决实际工作中的问题。
WebSocket工作原理
WebSocket的名字里带有Socket那Socket是什么呢网络上的两个程序通过一个双向链路进行通信这个双向链路的一端称为一个Socket。一个Socket对应一个IP地址和端口号应用程序通常通过Socket向网络发出请求或者应答网络请求。Socket不是协议它其实是对TCP/IP协议层抽象出来的API。
但WebSocket不是一套API跟HTTP协议一样WebSocket也是一个应用层协议。为了跟现有的HTTP协议保持兼容它通过HTTP协议进行一次握手握手之后数据就直接从TCP层的Socket传输就与HTTP协议无关了。浏览器发给服务端的请求会带上跟WebSocket有关的请求头比如Connection: Upgrade和Upgrade: websocket。
如果服务器支持WebSocket同样会在HTTP响应里加上WebSocket相关的HTTP头部。
这样WebSocket连接就建立好了接下来WebSocket的数据传输会以frame形式传输会将一条消息分为几个frame按照先后顺序传输出去。这样做的好处有
大数据的传输可以分片传输,不用考虑数据大小的问题。
和HTTP的chunk一样可以边生成数据边传输提高传输效率。
Tomcat如何支持WebSocket
在讲Tomcat如何支持WebSocket之前我们先来开发一个简单的聊天室程序需求是用户可以通过浏览器加入聊天室、发送消息聊天室的其他人都可以收到消息。
WebSocket聊天室程序
浏览器端JavaScript核心代码如下
var Chat = {};
Chat.socket = null;
Chat.connect = (function(host) {
//判断当前浏览器是否支持WebSocket
if ('WebSocket' in window) {
//如果支持则创建WebSocket JS类
Chat.socket = new WebSocket(host);
} else if ('MozWebSocket' in window) {
Chat.socket = new MozWebSocket(host);
} else {
Console.log('WebSocket is not supported by this browser.');
return;
}
//回调函数当和服务器的WebSocket连接建立起来后浏览器会回调这个方法
Chat.socket.onopen = function () {
Console.log('Info: WebSocket connection opened.');
document.getElementById('chat').onkeydown = function(event) {
if (event.keyCode == 13) {
Chat.sendMessage();
}
};
};
//回调函数当和服务器的WebSocket连接关闭后浏览器会回调这个方法
Chat.socket.onclose = function () {
document.getElementById('chat').onkeydown = null;
Console.log('Info: WebSocket closed.');
};
//回调函数,当服务器有新消息发送到浏览器,浏览器会回调这个方法
Chat.socket.onmessage = function (message) {
Console.log(message.data);
};
});
上面的代码实现逻辑比较清晰就是创建一个WebSocket JavaScript对象然后实现了几个回调方法onopen、onclose和onmessage。当连接建立、关闭和有新消息时浏览器会负责调用这些回调方法。我们再来看服务器端Tomcat的实现代码
//Tomcat端的实现类加上@ServerEndpoint注解里面的value是URL路径
@ServerEndpoint(value = "/websocket/chat")
public class ChatEndpoint {
private static final String GUEST_PREFIX = "Guest";
//记录当前有多少个用户加入到了聊天室它是static全局变量。为了多线程安全使用原子变量AtomicInteger
private static final AtomicInteger connectionIds = new AtomicInteger(0);
//每个用户用一个CharAnnotation实例来维护请你注意它是一个全局的static变量所以用到了线程安全的CopyOnWriteArraySet
private static final Set<ChatEndpoint> connections =
new CopyOnWriteArraySet<>();
private final String nickname;
private Session session;
public ChatEndpoint() {
nickname = GUEST_PREFIX + connectionIds.getAndIncrement();
}
//新连接到达时Tomcat会创建一个Session并回调这个函数
@OnOpen
public void start(Session session) {
this.session = session;
connections.add(this);
String message = String.format("* %s %s", nickname, "has joined.");
broadcast(message);
}
//浏览器关闭连接时Tomcat会回调这个函数
@OnClose
public void end() {
connections.remove(this);
String message = String.format("* %s %s",
nickname, "has disconnected.");
broadcast(message);
}
//浏览器发送消息到服务器时Tomcat会回调这个函数
@OnMessage
public void incoming(String message) {
// Never trust the client
String filteredMessage = String.format("%s: %s",
nickname, HTMLFilter.filter(message.toString()));
broadcast(filteredMessage);
}
//WebSocket连接出错时Tomcat会回调这个函数
@OnError
public void onError(Throwable t) throws Throwable {
log.error("Chat Error: " + t.toString(), t);
}
//向聊天室中的每个用户广播消息
private static void broadcast(String msg) {
for (ChatAnnotation client : connections) {
try {
synchronized (client) {
client.session.getBasicRemote().sendText(msg);
}
} catch (IOException e) {
...
}
}
}
}
根据Java WebSocket规范的规定Java WebSocket应用程序由一系列的WebSocket Endpoint组成。Endpoint是一个Java对象代表WebSocket连接的一端就好像处理HTTP请求的Servlet一样你可以把它看作是处理WebSocket消息的接口。跟Servlet不同的地方在于Tomcat会给每一个WebSocket连接创建一个Endpoint实例。你可以通过两种方式定义和实现Endpoint。
第一种方法是编程式的就是编写一个Java类继承javax.websocket.Endpoint并实现它的onOpen、onClose和onError方法。这些方法跟Endpoint的生命周期有关Tomcat负责管理Endpoint的生命周期并调用这些方法。并且当浏览器连接到一个Endpoint时Tomcat会给这个连接创建一个唯一的Sessionjavax.websocket.Session。Session在WebSocket连接握手成功之后创建并在连接关闭时销毁。当触发Endpoint各个生命周期事件时Tomcat会将当前Session作为参数传给Endpoint的回调方法因此一个Endpoint实例对应一个Session我们通过在Session中添加MessageHandler消息处理器来接收消息MessageHandler中定义了onMessage方法。在这里Session的本质是对Socket的封装Endpoint通过它与浏览器通信。
第二种定义Endpoint的方法是注解式的也就是上面的聊天室程序例子中用到的方式即实现一个业务类并给它添加WebSocket相关的注解。首先我们注意到@ServerEndpoint(value = "/websocket/chat")注解它表明当前业务类ChatEndpoint是一个实现了WebSocket规范的Endpoint并且注解的value值表明ChatEndpoint映射的URL是/websocket/chat。我们还看到ChatEndpoint类中有@OnOpen@OnClose@OnError和在@OnMessage注解的方法,从名字你就知道它们的功能是什么。
对于程序员来说其实我们只需要专注具体的Endpoint的实现比如在上面聊天室的例子中为了方便向所有人群发消息ChatEndpoint在内部使用了一个全局静态的集合CopyOnWriteArraySet来维护所有的ChatEndpoint实例因为每一个ChatEndpoint实例对应一个WebSocket连接也就是代表了一个加入聊天室的用户。当某个ChatEndpoint实例收到来自浏览器的消息时这个ChatEndpoint会向集合中其他ChatEndpoint实例背后的WebSocket连接推送消息。
那么这个过程中Tomcat主要做了哪些事情呢简单来说就是两件事情Endpoint加载和WebSocket请求处理。下面我分别来详细说说Tomcat是如何做这两件事情的。
WebSocket加载
Tomcat的WebSocket加载是通过SCI机制完成的。SCI全称ServletContainerInitializer是Servlet 3.0规范中定义的用来接收Web应用启动事件的接口。那为什么要监听Servlet容器的启动事件呢因为这样我们有机会在Web应用启动时做一些初始化工作比如WebSocket需要扫描和加载Endpoint类。SCI的使用也比较简单将实现ServletContainerInitializer接口的类增加HandlesTypes注解并且在注解内指定的一系列类和接口集合。比如Tomcat为了扫描和加载Endpoint而定义的SCI类如下
@HandlesTypes({ServerEndpoint.class, ServerApplicationConfig.class, Endpoint.class})
public class WsSci implements ServletContainerInitializer {
public void onStartup(Set<Class<?>> clazzes, ServletContext ctx) throws ServletException {
...
}
}
一旦定义好了SCITomcat在启动阶段扫描类时会将HandlesTypes注解中指定的类都扫描出来作为SCI的onStartup方法的参数并调用SCI的onStartup方法。注意到WsSci的HandlesTypes注解中定义了ServerEndpoint.class、ServerApplicationConfig.class和Endpoint.class因此在Tomcat的启动阶段会将这些类的类实例注意不是对象实例传递给WsSci的onStartup方法。那么WsSci的onStartup方法又做了什么事呢
它会构造一个WebSocketContainer实例你可以把WebSocketContainer理解成一个专门处理WebSocket请求的Endpoint容器。也就是说Tomcat会把扫描到的Endpoint子类和添加了注解@ServerEndpoint的类注册到这个容器中并且这个容器还维护了URL到Endpoint的映射关系这样通过请求URL就能找到具体的Endpoint来处理WebSocket请求。
WebSocket请求处理
在讲WebSocket请求处理之前我们先来回顾一下Tomcat连接器的组件图。
你可以看到Tomcat用ProtocolHandler组件屏蔽应用层协议的差异其中ProtocolHandler中有两个关键组件Endpoint和Processor。需要注意这里的Endpoint跟上文提到的WebSocket中的Endpoint完全是两回事连接器中的Endpoint组件用来处理I/O通信。WebSocket本质就是一个应用层协议因此不能用HttpProcessor来处理WebSocket请求而要用专门Processor来处理而在Tomcat中这样的Processor叫作UpgradeProcessor。
为什么叫UpgradeProcessor呢这是因为Tomcat是将HTTP协议升级成WebSocket协议的我们知道WebSocket是通过HTTP协议来进行握手的因此当WebSocket的握手请求到来时HttpProtocolHandler首先接收到这个请求在处理这个HTTP请求时Tomcat通过一个特殊的Filter判断该当前HTTP请求是否是一个WebSocket Upgrade请求即包含Upgrade: websocket的HTTP头信息如果是则在HTTP响应里添加WebSocket相关的响应头信息并进行协议升级。具体来说就是用UpgradeProtocolHandler替换当前的HttpProtocolHandler相应的把当前Socket的Processor替换成UpgradeProcessor同时Tomcat会创建WebSocket Session实例和Endpoint实例并跟当前的WebSocket连接一一对应起来。这个WebSocket连接不会立即关闭并且在请求处理中不再使用原有的HttpProcessor而是用专门的UpgradeProcessorUpgradeProcessor最终会调用相应的Endpoint实例来处理请求。下面我们通过一张图来理解一下。
你可以看到Tomcat对WebSocket请求的处理没有经过Servlet容器而是通过UpgradeProcessor组件直接把请求发到ServerEndpoint实例并且Tomcat的WebSocket实现不需要关注具体I/O模型的细节从而实现了与具体I/O方式的解耦。
本期精华
WebSocket技术实现了Tomcat与浏览器的双向通信Tomcat可以主动向浏览器推送数据可以用来实现对数据实时性要求比较高的应用。这需要浏览器和Web服务器同时支持WebSocket标准Tomcat启动时通过SCI技术来扫描和加载WebSocket的处理类ServerEndpoint并且建立起了URL到ServerEndpoint的映射关系。
当第一个WebSocket请求到达时Tomcat将HTTP协议升级成WebSocket协议并将该Socket连接的Processor替换成UpgradeProcessor。这个Socket不会立即关闭对接下来的请求Tomcat通过UpgradeProcessor直接调用相应的ServerEndpoint来处理。
今天我讲了可以通过两种方式来开发WebSocket应用一种是继承javax.websocket.Endpoint另一种通过WebSocket相关的注解。其实你还可以通过Spring来实现WebSocket应用有兴趣的话你可以去研究一下Spring WebSocket的原理。
课后思考
今天我举的聊天室的例子实现的是群发消息,如果要向某个特定用户发送消息,应该怎么做呢?
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,也欢迎你把你的课后思考和心得记录下来,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。

View File

@ -0,0 +1,237 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
19 比较Jetty的线程策略EatWhatYouKill
我在前面的专栏里介绍了Jetty的总体架构设计简单回顾一下Jetty总体上是由一系列Connector、一系列Handler和一个ThreadPool组成它们的关系如下图所示
相比较Tomcat的连接器Jetty的Connector在设计上有自己的特点。Jetty的Connector支持NIO通信模型我们知道NIO模型中的主角就是SelectorJetty在Java原生Selector的基础上封装了自己的Selector叫作ManagedSelector。ManagedSelector在线程策略方面做了大胆尝试将I/O事件的侦测和处理放到同一个线程来处理充分利用了CPU缓存并减少了线程上下文切换的开销。
具体的数字是根据Jetty的官方测试这种名为“EatWhatYouKill”的线程策略将吞吐量提高了8倍。你一定很好奇它是如何实现的吧今天我们就来看一看这背后的原理是什么。
Selector编程的一般思路
常规的NIO编程思路是将I/O事件的侦测和请求的处理分别用不同的线程处理。具体过程是
启动一个线程在一个死循环里不断地调用select方法检测Channel的I/O状态一旦I/O事件达到比如数据就绪就把该I/O事件以及一些数据包装成一个Runnable将Runnable放到新线程中去处理。
在这个过程中按照职责划分有两个线程在干活一个是I/O事件检测线程另一个是I/O事件处理线程。我们仔细思考一下这两者的关系其实它们是生产者和消费者的关系。I/O事件侦测线程作为生产者负责“生产”I/O事件也就是负责接活儿的老板I/O处理线程是消费者它“消费”并处理I/O事件就是干苦力的员工。把这两个工作用不同的线程来处理好处是它们互不干扰和阻塞对方。
Jetty中的Selector编程
然而世事无绝对将I/O事件检测和业务处理这两种工作分开的思路也有缺点。当Selector检测读就绪事件时数据已经被拷贝到内核中的缓存了同时CPU的缓存中也有这些数据了我们知道CPU本身的缓存比内存快多了这时当应用程序去读取这些数据时如果用另一个线程去读很有可能这个读线程使用另一个CPU核而不是之前那个检测数据就绪的CPU核这样CPU缓存中的数据就用不上了并且线程切换也需要开销。
因此Jetty的Connector做了一个大胆尝试那就是用把I/O事件的生产和消费放到同一个线程来处理如果这两个任务由同一个线程来执行如果执行过程中线程不阻塞操作系统会用同一个CPU核来执行这两个任务这样就能利用CPU缓存了。那具体是如何做的呢我们还是来详细分析一下Connector中的ManagedSelector组件。
ManagedSelector
ManagedSelector的本质就是一个Selector负责I/O事件的检测和分发。为了方便使用Jetty在Java原生的Selector上做了一些扩展就变成了ManagedSelector我们先来看看它有哪些成员变量
public class ManagedSelector extends ContainerLifeCycle implements Dumpable
{
//原子变量表明当前的ManagedSelector是否已经启动
private final AtomicBoolean _started = new AtomicBoolean(false);
//表明是否阻塞在select调用上
private boolean _selecting = false;
//管理器的引用SelectorManager管理若干ManagedSelector的生命周期
private final SelectorManager _selectorManager;
//ManagedSelector不止一个为它们每人分配一个id
private final int _id;
//关键的执行策略,生产者和消费者是否在同一个线程处理由它决定
private final ExecutionStrategy _strategy;
//Java原生的Selector
private Selector _selector;
//"Selector更新任务"队列
private Deque<SelectorUpdate> _updates = new ArrayDeque<>();
private Deque<SelectorUpdate> _updateable = new ArrayDeque<>();
...
}
这些成员变量中其他的都好理解就是“Selector更新任务”队列_updates和执行策略_strategy可能不是很直观。
SelectorUpdate接口
为什么需要一个“Selector更新任务”队列呢对于Selector的用户来说我们对Selector的操作无非是将Channel注册到Selector或者告诉Selector我对什么I/O事件感兴趣那么这些操作其实就是对Selector状态的更新Jetty把这些操作抽象成SelectorUpdate接口。
/**
* A selector update to be done when the selector has been woken.
*/
public interface SelectorUpdate
{
void update(Selector selector);
}
这意味着如果你不能直接操作ManageSelector中的Selector而是需要向ManagedSelector提交一个任务类这个类需要实现SelectorUpdate接口update方法在update方法里定义你想要对ManagedSelector做的操作。
比如Connector中Endpoint组件对读就绪事件感兴趣它就向ManagedSelector提交了一个内部任务类ManagedSelector.SelectorUpdate
_selector.submit(_updateKeyAction);
这个_updateKeyAction就是一个SelectorUpdate实例它的update方法实现如下
private final ManagedSelector.SelectorUpdate _updateKeyAction = new ManagedSelector.SelectorUpdate()
{
@Override
public void update(Selector selector)
{
//这里的updateKey其实就是调用了SelectionKey.interestOps(OP_READ);
updateKey();
}
};
我们看到在update方法里调用了SelectionKey类的interestOps方法传入的参数是OP_READ意思是现在我对这个Channel上的读就绪事件感兴趣了。
那谁来负责执行这些update方法呢答案是ManagedSelector自己它在一个死循环里拉取这些SelectorUpdate任务类逐个执行。
Selectable接口
那I/O事件到达时ManagedSelector怎么知道应该调哪个函数来处理呢其实也是通过一个任务类接口这个接口就是Selectable它返回一个Runnable这个Runnable其实就是I/O事件就绪时相应的处理逻辑。
public interface Selectable
{
//当某一个Channel的I/O事件就绪后ManagedSelector会调用的回调函数
Runnable onSelected();
//当所有事件处理完了之后ManagedSelector会调的回调函数我们先忽略。
void updateKey();
}
ManagedSelector在检测到某个Channel上的I/O事件就绪时也就是说这个Channel被选中了ManagedSelector调用这个Channel所绑定的附件类的onSelected方法来拿到一个Runnable。
这句话有点绕其实就是ManagedSelector的使用者比如Endpoint组件在向ManagedSelector注册读就绪事件时同时也要告诉ManagedSelector在事件就绪时执行什么任务具体来说就是传入一个附件类这个附件类需要实现Selectable接口。ManagedSelector通过调用这个onSelected拿到一个Runnable然后把Runnable扔给线程池去执行。
那Endpoint的onSelected是如何实现的呢
@Override
public Runnable onSelected()
{
int readyOps = _key.readyOps();
boolean fillable = (readyOps & SelectionKey.OP_READ) != 0;
boolean flushable = (readyOps & SelectionKey.OP_WRITE) != 0;
// return task to complete the job
Runnable task= fillable
? (flushable
? _runCompleteWriteFillable
: _runFillable)
: (flushable
? _runCompleteWrite
: null);
return task;
}
上面的代码逻辑很简单,就是读事件到了就读,写事件到了就写。
ExecutionStrategy
铺垫了这么多终于要上主菜了。前面我主要介绍了ManagedSelector的使用者如何跟ManagedSelector交互也就是如何注册Channel以及I/O事件提供什么样的处理类来处理I/O事件接下来我们来看看ManagedSelector是如何统一管理和维护用户注册的Channel集合。再回到今天开始的讨论ManagedSelector将I/O事件的生产和消费看作是生产者消费者模式为了充分利用CPU缓存生产和消费尽量放到同一个线程处理那这是如何实现的呢Jetty定义了ExecutionStrategy接口
public interface ExecutionStrategy
{
//只在HTTP2中用到简单起见我们先忽略这个方法。
public void dispatch();
//实现具体执行策略,任务生产出来后可能由当前线程执行,也可能由新线程来执行
public void produce();
//任务的生产委托给Producer内部接口
public interface Producer
{
//生产一个Runnable(任务)
Runnable produce();
}
}
我们看到ExecutionStrategy接口比较简单它将具体任务的生产委托内部接口Producer而在自己的produce方法里来实现具体执行逻辑也就是生产出来的任务要么由当前线程执行要么放到新线程中执行。Jetty提供了一些具体策略实现类ProduceConsume、ProduceExecuteConsume、ExecuteProduceConsume和EatWhatYouKill。它们的区别是
ProduceConsume任务生产者自己依次生产和执行任务对应到NIO通信模型就是用一个线程来侦测和处理一个ManagedSelector上所有的I/O事件后面的I/O事件要等待前面的I/O事件处理完效率明显不高。通过图来理解图中绿色表示生产一个任务蓝色表示执行这个任务。
ProduceExecuteConsume任务生产者开启新线程来运行任务这是典型的I/O事件侦测和处理用不同的线程来处理缺点是不能利用CPU缓存并且线程切换成本高。同样我们通过一张图来理解图中的棕色表示线程切换。
ExecuteProduceConsume任务生产者自己运行任务但是该策略可能会新建一个新线程以继续生产和执行任务。这种策略也被称为“吃掉你杀的猎物”它来自狩猎伦理认为一个人不应该杀死他不吃掉的东西对应线程来说不应该生成自己不打算运行的任务。它的优点是能利用CPU缓存但是潜在的问题是如果处理I/O事件的业务代码执行时间过长会导致线程大量阻塞和线程饥饿。
EatWhatYouKill这是Jetty对ExecuteProduceConsume策略的改良在线程池线程充足的情况下等同于ExecuteProduceConsume当系统比较忙线程不够时切换成ProduceExecuteConsume策略。为什么要这么做呢原因是ExecuteProduceConsume是在同一线程执行I/O事件的生产和消费它使用的线程来自Jetty全局的线程池这些线程有可能被业务代码阻塞如果阻塞得多了全局线程池中的线程自然就不够用了最坏的情况是连I/O事件的侦测都没有线程可用了会导致Connector拒绝浏览器请求。于是Jetty做了一个优化在低线程情况下就执行ProduceExecuteConsume策略I/O侦测用专门的线程处理I/O事件的处理扔给线程池处理其实就是放到线程池的队列里慢慢处理。
分析了这几种线程策略我们再来看看Jetty是如何实现ExecutionStrategy接口的。答案其实就是实现Produce接口生产任务一旦任务生产出来ExecutionStrategy会负责执行这个任务。
private class SelectorProducer implements ExecutionStrategy.Producer
{
private Set<SelectionKey> _keys = Collections.emptySet();
private Iterator<SelectionKey> _cursor = Collections.emptyIterator();
@Override
public Runnable produce()
{
while (true)
{
//如何Channel集合中有I/O事件就绪调用前面提到的Selectable接口获取Runnable,直接返回给ExecutionStrategy去处理
Runnable task = processSelected();
if (task != null)
return task;
//如果没有I/O事件就绪就干点杂活看看有没有客户提交了更新Selector的任务就是上面提到的SelectorUpdate任务类。
processUpdates();
updateKeys();
//继续执行select方法侦测I/O就绪事件
if (!select())
return null;
}
}
}
SelectorProducer是ManagedSelector的内部类SelectorProducer实现了ExecutionStrategy中的Producer接口中的produce方法需要向ExecutionStrategy返回一个Runnable。在这个方法里SelectorProducer主要干了三件事情
如果Channel集合中有I/O事件就绪调用前面提到的Selectable接口获取Runnable直接返回给ExecutionStrategy去处理。
如果没有I/O事件就绪就干点杂活看看有没有客户提交了更新Selector上事件注册的任务也就是上面提到的SelectorUpdate任务类。
干完杂活继续执行select方法侦测I/O就绪事件。
本期精华
多线程虽然是提高并发的法宝但并不是说线程越多越好CPU缓存以及线程上下文切换的开销也是需要考虑的。Jetty巧妙设计了EatWhatYouKill的线程策略尽量用同一个线程侦测I/O事件和处理I/O事件充分利用了CPU缓存并减少了线程切换的开销。
课后思考
文章提到ManagedSelector的使用者不能直接向它注册I/O事件而是需要向ManagedSelector提交一个SelectorUpdate事件ManagedSelector将这些事件Queue起来由自己来统一处理这样做有什么好处呢
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,也欢迎你把你的课后思考和心得记录下来,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。

View File

@ -0,0 +1,199 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
20 总结Tomcat和Jetty中的对象池技术
Java对象特别是一个比较大、比较复杂的Java对象它们的创建、初始化和GC都需要耗费CPU和内存资源为了减少这些开销Tomcat和Jetty都使用了对象池技术。所谓的对象池技术就是说一个Java对象用完之后把它保存起来之后再拿出来重复使用省去了对象创建、初始化和GC的过程。对象池技术是典型的以空间换时间的思路。
由于维护对象池本身也需要资源的开销不是所有场景都适合用对象池。如果你的Java对象数量很多并且存在的时间比较短对象本身又比较大比较复杂对象初始化的成本比较高这样的场景就适合用对象池技术。比如Tomcat和Jetty处理HTTP请求的场景就符合这个特征请求的数量很多为了处理单个请求需要创建不少的复杂对象比如Tomcat连接器中SocketWrapper和SocketProcessor而且一般来说请求处理的时间比较短一旦请求处理完毕这些对象就需要被销毁因此这个场景适合对象池技术。
Tomcat的SynchronizedStack
Tomcat用SynchronizedStack类来实现对象池下面我贴出它的关键代码来帮助你理解。
public class SynchronizedStack<T> {
//内部维护一个对象数组,用数组实现栈的功能
private Object[] stack;
//这个方法用来归还对象用synchronized进行线程同步
public synchronized boolean push(T obj) {
index++;
if (index == size) {
if (limit == -1 || size < limit) {
expand();//对象不够用了,扩展对象数组
} else {
index--;
return false;
}
}
stack[index] = obj;
return true;
}
//这个方法用来获取对象
public synchronized T pop() {
if (index == -1) {
return null;
}
T result = (T) stack[index];
stack[index--] = null;
return result;
}
//扩展对象数组长度以2倍大小扩展
private void expand() {
int newSize = size * 2;
if (limit != -1 && newSize > limit) {
newSize = limit;
}
//扩展策略是创建一个数组长度为原来两倍的新数组
Object[] newStack = new Object[newSize];
//将老数组对象引用复制到新数组
System.arraycopy(stack, 0, newStack, 0, size);
//将stack指向新数组老数组可以被GC掉了
stack = newStack;
size = newSize;
}
}
这个代码逻辑比较清晰主要是SynchronizedStack内部维护了一个对象数组并且用数组来实现栈的接口push和pop方法这两个方法分别用来归还对象和获取对象。你可能好奇为什么Tomcat使用一个看起来比较简单的SynchronizedStack来做对象容器为什么不使用高级一点的并发容器比如ConcurrentLinkedQueue呢
这是因为SynchronizedStack用数组而不是链表来维护对象可以减少结点维护的内存开销并且它本身只支持扩容不支持缩容也就是说数组对象在使用过程中不会被重新赋值也就不会被GC。这样设计的目的是用最低的内存和GC的代价来实现无界容器同时Tomcat的最大同时请求数是有限制的因此不需要担心对象的数量会无限膨胀。
Jetty的ByteBufferPool
我们再来看Jetty中的对象池ByteBufferPool它本质是一个ByteBuffer对象池。当Jetty在进行网络数据读写时不需要每次都在JVM堆上分配一块新的Buffer只需在ByteBuffer对象池里拿到一块预先分配好的Buffer这样就避免了频繁的分配内存和释放内存。这种设计你同样可以在高性能通信中间件比如Mina和Netty中看到。ByteBufferPool是一个接口
public interface ByteBufferPool
{
public ByteBuffer acquire(int size, boolean direct);
public void release(ByteBuffer buffer);
}
接口中的两个方法acquire和release分别用来分配和释放内存并且你可以通过acquire方法的direct参数来指定buffer是从JVM堆上分配还是从本地内存分配。ArrayByteBufferPool是ByteBufferPool的实现类我们先来看看它的成员变量和构造函数
public class ArrayByteBufferPool implements ByteBufferPool
{
private final int _min;//最小size的Buffer长度
private final int _maxQueue;//Queue最大长度
//用不同的Bucket(桶)来持有不同size的ByteBuffer对象,同一个桶中的ByteBuffer size是一样的
private final ByteBufferPool.Bucket[] _direct;
private final ByteBufferPool.Bucket[] _indirect;
//ByteBuffer的size增量
private final int _inc;
public ArrayByteBufferPool(int minSize, int increment, int maxSize, int maxQueue)
{
//检查参数值并设置默认值
if (minSize<=0)//ByteBuffer的最小长度
minSize=0;
if (increment<=0)
increment=1024;//默认以1024递增
if (maxSize<=0)
maxSize=64*1024;//ByteBuffer的最大长度默认是64K
//ByteBuffer的最小长度必须小于增量
if (minSize>=increment)
throw new IllegalArgumentException("minSize >= increment");
//最大长度必须是增量的整数倍
if ((maxSize%increment)!=0 || increment>=maxSize)
throw new IllegalArgumentException("increment must be a divisor of maxSize");
_min=minSize;
_inc=increment;
//创建maxSize/increment个桶,包含直接内存的与heap的
_direct=new ByteBufferPool.Bucket[maxSize/increment];
_indirect=new ByteBufferPool.Bucket[maxSize/increment];
_maxQueue=maxQueue;
int size=0;
for (int i=0;i<_direct.length;i++)
{
size+=_inc;
_direct[i]=new ByteBufferPool.Bucket(this,size,_maxQueue);
_indirect[i]=new ByteBufferPool.Bucket(this,size,_maxQueue);
}
}
}
从上面的代码我们看到ByteBufferPool是用不同的桶Bucket来管理不同长度的ByteBuffer因为我们可能需要分配一块1024字节的Buffer也可能需要一块64K字节的Buffer。而桶的内部用一个ConcurrentLinkedDeque来放置ByteBuffer对象的引用。
private final Deque<ByteBuffer> _queue = new ConcurrentLinkedDeque<>();
你可以通过下面的图再来理解一下:
而Buffer的分配和释放过程就是找到相应的桶并对桶中的Deque做出队和入队的操作而不是直接向JVM堆申请和释放内存。
//分配Buffer
public ByteBuffer acquire(int size, boolean direct)
{
//找到对应的桶,没有的话创建一个桶
ByteBufferPool.Bucket bucket = bucketFor(size,direct);
if (bucket==null)
return newByteBuffer(size,direct);
//这里其实调用了Deque的poll方法
return bucket.acquire(direct);
}
//释放Buffer
public void release(ByteBuffer buffer)
{
if (buffer!=null)
{
//找到对应的桶
ByteBufferPool.Bucket bucket = bucketFor(buffer.capacity(),buffer.isDirect());
//这里调用了Deque的offerFirst方法
if (bucket!=null)
bucket.release(buffer);
}
}
对象池的思考
对象池作为全局资源,高并发环境中多个线程可能同时需要获取对象池中的对象,因此多个线程在争抢对象时会因为锁竞争而阻塞, 因此使用对象池有线程同步的开销而不使用对象池则有创建和销毁对象的开销。对于对象池本身的设计来说需要尽量做到无锁化比如Jetty就使用了ConcurrentLinkedDeque。如果你的内存足够大可以考虑用线程本地ThreadLocal对象池这样每个线程都有自己的对象池线程之间互不干扰。
为了防止对象池的无限膨胀,必须要对池的大小做限制。对象池太小发挥不了作用,对象池太大的话可能有空闲对象,这些空闲对象会一直占用内存,造成内存浪费。这里你需要根据实际情况做一个平衡,因此对象池本身除了应该有自动扩容的功能,还需要考虑自动缩容。
所有的池化技术包括缓存都会面临内存泄露的问题原因是对象池或者缓存的本质是一个Java集合类比如List和Stack这个集合类持有缓存对象的引用只要集合类不被GC缓存对象也不会被GC。维持大量的对象也比较占用内存空间所以必要时我们需要主动清理这些对象。以Java的线程池ThreadPoolExecutor为例它提供了allowCoreThreadTimeOut和setKeepAliveTime两种方法可以在超时后销毁线程我们在实际项目中也可以参考这个策略。
另外在使用对象池时,我这里还有一些小贴士供你参考:
对象在用完后,需要调用对象池的方法将对象归还给对象池。
对象池中的对象在再次使用时需要重置,否则会产生脏对象,脏对象可能持有上次使用的引用,导致内存泄漏等问题,并且如果脏对象下一次使用时没有被清理,程序在运行过程中会发生意想不到的问题。
对象一旦归还给对象池,使用者就不能对它做任何操作了。
向对象池请求对象时有可能出现的阻塞、异常或者返回null值这些都需要我们做一些额外的处理来确保程序的正常运行。
本期精华
Tomcat和Jetty都用到了对象池技术这是因为处理一次HTTP请求的时间比较短但是这个过程中又需要创建大量复杂对象。
对象池技术可以减少频繁创建和销毁对象带来的成本实现对象的缓存和复用。如果你的系统需要频繁的创建和销毁对象并且对象的创建代价比较大这种情况下一般来说你会观察到GC的压力比较大占用CPU率比较高这个时候你就可以考虑使用对象池了。
还有一种情况是你需要对资源的使用做限制,比如数据库连接,不能无限制地创建数据库连接,因此就有了数据库连接池,你也可以考虑把一些关键的资源池化,对它们进行统一管理,防止滥用。
课后思考
请你想想在实际工作中,有哪些场景可以用“池化”技术来优化。
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,也欢迎你把你的课后思考和心得记录下来,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。

View File

@ -0,0 +1,172 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
21 总结Tomcat和Jetty的高性能、高并发之道
高性能程序就是高效的利用CPU、内存、网络和磁盘等资源在短时间内处理大量的请求。那如何衡量“短时间和大量”呢其实就是两个关键指标响应时间和每秒事务处理量TPS
那什么是资源的高效利用呢? 我觉得有两个原则:
减少资源浪费。比如尽量避免线程阻塞因为一阻塞就会发生线程上下文切换就需要耗费CPU资源再比如网络通信时数据从内核空间拷贝到Java堆内存需要通过本地内存中转。
当某种资源成为瓶颈时用另一种资源来换取。比如缓存和对象池技术就是用内存换CPU数据压缩后再传输就是用CPU换网络。
Tomcat和Jetty中用到了大量的高性能、高并发的设计我总结了几点I/O和线程模型、减少系统调用、池化、零拷贝、高效的并发编程。下面我会详细介绍这些设计希望你也可以将这些技术用到实际的工作中去。
I/O和线程模型
I/O模型的本质就是为了缓解CPU和外设之间的速度差。当线程发起I/O请求时比如读写网络数据网卡数据还没准备好这个线程就会被阻塞让出CPU也就是说发生了线程切换。而线程切换是无用功并且线程被阻塞后它持有内存资源并没有释放阻塞的线程越多消耗的内存就越大因此I/O模型的目标就是尽量减少线程阻塞。Tomcat和Jetty都已经抛弃了传统的同步阻塞I/O采用了非阻塞I/O或者异步I/O目的是业务线程不需要阻塞在I/O等待上。
除了I/O模型线程模型也是影响性能和并发的关键点。Tomcat和Jetty的总体处理原则是
连接请求由专门的Acceptor线程组处理。
I/O事件侦测也由专门的Selector线程组来处理。
具体的协议解析和业务处理可能交给线程池Tomcat或者交给Selector线程来处理Jetty
将这些事情分开的好处是解耦并且可以根据实际情况合理设置各部分的线程数。这里请你注意线程数并不是越多越好因为CPU核的个数有限线程太多也处理不过来会导致大量的线程上下文切换。
减少系统调用
其实系统调用是非常耗资源的一个过程涉及CPU从用户态切换到内核态的过程因此我们在编写程序的时候要有意识尽量避免系统调用。比如在Tomcat和Jetty中系统调用最多的就是网络通信操作了一个Channel上的write就是系统调用为了降低系统调用的次数最直接的方法就是使用缓冲当输出数据达到一定的大小才flush缓冲区。Tomcat和Jetty的Channel都带有输入输出缓冲区。
还有值得一提的是Tomcat和Jetty在解析HTTP协议数据时 都采取了延迟解析的策略HTTP的请求体HTTP Body直到用的时候才解析。也就是说当Tomcat调用Servlet的service方法时只是读取了和解析了HTTP请求头并没有读取HTTP请求体。
直到你的Web应用程序调用了ServletRequest对象的getInputStream方法或者getParameter方法时Tomcat才会去读取和解析HTTP请求体中的数据这意味着如果你的应用程序没有调用上面那两个方法HTTP请求体的数据就不会被读取和解析这样就省掉了一次I/O系统调用。
池化、零拷贝
关于池化和零拷贝我在专栏前面已经详细讲了它们的原理你可以回过头看看专栏第20期和第16期。其实池化的本质就是用内存换CPU而零拷贝就是不做无用功减少资源浪费。
高效的并发编程
我们知道并发的过程中为了同步多个线程对共享变量的访问需要加锁来实现。而锁的开销是比较大的拿锁的过程本身就是个系统调用如果锁没拿到线程会阻塞又会发生线程上下文切换尤其是大量线程同时竞争一把锁时会浪费大量的系统资源。因此作为程序员要有意识的尽量避免锁的使用比如可以使用原子类CAS或者并发集合来代替。如果万不得已需要用到锁也要尽量缩小锁的范围和锁的强度。接下来我们来看看Tomcat和Jetty如何做到高效的并发编程的。
缩小锁的范围
缩小锁的范围其实就是不直接在方法上加synchronized而是使用细粒度的对象锁。
protected void startInternal() throws LifecycleException {
setState(LifecycleState.STARTING);
// 锁engine成员变量
if (engine != null) {
synchronized (engine) {
engine.start();
}
}
//锁executors成员变量
synchronized (executors) {
for (Executor executor: executors) {
executor.start();
}
}
mapperListener.start();
//锁connectors成员变量
synchronized (connectorsLock) {
for (Connector connector: connectors) {
// If it has already failed, don't try and start it
if (connector.getState() != LifecycleState.FAILED) {
connector.start();
}
}
}
}
比如上面的代码是Tomcat的StandardService组件的启动方法这个启动方法要启动三种子组件Engine、Executors和Connectors。它没有直接在方法上加锁而是用了三把细粒度的锁来分别用来锁三个成员变量。如果直接在方法上加synchronized多个线程执行到这个方法时需要排队而在对象级别上加synchronized多个线程可以并行执行这个方法只是在访问某个成员变量时才需要排队。
用原子变量和CAS取代锁
下面的代码是Jetty线程池的启动方法它的主要功能就是根据传入的参数启动相应个数的线程。
private boolean startThreads(int threadsToStart)
{
while (threadsToStart > 0 && isRunning())
{
//获取当前已经启动的线程数,如果已经够了就不需要启动了
int threads = _threadsStarted.get();
if (threads >= _maxThreads)
return false;
//用CAS方法将线程数加一请注意执行失败走continue继续尝试
if (!_threadsStarted.compareAndSet(threads, threads + 1))
continue;
boolean started = false;
try
{
Thread thread = newThread(_runnable);
thread.setDaemon(isDaemon());
thread.setPriority(getThreadsPriority());
thread.setName(_name + "-" + thread.getId());
_threads.add(thread);//_threads并发集合
_lastShrink.set(System.nanoTime());//_lastShrink是原子变量
thread.start();
started = true;
--threadsToStart;
}
finally
{
//如果最终线程启动失败,还需要把线程数减一
if (!started)
_threadsStarted.decrementAndGet();
}
}
return true;
}
你可以看到整个函数的实现是一个while循环并且是无锁的。_threadsStarted表示当前线程池已经启动了多少个线程它是一个原子变量AtomicInteger首先通过它的get方法拿到值如果线程数已经达到最大值直接返回。否则尝试用CAS操作将_threadsStarted的值加一如果成功了意味着没有其他线程在改这个值当前线程可以继续往下执行否则走continue分支也就是继续重试直到成功为止。在这里当然你也可以使用锁来实现但是我们的目的是无锁化。
并发容器的使用
CopyOnWriteArrayList适用于读多写少的场景比如Tomcat用它来“存放”事件监听器这是因为监听器一般在初始化过程中确定后就基本不会改变当事件触发时需要遍历这个监听器列表所以这个场景符合读多写少的特征。
public abstract class LifecycleBase implements Lifecycle {
//事件监听器集合
private final List<LifecycleListener> lifecycleListeners = new CopyOnWriteArrayList<>();
...
}
volatile关键字的使用
再拿Tomcat中的LifecycleBase作为例子它里面的生命状态就是用volatile关键字修饰的。volatile的目的是为了保证一个线程修改了变量另一个线程能够读到这种变化。对于生命状态来说需要在各个线程中保持是最新的值因此采用了volatile修饰。
public abstract class LifecycleBase implements Lifecycle {
//当前组件的生命状态用volatile修饰
private volatile LifecycleState state = LifecycleState.NEW;
}
本期精华
高性能程序能够高效的利用系统资源首先就是减少资源浪费比如要减少线程的阻塞因为阻塞会导致资源闲置和线程上下文切换Tomcat和Jetty通过合理的I/O模型和线程模型减少了线程的阻塞。
另外系统调用会导致用户态和内核态切换的过程Tomcat和Jetty通过缓存和延迟解析尽量减少系统调用另外还通过零拷贝技术避免多余的数据拷贝。
高效的利用资源还包括另一层含义那就是我们在系统设计的过程中经常会用一种资源换取另一种资源比如Tomcat和Jetty中使用的对象池技术就是用内存换取CPU将数据压缩后再传输就是用CPU换网络。
除此之外高效的并发编程也很重要多线程虽然可以提高并发度也带来了锁的开销因此我们在实际编程过程中要尽量避免使用锁比如可以用原子变量和CAS操作来代替锁。如果实在避免不了用锁也要尽量减少锁的范围和强度比如可以用细粒度的对象锁或者低强度的读写锁。Tomcat和Jetty的代码也很好的实践了这一理念。
课后思考
今天的文章提到我们要有意识尽量避免系统调用那你知道有哪些Java API会导致系统调用吗
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,也欢迎你把你的课后思考和心得记录下来,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。

View File

@ -0,0 +1,60 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
22 热点问题答疑2内核如何阻塞与唤醒进程
在专栏的第三个模块我们学习了Tomcat连接器组件的设计其中最重要的是各种I/O模型及其实现。而I/O模型跟操作系统密切相关要彻底理解这些原理我们首先需要弄清楚什么是进程和线程什么是虚拟内存和物理内存什么是用户空间和内核空间线程的阻塞到底意味着什么内核又是如何唤醒用户线程的等等这些问题。可以说掌握这些底层的知识对于你学习Tomcat和Jetty的原理乃至其他各种后端架构都至关重要这些知识可以说是后端开发的“基石”。
在专栏的留言中我也发现很多同学反馈对这些底层的概念很模糊,那今天作为模块的答疑篇,我就来跟你聊聊这些问题。
进程和线程
我们先从Linux的进程谈起操作系统要运行一个可执行程序首先要将程序文件加载到内存然后CPU去读取和执行程序指令而一个进程就是“一次程序的运行过程”内核会给每一个进程创建一个名为task_struct的数据结构而内核也是一段程序系统启动时就被加载到内存中了。
进程在运行过程中要访问内存而物理内存是有限的比如16GB那怎么把有限的内存分给不同的进程使用呢跟CPU的分时共享一样内存也是共享的Linux给每个进程虚拟出一块很大的地址空间比如32位机器上进程的虚拟内存地址空间是4GB从0x00000000到0xFFFFFFFF。但这4GB并不是真实的物理内存而是进程访问到了某个虚拟地址如果这个地址还没有对应的物理内存页就会产生缺页中断分配物理内存MMU内存管理单元会将虚拟地址与物理内存页的映射关系保存在页表中再次访问这个虚拟地址就能找到相应的物理内存页。每个进程的这4GB虚拟地址空间分布如下图所示
进程的虚拟地址空间总体分为用户空间和内核空间低地址上的3GB属于用户空间高地址的1GB是内核空间这是基于安全上的考虑用户程序只能访问用户空间内核程序可以访问整个进程空间并且只有内核可以直接访问各种硬件资源比如磁盘和网卡。那用户程序需要访问这些硬件资源该怎么办呢答案是通过系统调用系统调用可以理解为内核实现的函数比如应用程序要通过网卡接收数据会调用Socket的read函数
ssize_t read(int fd,void *buf,size_t nbyte)
CPU在执行系统调用的过程中会从用户态切换到内核态CPU在用户态下执行用户程序使用的是用户空间的栈访问用户空间的内存当CPU切换到内核态后执行内核代码使用的是内核空间上的栈。
从上面这张图我们看到用户空间从低到高依次是代码区、数据区、堆、共享库与mmap内存映射区、栈、环境变量。其中堆向高地址增长栈向低地址增长。
请注意用户空间上还有一个共享库和mmap映射区Linux提供了内存映射函数mmap 它可将文件内容映射到这个内存区域用户通过读写这段内存从而实现对文件的读取和修改无需通过read/write系统调用来读写文件省去了用户空间和内核空间之间的数据拷贝Java的MappedByteBuffer就是通过它来实现的用户程序用到的系统共享库也是通过mmap映射到了这个区域。
我在开始提到的task_struct结构体本身是分配在内核空间它的vm_struct成员变量保存了各内存区域的起始和终止地址此外task_struct中还保存了进程的其他信息比如进程号、打开的文件、创建的Socket以及CPU运行上下文等。
在Linux中线程是一个轻量级的进程轻量级说的是线程只是一个CPU调度单元因此线程有自己的task_struct结构体和运行栈区但是线程的其他资源都是跟父进程共用的比如虚拟地址空间、打开的文件和Socket等。
阻塞与唤醒
我们知道当用户线程发起一个阻塞式的read调用数据未就绪时线程就会阻塞那阻塞具体是如何实现的呢
Linux内核将线程当作一个进程进行CPU调度内核维护了一个可运行的进程队列所有处于TASK_RUNNING状态的进程都会被放入运行队列中本质是用双向链表将task_struct链接起来排队使用CPU时间片时间片用完重新调度CPU。所谓调度就是在可运行进程列表中选择一个进程再从CPU列表中选择一个可用的CPU将进程的上下文恢复到这个CPU的寄存器中然后执行进程上下文指定的下一条指令。
而阻塞的本质就是将进程的task_struct移出运行队列添加到等待队列并且将进程的状态的置为TASK_UNINTERRUPTIBLE或者TASK_INTERRUPTIBLE重新触发一次CPU调度让出CPU。
那线程怎么唤醒呢线程在加入到等待队列的同时向内核注册了一个回调函数告诉内核我在等待这个Socket上的数据如果数据到了就唤醒我。这样当网卡接收到数据时产生硬件中断内核再通过调用回调函数唤醒进程。唤醒的过程就是将进程的task_struct从等待队列移到运行队列并且将task_struct的状态置为TASK_RUNNING这样进程就有机会重新获得CPU时间片。
这个过程中,内核还会将数据从内核空间拷贝到用户空间的堆上。
当read系统调用返回时CPU又从内核态切换到用户态继续执行read调用的下一行代码并且能从用户空间上的Buffer读到数据了。
小结
今天我们谈到了一次Socket read系统调用的过程首先CPU在用户态执行应用程序的代码访问进程虚拟地址空间的用户空间read系统调用时CPU从用户态切换到内核态执行内核代码内核检测到Socket上的数据未就绪时将进程的task_struct结构体从运行队列中移到等待队列并触发一次CPU调度这时进程会让出CPU当网卡数据到达时内核将数据从内核空间拷贝到用户空间的Buffer接着将进程的task_struct结构体重新移到运行队列这样进程就有机会重新获得CPU时间片系统调用返回CPU又从内核态切换到用户态访问用户空间的数据。
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。

View File

@ -0,0 +1,217 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
23 Host容器Tomcat如何实现热部署和热加载
从这一期我们开始学习Tomcat的容器模块来聊一聊各容器组件实现的功能主要有热部署热加载、类加载机制以及Servlet规范的实现。最后还会谈到Spring Boot是如何与Web容器进行交互的。
今天我们首先来看热部署和热加载。要在运行的过程中升级Web应用如果你不想重启系统实现的方式有两种热加载和热部署。
那如何实现热部署和热加载呢?它们跟类加载机制有关,具体来说就是:
热加载的实现方式是Web容器启动一个后台线程定期检测类文件的变化如果有变化就重新加载类在这个过程中不会清空Session ,一般用在开发环境。
热部署原理类似也是由后台线程定时检测Web应用的变化但它会重新加载整个Web应用。这种方式会清空Session比热加载更加干净、彻底一般用在生产环境。
今天我们来学习一下Tomcat是如何用后台线程来实现热加载和热部署的。Tomcat通过开启后台线程使得各个层次的容器组件都有机会完成一些周期性任务。我们在实际工作中往往也需要执行一些周期性的任务比如监控程序周期性拉取系统的健康状态就可以借鉴这种设计。
Tomcat的后台线程
要说开启后台线程做周期性的任务有经验的同学马上会想到线程池中的ScheduledThreadPoolExecutor它除了具有线程池的功能还能够执行周期性的任务。Tomcat就是通过它来开启后台线程的
bgFuture = exec.scheduleWithFixedDelay(
new ContainerBackgroundProcessor(),//要执行的Runnable
backgroundProcessorDelay, //第一次执行延迟多久
backgroundProcessorDelay, //之后每次执行间隔多久
TimeUnit.SECONDS); //时间单位
上面的代码调用了scheduleWithFixedDelay方法传入了四个参数第一个参数就是要周期性执行的任务类ContainerBackgroundProcessor它是一个Runnable同时也是ContainerBase的内部类ContainerBase是所有容器组件的基类我们来回忆一下容器组件有哪些有Engine、Host、Context和Wrapper等它们具有父子关系。
ContainerBackgroundProcessor实现
我们接来看ContainerBackgroundProcessor具体是如何实现的。
protected class ContainerBackgroundProcessor implements Runnable {
@Override
public void run() {
//请注意这里传入的参数是"宿主类"的实例
processChildren(ContainerBase.this);
}
protected void processChildren(Container container) {
try {
//1. 调用当前容器的backgroundProcess方法。
container.backgroundProcess();
//2. 遍历所有的子容器递归调用processChildren
//这样当前容器的子孙都会被处理
Container[] children = container.findChildren();
for (int i = 0; i < children.length; i++) {
//这里请你注意容器基类有个变量叫做backgroundProcessorDelay如果大于0表明子容器有自己的后台线程无需父容器来调用它的processChildren方法。
if (children[i].getBackgroundProcessorDelay() <= 0) {
processChildren(children[i]);
}
}
} catch (Throwable t) { ... }
上面的代码逻辑也是比较清晰的首先ContainerBackgroundProcessor是一个Runnable它需要实现run方法它的run很简单就是调用了processChildren方法。这里有个小技巧它把“宿主类”也就是ContainerBase的类实例当成参数传给了run方法。
而在processChildren方法里就做了两步调用当前容器的backgroundProcess方法以及递归调用子孙的backgroundProcess方法。请你注意backgroundProcess是Container接口中的方法也就是说所有类型的容器都可以实现这个方法在这个方法里完成需要周期性执行的任务。
这样的设计意味着什么呢我们只需要在顶层容器也就是Engine容器中启动一个后台线程那么这个线程不但会执行Engine容器的周期性任务它还会执行所有子容器的周期性任务。
backgroundProcess方法
上述代码都是在基类ContainerBase中实现的那具体容器类需要做什么呢其实很简单如果有周期性任务要执行就实现backgroundProcess方法如果没有就重用基类ContainerBase的方法。ContainerBase的backgroundProcess方法实现如下
public void backgroundProcess() {
//1.执行容器中Cluster组件的周期性任务
Cluster cluster = getClusterInternal();
if (cluster != null) {
cluster.backgroundProcess();
}
//2.执行容器中Realm组件的周期性任务
Realm realm = getRealmInternal();
if (realm != null) {
realm.backgroundProcess();
}
//3.执行容器中Valve组件的周期性任务
Valve current = pipeline.getFirst();
while (current != null) {
current.backgroundProcess();
current = current.getNext();
}
//4. 触发容器的"周期事件"Host容器的监听器HostConfig就靠它来调用
fireLifecycleEvent(Lifecycle.PERIODIC_EVENT, null);
}
从上面的代码可以看到不仅每个容器可以有周期性任务每个容器中的其他通用组件比如跟集群管理有关的Cluster组件、跟安全管理有关的Realm组件都可以有自己的周期性任务。
我在前面的专栏里提到过容器之间的链式调用是通过Pipeline-Valve机制来实现的从上面的代码你可以看到容器中的Valve也可以有周期性任务并且被ContainerBase统一处理。
请你特别注意的是在backgroundProcess方法的最后还触发了容器的“周期事件”。我们知道容器的生命周期事件有初始化、启动和停止等那“周期事件”又是什么呢它跟生命周期事件一样是一种扩展机制你可以这样理解
又一段时间过去了,容器还活着,你想做点什么吗?如果你想做点什么,就创建一个监听器来监听这个“周期事件”,事件到了我负责调用你的方法。
总之有了ContainerBase中的后台线程和backgroundProcess方法各种子容器和通用组件不需要各自弄一个后台线程来处理周期性任务这样的设计显得优雅和整洁。
Tomcat热加载
有了ContainerBase的周期性任务处理“框架”作为具体容器子类只需要实现自己的周期性任务就行。而Tomcat的热加载就是在Context容器中实现的。Context容器的backgroundProcess方法是这样实现的
public void backgroundProcess() {
//WebappLoader周期性的检查WEB-INF/classes和WEB-INF/lib目录下的类文件
Loader loader = getLoader();
if (loader != null) {
loader.backgroundProcess();
}
//Session管理器周期性的检查是否有过期的Session
Manager manager = getManager();
if (manager != null) {
manager.backgroundProcess();
}
//周期性的检查静态资源是否有变化
WebResourceRoot resources = getResources();
if (resources != null) {
resources.backgroundProcess();
}
//调用父类ContainerBase的backgroundProcess方法
super.backgroundProcess();
}
从上面的代码我们看到Context容器通过WebappLoader来检查类文件是否有更新通过Session管理器来检查是否有Session过期并且通过资源管理器来检查静态资源是否有更新最后还调用了父类ContainerBase的backgroundProcess方法。
这里我们要重点关注WebappLoader是如何实现热加载的它主要是调用了Context容器的reload方法而Context的reload方法比较复杂总结起来主要完成了下面这些任务
停止和销毁Context容器及其所有子容器子容器其实就是Wrapper也就是说Wrapper里面Servlet实例也被销毁了。
停止和销毁Context容器关联的Listener和Filter。
停止和销毁Context下的Pipeline和各种Valve。
停止和销毁Context的类加载器以及类加载器加载的类文件资源。
启动Context容器在这个过程中会重新创建前面四步被销毁的资源。
在这个过程中类加载器发挥着关键作用。一个Context容器对应一个类加载器类加载器在销毁的过程中会把它加载的所有类也全部销毁。Context容器在启动过程中会创建一个新的类加载器来加载新的类文件。
在Context的reload方法里并没有调用Session管理器的destroy方法也就是说这个Context关联的Session是没有销毁的。你还需要注意的是Tomcat的热加载默认是关闭的你需要在conf目录下的context.xml文件中设置reloadable参数来开启这个功能像下面这样
<Context reloadable="true"/>
Tomcat热部署
我们再来看看热部署热部署跟热加载的本质区别是热部署会重新部署Web应用原来的Context对象会整个被销毁掉因此这个Context所关联的一切资源都会被销毁包括Session。
那么Tomcat热部署又是由哪个容器来实现的呢应该不是由Context因为热部署过程中Context容器被销毁了那么这个重担就落在Host身上了因为它是Context的父容器。
跟Context不一样Host容器并没有在backgroundProcess方法中实现周期性检测的任务而是通过监听器HostConfig来实现的HostConfig就是前面提到的“周期事件”的监听器那“周期事件”达到时HostConfig会做什么事呢
public void lifecycleEvent(LifecycleEvent event) {
// 执行check方法。
if (event.getType().equals(Lifecycle.PERIODIC_EVENT)) {
check();
}
}
它执行了check方法我们接着来看check方法里做了什么。
protected void check() {
if (host.getAutoDeploy()) {
// 检查这个Host下所有已经部署的Web应用
DeployedApplication[] apps =
deployed.values().toArray(new DeployedApplication[0]);
for (int i = 0; i < apps.length; i++) {
//检查Web应用目录是否有变化
checkResources(apps[i], false);
}
//执行部署
deployApps();
}
}
其实HostConfig会检查webapps目录下的所有Web应用
如果原来Web应用目录被删掉了就把相应Context容器整个销毁掉。
是否有新的Web应用目录放进来了或者有新的WAR包放进来了就部署相应的Web应用。
因此HostConfig做的事情都是比较“宏观”的它不会去检查具体类文件或者资源文件是否有变化而是检查Web应用目录级别的变化。
本期精华
今天我们学习Tomcat的热加载和热部署它们的目的都是在不重启Tomcat的情况下实现Web应用的更新。
热加载的粒度比较小主要是针对类文件的更新通过创建新的类加载器来实现重新加载。而热部署是针对整个Web应用的Tomcat会将原来的Context对象整个销毁掉再重新创建Context容器对象。
热加载和热部署的实现都离不开后台线程的周期性检查Tomcat在基类ContainerBase中统一实现了后台线程的处理逻辑并在顶层容器Engine启动后台线程这样子容器组件甚至各种通用组件都不需要自己去创建后台线程这样的设计显得优雅整洁。
课后思考
为什么Host容器不通过重写backgroundProcess方法来实现热部署呢
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,也欢迎你把你的课后思考和心得记录下来,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。

View File

@ -0,0 +1,224 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
24 Context容器Tomcat如何打破双亲委托机制
相信我们平时在工作中都遇到过ClassNotFound异常这个异常表示JVM在尝试加载某个类的时候失败了。想要解决这个问题首先你需要知道什么是类加载JVM是如何加载类的以及为什么会出现ClassNotFound异常弄懂上面这些问题之后我们接着要思考Tomcat作为Web容器它是如何加载和管理Web应用下的Servlet呢
Tomcat正是通过Context组件来加载管理Web应用的所以今天我会详细分析Tomcat的类加载机制。但在这之前我们有必要预习一下JVM的类加载机制我会先回答一下一开始抛出来的问题接着再谈谈Tomcat的类加载器如何打破Java的双亲委托机制。
JVM的类加载器
Java的类加载就是把字节码格式“.class”文件加载到JVM的方法区并在JVM的堆区建立一个java.lang.Class对象的实例用来封装Java类相关的数据和方法。那Class对象又是什么呢你可以把它理解成业务类的模板JVM根据这个模板来创建具体业务类对象实例。
JVM并不是在启动时就把所有的“.class”文件都加载一遍而是程序在运行过程中用到了这个类才去加载。JVM类加载是由类加载器来完成的JDK提供一个抽象类ClassLoader这个抽象类中定义了三个关键方法理解清楚它们的作用和关系非常重要。
public abstract class ClassLoader {
//每个类加载器都有个父加载器
private final ClassLoader parent;
public Class<?> loadClass(String name) {
//查找一下这个类是不是已经加载过了
Class<?> c = findLoadedClass(name);
//如果没有加载过
if( c == null ){
//先委托给父加载器去加载,注意这是个递归调用
if (parent != null) {
c = parent.loadClass(name);
}else {
// 如果父加载器为空查找Bootstrap加载器是不是加载过了
c = findBootstrapClassOrNull(name);
}
}
// 如果父加载器没加载成功调用自己的findClass去加载
if (c == null) {
c = findClass(name);
}
return c
}
protected Class<?> findClass(String name){
//1. 根据传入的类名name到在特定目录下去寻找类文件把.class文件读入内存
...
//2. 调用defineClass将字节数组转成Class对象
return defineClass(buf, off, len)
}
// 将字节码数组解析成一个Class对象用native方法实现
protected final Class<?> defineClass(byte[] b, int off, int len){
...
}
}
从上面的代码我们可以得到几个关键信息:
JVM的类加载器是分层次的它们有父子关系每个类加载器都持有一个parent字段指向父加载器。
defineClass是个工具方法它的职责是调用native方法把Java类的字节码解析成一个Class对象所谓的native方法就是由C语言实现的方法Java通过JNI机制调用。
findClass方法的主要职责就是找到“.class”文件可能来自文件系统或者网络找到后把“.class”文件读到内存得到字节码数组然后调用defineClass方法得到Class对象。
loadClass是个public方法说明它才是对外提供服务的接口具体实现也比较清晰首先检查这个类是不是已经被加载过了如果加载过了直接返回否则交给父加载器去加载。请你注意这是一个递归调用也就是说子加载器持有父加载器的引用当一个类加载器需要加载一个Java类时会先委托父加载器去加载然后父加载器在自己的加载路径中搜索Java类当父加载器在自己的加载范围内找不到时才会交还给子加载器加载这就是双亲委托机制。
JDK中有哪些默认的类加载器它们的本质区别是什么为什么需要双亲委托机制JDK中有3个类加载器另外你也可以自定义类加载器它们的关系如下图所示。
BootstrapClassLoader是启动类加载器由C语言实现用来加载JVM启动时所需要的核心类比如rt.jar、resources.jar等。
ExtClassLoader是扩展类加载器用来加载\jre\lib\ext目录下JAR包。
AppClassLoader是系统类加载器用来加载classpath下的类应用程序默认用它来加载类。
自定义类加载器,用来加载自定义路径下的类。
这些类加载器的工作原理是一样的区别是它们的加载路径不同也就是说findClass这个方法查找的路径不同。双亲委托机制是为了保证一个Java类在JVM中是唯一的假如你不小心写了一个与JRE核心类同名的类比如Object类双亲委托机制能保证加载的是JRE里的那个Object类而不是你写的Object类。这是因为AppClassLoader在加载你的Object类时会委托给ExtClassLoader去加载而ExtClassLoader又会委托给BootstrapClassLoaderBootstrapClassLoader发现自己已经加载过了Object类会直接返回不会去加载你写的Object类。
这里请你注意类加载器的父子关系不是通过继承来实现的比如AppClassLoader并不是ExtClassLoader的子类而是说AppClassLoader的parent成员变量指向ExtClassLoader对象。同样的道理如果你要自定义类加载器不去继承AppClassLoader而是继承ClassLoader抽象类再重写findClass和loadClass方法即可Tomcat就是通过自定义类加载器来实现自己的类加载逻辑。不知道你发现没有如果你要打破双亲委托机制就需要重写loadClass方法因为loadClass的默认实现就是双亲委托机制。
Tomcat的类加载器
Tomcat的自定义类加载器WebAppClassLoader打破了双亲委托机制它首先自己尝试去加载某个类如果找不到再代理给父类加载器其目的是优先加载Web应用自己定义的类。具体实现就是重写ClassLoader的两个方法findClass和loadClass。
findClass方法
我们先来看看findClass方法的实现为了方便理解和阅读我去掉了一些细节
public Class<?> findClass(String name) throws ClassNotFoundException {
...
Class<?> clazz = null;
try {
//1. 先在Web应用目录下查找类
clazz = findClassInternal(name);
} catch (RuntimeException e) {
throw e;
}
if (clazz == null) {
try {
//2. 如果在本地目录没有找到,交给父加载器去查找
clazz = super.findClass(name);
} catch (RuntimeException e) {
throw e;
}
//3. 如果父类也没找到抛出ClassNotFoundException
if (clazz == null) {
throw new ClassNotFoundException(name);
}
return clazz;
}
在findClass方法里主要有三个步骤
先在Web应用本地目录下查找要加载的类。
如果没有找到交给父加载器去查找它的父加载器就是上面提到的系统类加载器AppClassLoader。
如何父加载器也没找到这个类抛出ClassNotFound异常。
loadClass方法
接着我们再来看Tomcat类加载器的loadClass方法的实现同样我也去掉了一些细节
public Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
synchronized (getClassLoadingLock(name)) {
Class<?> clazz = null;
//1. 先在本地cache查找该类是否已经加载过
clazz = findLoadedClass0(name);
if (clazz != null) {
if (resolve)
resolveClass(clazz);
return clazz;
}
//2. 从系统类加载器的cache中查找是否加载过
clazz = findLoadedClass(name);
if (clazz != null) {
if (resolve)
resolveClass(clazz);
return clazz;
}
// 3. 尝试用ExtClassLoader类加载器类加载为什么
ClassLoader javaseLoader = getJavaseClassLoader();
try {
clazz = javaseLoader.loadClass(name);
if (clazz != null) {
if (resolve)
resolveClass(clazz);
return clazz;
}
} catch (ClassNotFoundException e) {
// Ignore
}
// 4. 尝试在本地目录搜索class并加载
try {
clazz = findClass(name);
if (clazz != null) {
if (resolve)
resolveClass(clazz);
return clazz;
}
} catch (ClassNotFoundException e) {
// Ignore
}
// 5. 尝试用系统类加载器(也就是AppClassLoader)来加载
try {
clazz = Class.forName(name, false, parent);
if (clazz != null) {
if (resolve)
resolveClass(clazz);
return clazz;
}
} catch (ClassNotFoundException e) {
// Ignore
}
}
//6. 上述过程都加载失败,抛出异常
throw new ClassNotFoundException(name);
}
loadClass方法稍微复杂一点主要有六个步骤
先在本地Cache查找该类是否已经加载过也就是说Tomcat的类加载器是否已经加载过这个类。
如果Tomcat类加载器没有加载过这个类再看看系统类加载器是否加载过。
如果都没有就让ExtClassLoader去加载这一步比较关键目的防止Web应用自己的类覆盖JRE的核心类。因为Tomcat需要打破双亲委托机制假如Web应用里自定义了一个叫Object的类如果先加载这个Object类就会覆盖JRE里面的那个Object类这就是为什么Tomcat的类加载器会优先尝试用ExtClassLoader去加载因为ExtClassLoader会委托给BootstrapClassLoader去加载BootstrapClassLoader发现自己已经加载了Object类直接返回给Tomcat的类加载器这样Tomcat的类加载器就不会去加载Web应用下的Object类了也就避免了覆盖JRE核心类的问题。
如果ExtClassLoader加载器加载失败也就是说JRE核心类中没有这类那么就在本地Web应用目录下查找并加载。
如果本地目录下没有这个类说明不是Web应用自己定义的类那么由系统类加载器去加载。这里请你注意Web应用是通过Class.forName调用交给系统类加载器的因为Class.forName的默认加载器就是系统类加载器。
如果上述加载过程全部失败抛出ClassNotFound异常。
从上面的过程我们可以看到Tomcat的类加载器打破了双亲委托机制没有一上来就直接委托给父加载器而是先在本地目录下加载为了避免本地目录下的类覆盖JRE的核心类先尝试用JVM扩展类加载器ExtClassLoader去加载。那为什么不先用系统类加载器AppClassLoader去加载很显然如果是这样的话那就变成双亲委托机制了这就是Tomcat类加载器的巧妙之处。
本期精华
今天我介绍了JVM的类加载器原理和源码剖析以及Tomcat的类加载器是如何打破双亲委托机制的目的是为了优先加载Web应用目录下的类然后再加载其他目录下的类这也是Servlet规范的推荐做法。
要打破双亲委托机制需要继承ClassLoader抽象类并且需要重写它的loadClass方法因为ClassLoader的默认实现就是双亲委托。
课后思考
如果你并不想打破双亲委托机制但是又想定义自己的类加载器来加载特定目录下的类你需要重写findClass和loadClass方法中的哪一个还是两个都要重写
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,也欢迎你把你的课后思考和心得记录下来,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。

View File

@ -0,0 +1,91 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
25 Context容器Tomcat如何隔离Web应用
我在专栏上一期提到Tomcat通过自定义类加载器WebAppClassLoader打破了双亲委托机制具体来说就是重写了JVM的类加载器ClassLoader的findClass方法和loadClass方法这样做的目的是优先加载Web应用目录下的类。除此之外你觉得Tomcat的类加载器还需要完成哪些需求呢或者说在设计上还需要考虑哪些方面
我们知道Tomcat作为Servlet容器它负责加载我们的Servlet类此外它还负责加载Servlet所依赖的JAR包。并且Tomcat本身也是一个Java程序因此它需要加载自己的类和依赖的JAR包。首先让我们思考这一下这几个问题
假如我们在Tomcat中运行了两个Web应用程序两个Web应用中有同名的Servlet但是功能不同Tomcat需要同时加载和管理这两个同名的Servlet类保证它们不会冲突因此Web应用之间的类需要隔离。
假如两个Web应用都依赖同一个第三方的JAR包比如Spring那Spring的JAR包被加载到内存后Tomcat要保证这两个Web应用能够共享也就是说Spring的JAR包只被加载一次否则随着依赖的第三方JAR包增多JVM的内存会膨胀。
跟JVM一样我们需要隔离Tomcat本身的类和Web应用的类。
在了解了Tomcat的类加载器在设计时要考虑的这些问题以后今天我们主要来学习一下Tomcat是如何通过设计多层次的类加载器来解决这些问题的。
Tomcat类加载器的层次结构
为了解决这些问题Tomcat设计了类加载器的层次结构它们的关系如下图所示。下面我来详细解释为什么要设计这些类加载器告诉你它们是怎么解决上面这些问题的。
我们先来看第1个问题假如我们使用JVM默认AppClassLoader来加载Web应用AppClassLoader只能加载一个Servlet类在加载第二个同名Servlet类时AppClassLoader会返回第一个Servlet类的Class实例这是因为在AppClassLoader看来同名的Servlet类只被加载一次。
因此Tomcat的解决方案是自定义一个类加载器WebAppClassLoader 并且给每个Web应用创建一个类加载器实例。我们知道Context容器组件对应一个Web应用因此每个Context容器负责创建和维护一个WebAppClassLoader加载器实例。这背后的原理是不同的加载器实例加载的类被认为是不同的类即使它们的类名相同。这就相当于在Java虚拟机内部创建了一个个相互隔离的Java类空间每一个Web应用都有自己的类空间Web应用之间通过各自的类加载器互相隔离。
SharedClassLoader
我们再来看第2个问题本质需求是两个Web应用之间怎么共享库类并且不能重复加载相同的类。我们知道在双亲委托机制里各个子加载器都可以通过父加载器去加载类那么把需要共享的类放到父加载器的加载路径下不就行了吗应用程序也正是通过这种方式共享JRE的核心类。因此Tomcat的设计者又加了一个类加载器SharedClassLoader作为WebAppClassLoader的父加载器专门来加载Web应用之间共享的类。如果WebAppClassLoader自己没有加载到某个类就会委托父加载器SharedClassLoader去加载这个类SharedClassLoader会在指定目录下加载共享类之后返回给WebAppClassLoader这样共享的问题就解决了。
CatalinaClassLoader
我们来看第3个问题如何隔离Tomcat本身的类和Web应用的类我们知道要共享可以通过父子关系要隔离那就需要兄弟关系了。兄弟关系就是指两个类加载器是平行的它们可能拥有同一个父加载器但是两个兄弟类加载器加载的类是隔离的。基于此Tomcat又设计一个类加载器CatalinaClassLoader专门来加载Tomcat自身的类。这样设计有个问题那Tomcat和各Web应用之间需要共享一些类时该怎么办呢
CommonClassLoader
老办法还是再增加一个CommonClassLoader作为CatalinaClassLoader和SharedClassLoader的父加载器。CommonClassLoader能加载的类都可以被CatalinaClassLoader和SharedClassLoader 使用而CatalinaClassLoader和SharedClassLoader能加载的类则与对方相互隔离。WebAppClassLoader可以使用SharedClassLoader加载到的类但各个WebAppClassLoader实例之间相互隔离。
Spring的加载问题
在JVM的实现中有一条隐含的规则默认情况下如果一个类由类加载器A加载那么这个类的依赖类也是由相同的类加载器加载。比如Spring作为一个Bean工厂它需要创建业务类的实例并且在创建业务类实例之前需要加载这些类。Spring是通过调用Class.forName来加载业务类的我们来看一下forName的源码
public static Class<?> forName(String className) {
Class<?> caller = Reflection.getCallerClass();
return forName0(className, true, ClassLoader.getClassLoader(caller), caller);
}
可以看到在forName的函数里会用调用者也就是Spring的加载器去加载业务类。
我在前面提到Web应用之间共享的JAR包可以交给SharedClassLoader来加载从而避免重复加载。Spring作为共享的第三方JAR包它本身是由SharedClassLoader来加载的Spring又要去加载业务类按照前面那条规则加载Spring的类加载器也会用来加载业务类但是业务类在Web应用目录下不在SharedClassLoader的加载路径下这该怎么办呢
于是线程上下文加载器登场了它其实是一种类加载器传递机制。为什么叫作“线程上下文加载器”呢因为这个类加载器保存在线程私有数据里只要是同一个线程一旦设置了线程上下文加载器在线程后续执行过程中就能把这个类加载器取出来用。因此Tomcat为每个Web应用创建一个WebAppClassLoader类加载器并在启动Web应用的线程里设置线程上下文加载器这样Spring在启动时就将线程上下文加载器取出来用来加载Bean。Spring取线程上下文加载的代码如下
cl = Thread.currentThread().getContextClassLoader();
本期精华
今天我介绍了JVM的类加载器原理并剖析了源码以及Tomcat的类加载器的设计。重点需要你理解的是Tomcat的Context组件为每个Web应用创建一个WebAppClassLoader类加载器由于不同类加载器实例加载的类是互相隔离的因此达到了隔离Web应用的目的同时通过CommonClassLoader等父加载器来共享第三方JAR包。而共享的第三方JAR包怎么加载特定Web应用的类呢可以通过设置线程上下文加载器来解决。而作为Java程序员我们应该牢记的是
每个Web应用自己的Java类文件和依赖的JAR包分别放在WEB-INF/classes和WEB-INF/lib目录下面。
多个应用共享的Java类文件和JAR包分别放在Web容器指定的共享目录下。
当出现ClassNotFound错误时应该检查你的类加载器是否正确。
线程上下文加载器不仅仅可以用在Tomcat和Spring类加载的场景里核心框架类需要加载具体实现类时都可以用到它比如我们熟悉的JDBC就是通过上下文类加载器来加载不同的数据库驱动的感兴趣的话可以深入了解一下。
课后思考
在StandardContext的启动方法里会将当前线程的上下文加载器设置为WebAppClassLoader。
originalClassLoader = Thread.currentThread().getContextClassLoader();
Thread.currentThread().setContextClassLoader(webApplicationClassLoader);
在启动方法结束的时候,还会恢复线程的上下文加载器:
Thread.currentThread().setContextClassLoader(originalClassLoader);
这是为什么呢?
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,也欢迎你把你的课后思考和心得记录下来,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。

View File

@ -0,0 +1,208 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
26 Context容器Tomcat如何实现Servlet规范
我们知道Servlet容器最重要的任务就是创建Servlet的实例并且调用Servlet在前面两期我谈到了Tomcat如何定义自己的类加载器来加载Servlet但加载Servlet的类不等于创建Servlet的实例类加载只是第一步类加载好了才能创建类的实例也就是说Tomcat先加载Servlet的类然后在Java堆上创建了一个Servlet实例。
一个Web应用里往往有多个Servlet而在Tomcat中一个Web应用对应一个Context容器也就是说一个Context容器需要管理多个Servlet实例。但Context容器并不直接持有Servlet实例而是通过子容器Wrapper来管理Servlet你可以把Wrapper容器看作是Servlet的包装。
那为什么需要Wrapper呢Context容器直接维护一个Servlet数组不就行了吗这是因为Servlet不仅仅是一个类实例它还有相关的配置信息比如它的URL映射、它的初始化参数因此设计出了一个包装器把Servlet本身和它相关的数据包起来没错这就是面向对象的思想。
那管理好Servlet就完事大吉了吗别忘了Servlet还有两个兄弟Listener和Filter它们也是Servlet规范中的重要成员因此Tomcat也需要创建它们的实例也需要在合适的时机去调用它们的方法。
说了那么多下面我们就来聊一聊Tomcat是如何做到上面这些事的。
Servlet管理
前面提到Tomcat是用Wrapper容器来管理Servlet的那Wrapper容器具体长什么样子呢我们先来看看它里面有哪些关键的成员变量
protected volatile Servlet instance = null;
毫无悬念它拥有一个Servlet实例并且Wrapper通过loadServlet方法来实例化Servlet。为了方便你阅读我简化了代码
public synchronized Servlet loadServlet() throws ServletException {
Servlet servlet;
//1. 创建一个Servlet实例
servlet = (Servlet) instanceManager.newInstance(servletClass);
//2.调用了Servlet的init方法这是Servlet规范要求的
initServlet(servlet);
return servlet;
}
其实loadServlet主要做了两件事创建Servlet的实例并且调用Servlet的init方法因为这是Servlet规范要求的。
那接下来的问题是什么时候会调到这个loadServlet方法呢为了加快系统的启动速度我们往往会采取资源延迟加载的策略Tomcat也不例外默认情况下Tomcat在启动时不会加载你的Servlet除非你把Servlet的loadOnStartup参数设置为true。
这里还需要你注意的是虽然Tomcat在启动时不会创建Servlet实例但是会创建Wrapper容器就好比尽管枪里面还没有子弹先把枪造出来。那子弹什么时候造呢是真正需要开枪的时候也就是说有请求来访问某个Servlet时这个Servlet的实例才会被创建。
那Servlet是被谁调用的呢我们回忆一下专栏前面提到过Tomcat的Pipeline-Valve机制每个容器组件都有自己的Pipeline每个Pipeline中有一个Valve链并且每个容器组件有一个BasicValve基础阀。Wrapper作为一个容器组件它也有自己的Pipeline和BasicValveWrapper的BasicValve叫StandardWrapperValve。
你可以想到当请求到来时Context容器的BasicValve会调用Wrapper容器中Pipeline中的第一个Valve然后会调用到StandardWrapperValve。我们先来看看它的invoke方法是如何实现的同样为了方便你阅读我简化了代码
public final void invoke(Request request, Response response) {
//1.实例化Servlet
servlet = wrapper.allocate();
//2.给当前请求创建一个Filter链
ApplicationFilterChain filterChain =
ApplicationFilterFactory.createFilterChain(request, wrapper, servlet);
//3. 调用这个Filter链Filter链中的最后一个Filter会调用Servlet
filterChain.doFilter(request.getRequest(), response.getResponse());
}
StandardWrapperValve的invoke方法比较复杂去掉其他异常处理的一些细节本质上就是三步
第一步创建Servlet实例
第二步给当前请求创建一个Filter链
第三步调用这个Filter链。
你可能会问为什么需要给每个请求创建一个Filter链这是因为每个请求的请求路径都不一样而Filter都有相应的路径映射因此不是所有的Filter都需要来处理当前的请求我们需要根据请求的路径来选择特定的一些Filter来处理。
第二个问题是为什么没有看到调到Servlet的service方法这是因为Filter链的doFilter方法会负责调用Servlet具体来说就是Filter链中的最后一个Filter会负责调用Servlet。
接下来我们来看Filter的实现原理。
Filter管理
我们知道跟Servlet一样Filter也可以在web.xml文件里进行配置不同的是Filter的作用域是整个Web应用因此Filter的实例是在Context容器中进行管理的Context容器用Map集合来保存Filter。
private Map<String, FilterDef> filterDefs = new HashMap<>();
那上面提到的Filter链又是什么呢Filter链的存活期很短它是跟每个请求对应的。一个新的请求来了就动态创建一个Filter链请求处理完了Filter链也就被回收了。理解它的原理也非常关键我们还是来看看源码
public final class ApplicationFilterChain implements FilterChain {
//Filter链中有Filter数组这个好理解
private ApplicationFilterConfig[] filters = new ApplicationFilterConfig[0];
//Filter链中的当前的调用位置
private int pos = 0;
//总共有多少了Filter
private int n = 0;
//每个Filter链对应一个Servlet也就是它要调用的Servlet
private Servlet servlet = null;
public void doFilter(ServletRequest req, ServletResponse res) {
internalDoFilter(request,response);
}
private void internalDoFilter(ServletRequest req,
ServletResponse res){
// 每个Filter链在内部维护了一个Filter数组
if (pos < n) {
ApplicationFilterConfig filterConfig = filters[pos++];
Filter filter = filterConfig.getFilter();
filter.doFilter(request, response, this);
return;
}
servlet.service(request, response);
}
从ApplicationFilterChain的源码我们可以看到几个关键信息
Filter链中除了有Filter对象的数组还有一个整数变量pos这个变量用来记录当前被调用的Filter在数组中的位置。
Filter链中有个Servlet实例这个好理解因为上面提到了每个Filter链最后都会调到一个Servlet。
Filter链本身也实现了doFilter方法直接调用了一个内部方法internalDoFilter。
internalDoFilter方法的实现比较有意思它做了一个判断如果当前Filter的位置小于Filter数组的长度也就是说Filter还没调完就从Filter数组拿下一个Filter调用它的doFilter方法。否则意味着所有Filter都调到了就调用Servlet的service方法。
但问题是方法体里没看到循环谁在不停地调用Filter链的doFilter方法呢Filter是怎么依次调到的呢
答案是Filter本身的doFilter方法会调用Filter链的doFilter方法我们还是来看看代码就明白了
public void doFilter(ServletRequest request, ServletResponse response,
FilterChain chain){
...
//调用Filter的方法
chain.doFilter(request, response);
}
注意Filter的doFilter方法有个关键参数FilterChain就是Filter链。并且每个Filter在实现doFilter时必须要调用Filter链的doFilter方法而Filter链中保存当前Filter的位置会调用下一个Filter的doFilter方法这样链式调用就完成了。
Filter链跟Tomcat的Pipeline-Valve本质都是责任链模式但是在具体实现上稍有不同你可以细细体会一下。
Listener管理
我们接着聊Servlet规范里Listener。跟Filter一样Listener也是一种扩展机制你可以监听容器内部发生的事件主要有两类事件
第一类是生命状态的变化比如Context容器启动和停止、Session的创建和销毁。
第二类是属性的变化比如Context容器某个属性值变了、Session的某个属性值变了以及新的请求来了等。
我们可以在web.xml配置或者通过注解的方式来添加监听器在监听器里实现我们的业务逻辑。对于Tomcat来说它需要读取配置文件拿到监听器类的名字实例化这些类并且在合适的时机调用这些监听器的方法。
Tomcat是通过Context容器来管理这些监听器的。Context容器将两类事件分开来管理分别用不同的集合来存放不同类型事件的监听器
//监听属性值变化的监听器
private List<Object> applicationEventListenersList = new CopyOnWriteArrayList<>();
//监听生命事件的监听器
private Object applicationLifecycleListenersObjects[] = new Object[0];
剩下的事情就是触发监听器了比如在Context容器的启动方法里就触发了所有的ServletContextListener
//1.拿到所有的生命周期监听器
Object instances[] = getApplicationLifecycleListeners();
for (int i = 0; i < instances.length; i++) {
//2. 判断Listener的类型是不是ServletContextListener
if (!(instances[i] instanceof ServletContextListener))
continue;
//3.触发Listener的方法
ServletContextListener lr = (ServletContextListener) instances[i];
lr.contextInitialized(event);
}
需要注意的是这里的ServletContextListener接口是一种留给用户的扩展机制用户可以实现这个接口来定义自己的监听器监听Context容器的启停事件。Spring就是这么做的。ServletContextListener跟Tomcat自己的生命周期事件LifecycleListener是不同的。LifecycleListener定义在生命周期管理组件中由基类LifecycleBase统一管理。
本期精华
Servlet规范中最重要的就是Servlet、Filter和Listener“三兄弟”。Web容器最重要的职能就是把它们创建出来并在适当的时候调用它们的方法。
Tomcat通过Wrapper容器来管理ServletWrapper包装了Servlet本身以及相应的参数这体现了面向对象中“封装”的设计原则。
Tomcat会给每个请求生成一个Filter链Filter链中的最后一个Filter会负责调用Servlet的service方法。
对于Listener来说我们可以定制自己的监听器来监听Tomcat内部发生的各种事件包括Web应用级别的、Session级别的和请求级别的。Tomcat中的Context容器统一维护了这些监听器并负责触发。
最后小结一下这3期内容Context组件通过自定义类加载器来加载Web应用并实现了Servlet规范直接跟Web应用打交道是一个核心的容器组件。也因此我用了很重的篇幅去讲解它也非常建议你花点时间阅读一下它的源码。
课后思考
Context容器分别用了CopyOnWriteArrayList和对象数组来存储两种不同的监听器为什么要这样设计你可以思考一下背后的原因。
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,也欢迎你把你的课后思考和心得记录下来,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。

View File

@ -0,0 +1,202 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
27 新特性Tomcat如何支持异步Servlet
通过专栏前面的学习我们知道当一个新的请求到达时Tomcat和Jetty会从线程池里拿出一个线程来处理请求这个线程会调用你的Web应用Web应用在处理请求的过程中Tomcat线程会一直阻塞直到Web应用处理完毕才能再输出响应最后Tomcat才回收这个线程。
我们来思考这样一个问题假如你的Web应用需要较长的时间来处理请求比如数据库查询或者等待下游的服务调用返回那么Tomcat线程一直不回收会占用系统资源在极端情况下会导致“线程饥饿”也就是说Tomcat和Jetty没有更多的线程来处理新的请求。
那该如何解决这个问题呢方案是Servlet 3.0中引入的异步Servlet。主要是在Web应用里启动一个单独的线程来执行这些比较耗时的请求而Tomcat线程立即返回不再等待Web应用将请求处理完这样Tomcat线程可以立即被回收到线程池用来响应其他请求降低了系统的资源消耗同时还能提高系统的吞吐量。
今天我们就来学习一下如何开发一个异步Servlet以及异步Servlet的工作原理也就是Tomcat是如何支持异步Servlet的让你彻底理解它的来龙去脉。
异步Servlet示例
我们先通过一个简单的示例来了解一下异步Servlet的实现。
@WebServlet(urlPatterns = {"/async"}, asyncSupported = true)
public class AsyncServlet extends HttpServlet {
//Web应用线程池用来处理异步Servlet
ExecutorService executor = Executors.newSingleThreadExecutor();
public void service(HttpServletRequest req, HttpServletResponse resp) {
//1. 调用startAsync或者异步上下文
final AsyncContext ctx = req.startAsync();
//用线程池来执行耗时操作
executor.execute(new Runnable() {
@Override
public void run() {
//在这里做耗时的操作
try {
ctx.getResponse().getWriter().println("Handling Async Servlet");
} catch (IOException e) {}
//3. 异步Servlet处理完了调用异步上下文的complete方法
ctx.complete();
}
});
}
}
上面的代码有三个要点:
通过注解的方式来注册Servlet除了@WebServlet注解还需要加上asyncSupported=true的属性表明当前的Servlet是一个异步Servlet。
Web应用程序需要调用Request对象的startAsync方法来拿到一个异步上下文AsyncContext。这个上下文保存了请求和响应对象。
Web应用需要开启一个新线程来处理耗时的操作处理完成后需要调用AsyncContext的complete方法。目的是告诉Tomcat请求已经处理完成。
这里请你注意虽然异步Servlet允许用更长的时间来处理请求但是也有超时限制的默认是30秒如果30秒内请求还没处理完Tomcat会触发超时机制向浏览器返回超时错误如果这个时候你的Web应用再调用ctx.complete方法会得到一个IllegalStateException异常。
异步Servlet原理
通过上面的例子相信你对Servlet的异步实现有了基本的理解。要理解Tomcat在这个过程都做了什么事情关键就是要弄清楚req.startAsync方法和ctx.complete方法都做了什么。
startAsync方法
startAsync方法其实就是创建了一个异步上下文AsyncContext对象AsyncContext对象的作用是保存请求的中间信息比如Request和Response对象等上下文信息。你来思考一下为什么需要保存这些信息呢
这是因为Tomcat的工作线程在request.startAsync调用之后就直接结束回到线程池中了线程本身不会保存任何信息。也就是说一个请求到服务端执行到一半你的Web应用正在处理这个时候Tomcat的工作线程没了这就需要有个缓存能够保存原始的Request和Response对象而这个缓存就是AsyncContext。
有了AsyncContext你的Web应用通过它拿到Request和Response对象拿到Request对象后就可以读取请求信息请求处理完了还需要通过Response对象将HTTP响应发送给浏览器。
除了创建AsyncContext对象startAsync还需要完成一个关键任务那就是告诉Tomcat当前的Servlet处理方法返回时不要把响应发到浏览器因为这个时候响应还没生成呢并且不能把Request对象和Response对象销毁因为后面Web应用还要用呢。
在Tomcat中负责flush响应数据的是CoyoteAdapter它还会销毁Request对象和Response对象因此需要通过某种机制通知CoyoteAdapter具体来说是通过下面这行代码
this.request.getCoyoteRequest().action(ActionCode.ASYNC_START, this);
你可以把它理解为一个Callback在这个action方法里设置了Request对象的状态设置它为一个异步Servlet请求。
我们知道连接器是调用CoyoteAdapter的service方法来处理请求的而CoyoteAdapter会调用容器的service方法当容器的service方法返回时CoyoteAdapter判断当前的请求是不是异步Servlet请求如果是就不会销毁Request和Response对象也不会把响应信息发到浏览器。你可以通过下面的代码理解一下这是CoyoteAdapter的service方法我对它进行了简化
public void service(org.apache.coyote.Request req, org.apache.coyote.Response res) {
//调用容器的service方法处理请求
connector.getService().getContainer().getPipeline().
getFirst().invoke(request, response);
//如果是异步Servlet请求仅仅设置一个标志
//否则说明是同步Servlet请求就将响应数据刷到浏览器
if (request.isAsync()) {
async = true;
} else {
request.finishRequest();
response.finishResponse();
}
//如果不是异步Servlet请求就销毁Request对象和Response对象
if (!async) {
request.recycle();
response.recycle();
}
}
接下来当CoyoteAdapter的service方法返回到ProtocolHandler组件时ProtocolHandler判断返回值如果当前请求是一个异步Servlet请求它会把当前Socket的协议处理者Processor缓存起来将SocketWrapper对象和相应的Processor存到一个Map数据结构里。
private final Map<S,Processor> connections = new ConcurrentHashMap<>();
之所以要缓存是因为这个请求接下来还要接着处理还是由原来的Processor来处理通过SocketWrapper就能从Map里找到相应的Processor。
complete方法
接着我们再来看关键的ctx.complete方法当请求处理完成时Web应用调用这个方法。那么这个方法做了些什么事情呢最重要的就是把响应数据发送到浏览器。
这件事情不能由Web应用线程来做也就是说ctx.complete方法不能直接把响应数据发送到浏览器因为这件事情应该由Tomcat线程来做但具体怎么做呢
我们知道连接器中的Endpoint组件检测到有请求数据达到时会创建一个SocketProcessor对象交给线程池去处理因此Endpoint的通信处理和具体请求处理在两个线程里运行。
在异步Servlet的场景里Web应用通过调用ctx.complete方法时也可以生成一个新的SocketProcessor任务类交给线程池处理。对于异步Servlet请求来说相应的Socket和协议处理组件Processor都被缓存起来了并且这些对象都可以通过Request对象拿到。
讲到这里你可能已经猜到ctx.complete是如何实现的了
public void complete() {
//检查状态合法性,我们先忽略这句
check();
//调用Request对象的action方法其实就是通知连接器这个异步请求处理完了
request.getCoyoteRequest().action(ActionCode.ASYNC_COMPLETE, null);
}
我们可以看到complete方法调用了Request对象的action方法。而在action方法里则是调用了Processor的processSocketEvent方法并且传入了操作码OPEN_READ。
case ASYNC_COMPLETE: {
clearDispatches();
if (asyncStateMachine.asyncComplete()) {
processSocketEvent(SocketEvent.OPEN_READ, true);
}
break;
}
我们接着看processSocketEvent方法它调用SocketWrapper的processSocket方法
protected void processSocketEvent(SocketEvent event, boolean dispatch) {
SocketWrapperBase<?> socketWrapper = getSocketWrapper();
if (socketWrapper != null) {
socketWrapper.processSocket(event, dispatch);
}
}
而SocketWrapper的processSocket方法会创建SocketProcessor任务类并通过Tomcat线程池来处理
public boolean processSocket(SocketWrapperBase<S> socketWrapper,
SocketEvent event, boolean dispatch) {
if (socketWrapper == null) {
return false;
}
SocketProcessorBase<S> sc = processorCache.pop();
if (sc == null) {
sc = createSocketProcessor(socketWrapper, event);
} else {
sc.reset(socketWrapper, event);
}
//线程池运行
Executor executor = getExecutor();
if (dispatch && executor != null) {
executor.execute(sc);
} else {
sc.run();
}
}
请你注意createSocketProcessor函数的第二个参数是SocketEvent这里我们传入的是OPEN_READ。通过这个参数我们就能控制SocketProcessor的行为因为我们不需要再把请求发送到容器进行处理只需要向浏览器端发送数据并且重新在这个Socket上监听新的请求就行了。
最后我通过一张在帮你理解一下整个过程:
本期精华
非阻塞I/O模型可以利用很少的线程处理大量的连接提高了并发度本质就是通过一个Selector线程查询多个Socket的I/O事件减少了线程的阻塞等待。
同样异步Servlet机制也是减少了线程的阻塞等待将Tomcat线程和业务线程分开Tomcat线程不再等待业务代码的执行。
那什么样的场景适合异步Servlet呢适合的场景有很多最主要的还是根据你的实际情况如果你拿不准是否适合异步Servlet就看一条如果你发现Tomcat的线程不够了大量线程阻塞在等待Web应用的处理上而Web应用又没有优化的空间了确实需要长时间处理这个时候你不妨尝试一下异步Servlet。
课后思考
异步Servlet将Tomcat线程和Web应用线程分开体现了隔离的思想也就是把不同的业务处理所使用的资源隔离开使得它们互不干扰尤其是低优先级的业务不能影响高优先级的业务。你可以思考一下在你的Web应用内部是不是也可以运用这种设计思想呢
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,也欢迎你把你的课后思考和心得记录下来,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。

View File

@ -0,0 +1,240 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
28 新特性Spring Boot如何使用内嵌式的Tomcat和Jetty
为了方便开发和部署Spring Boot在内部启动了一个嵌入式的Web容器。我们知道Tomcat和Jetty是组件化的设计要启动Tomcat或者Jetty其实就是启动这些组件。在Tomcat独立部署的模式下我们通过startup脚本来启动TomcatTomcat中的Bootstrap和Catalina会负责初始化类加载器并解析server.xml和启动这些组件。
在内嵌式的模式下Bootstrap和Catalina的工作就由Spring Boot来做了Spring Boot调用了Tomcat和Jetty的API来启动这些组件。那Spring Boot具体是怎么做的呢而作为程序员我们如何向Spring Boot中的Tomcat注册Servlet或者Filter呢我们又如何定制内嵌式的Tomcat今天我们就来聊聊这些话题。
Spring Boot中Web容器相关的接口
既然要支持多种Web容器Spring Boot对内嵌式Web容器进行了抽象定义了WebServer接口
public interface WebServer {
void start() throws WebServerException;
void stop() throws WebServerException;
int getPort();
}
各种Web容器比如Tomcat和Jetty需要去实现这个接口。
Spring Boot还定义了一个工厂ServletWebServerFactory来创建Web容器返回的对象就是上面提到的WebServer。
public interface ServletWebServerFactory {
WebServer getWebServer(ServletContextInitializer... initializers);
}
可以看到getWebServer有个参数类型是ServletContextInitializer。它表示ServletContext的初始化器用于ServletContext中的一些配置
public interface ServletContextInitializer {
void onStartup(ServletContext servletContext) throws ServletException;
}
这里请注意上面提到的getWebServer方法会调用ServletContextInitializer的onStartup方法也就是说如果你想在Servlet容器启动时做一些事情比如注册你自己的Servlet可以实现一个ServletContextInitializer在Web容器启动时Spring Boot会把所有实现了ServletContextInitializer接口的类收集起来统一调它们的onStartup方法。
为了支持对内嵌式Web容器的定制化Spring Boot还定义了WebServerFactoryCustomizerBeanPostProcessor接口它是一个BeanPostProcessor它在postProcessBeforeInitialization过程中去寻找Spring容器中WebServerFactoryCustomizer类型的Bean并依次调用WebServerFactoryCustomizer接口的customize方法做一些定制化。
public interface WebServerFactoryCustomizer<T extends WebServerFactory> {
void customize(T factory);
}
内嵌式Web容器的创建和启动
铺垫了这些接口我们再来看看Spring Boot是如何实例化和启动一个Web容器的。我们知道Spring的核心是一个ApplicationContext它的抽象实现类AbstractApplicationContext实现了著名的refresh方法它用来新建或者刷新一个ApplicationContext在refresh方法中会调用onRefresh方法AbstractApplicationContext的子类可以重写这个onRefresh方法来实现特定Context的刷新逻辑因此ServletWebServerApplicationContext就是通过重写onRefresh方法来创建内嵌式的Web容器具体创建过程是这样的
@Override
protected void onRefresh() {
super.onRefresh();
try {
//重写onRefresh方法调用createWebServer创建和启动Tomcat
createWebServer();
}
catch (Throwable ex) {
}
}
//createWebServer的具体实现
private void createWebServer() {
//这里WebServer是Spring Boot抽象出来的接口具体实现类就是不同的Web容器
WebServer webServer = this.webServer;
ServletContext servletContext = this.getServletContext();
//如果Web容器还没创建
if (webServer == null && servletContext == null) {
//通过Web容器工厂来创建
ServletWebServerFactory factory = this.getWebServerFactory();
//注意传入了一个"SelfInitializer"
this.webServer = factory.getWebServer(new ServletContextInitializer[]{this.getSelfInitializer()});
} else if (servletContext != null) {
try {
this.getSelfInitializer().onStartup(servletContext);
} catch (ServletException var4) {
...
}
}
this.initPropertySources();
}
再来看看getWebServer具体做了什么以Tomcat为例主要调用Tomcat的API去创建各种组件
public WebServer getWebServer(ServletContextInitializer... initializers) {
//1.实例化一个Tomcat可以理解为Server组件。
Tomcat tomcat = new Tomcat();
//2. 创建一个临时目录
File baseDir = this.baseDirectory != null ? this.baseDirectory : this.createTempDir("tomcat");
tomcat.setBaseDir(baseDir.getAbsolutePath());
//3.初始化各种组件
Connector connector = new Connector(this.protocol);
tomcat.getService().addConnector(connector);
this.customizeConnector(connector);
tomcat.setConnector(connector);
tomcat.getHost().setAutoDeploy(false);
this.configureEngine(tomcat.getEngine());
//4. 创建定制版的"Context"组件。
this.prepareContext(tomcat.getHost(), initializers);
return this.getTomcatWebServer(tomcat);
}
你可能好奇prepareContext方法是做什么的呢这里的Context是指Tomcat中的Context组件为了方便控制Context组件的行为Spring Boot定义了自己的TomcatEmbeddedContext它扩展了Tomcat的StandardContext
class TomcatEmbeddedContext extends StandardContext {}
注册Servlet的三种方式
1. Servlet注解
在Spring Boot启动类上加上@ServletComponentScan注解后,使用@WebServlet@WebFilter@WebListener标记的Servlet、Filter、Listener就可以自动注册到Servlet容器中无需其他代码我们通过下面的代码示例来理解一下。
@SpringBootApplication
@ServletComponentScan
public class xxxApplication
{}
@WebServlet("/hello")
public class HelloServlet extends HttpServlet {}
在Web应用的入口类上加上@ServletComponentScan并且在Servlet类上加上@WebServlet这样Spring Boot会负责将Servlet注册到内嵌的Tomcat中。
2. ServletRegistrationBean
同时Spring Boot也提供了ServletRegistrationBean、FilterRegistrationBean和ServletListenerRegistrationBean这三个类分别用来注册Servlet、Filter、Listener。假如要注册一个Servlet可以这样做
@Bean
public ServletRegistrationBean servletRegistrationBean() {
return new ServletRegistrationBean(new HelloServlet(),"/hello");
}
这段代码实现的方法返回一个ServletRegistrationBean并将它当作Bean注册到Spring中因此你需要把这段代码放到Spring Boot自动扫描的目录中或者放到@Configuration标识的类中
3. 动态注册
你还可以创建一个类去实现前面提到的ServletContextInitializer接口并把它注册为一个BeanSpring Boot会负责调用这个接口的onStartup方法。
@Component
public class MyServletRegister implements ServletContextInitializer {
@Override
public void onStartup(ServletContext servletContext) {
//Servlet 3.0规范新的API
ServletRegistration myServlet = servletContext
.addServlet("HelloServlet", HelloServlet.class);
myServlet.addMapping("/hello");
myServlet.setInitParameter("name", "Hello Servlet");
}
}
这里请注意两点:
ServletRegistrationBean其实也是通过ServletContextInitializer来实现的它实现了ServletContextInitializer接口。
注意到onStartup方法的参数是我们熟悉的ServletContext可以通过调用它的addServlet方法来动态注册新的Servlet这是Servlet 3.0以后才有的功能。
Web容器的定制
我们再来考虑一个问题那就是如何在Spring Boot中定制Web容器。在Spring Boot 2.0中我们可以通过两种方式来定制Web容器。
第一种方式是通过通用的Web容器工厂ConfigurableServletWebServerFactory来定制一些Web容器通用的参数
@Component
public class MyGeneralCustomizer implements
WebServerFactoryCustomizer<ConfigurableServletWebServerFactory> {
public void customize(ConfigurableServletWebServerFactory factory) {
factory.setPort(8081);
factory.setContextPath("/hello");
}
}
第二种方式是通过特定Web容器的工厂比如TomcatServletWebServerFactory来进一步定制。下面的例子里我们给Tomcat增加一个Valve这个Valve的功能是向请求头里添加traceid用于分布式追踪。TraceValve的定义如下
class TraceValve extends ValveBase {
@Override
public void invoke(Request request, Response response) throws IOException, ServletException {
request.getCoyoteRequest().getMimeHeaders().
addValue("traceid").setString("1234xxxxabcd");
Valve next = getNext();
if (null == next) {
return;
}
next.invoke(request, response);
}
}
跟第一种方式类似,再添加一个定制器,代码如下:
@Component
public class MyTomcatCustomizer implements
WebServerFactoryCustomizer<TomcatServletWebServerFactory> {
@Override
public void customize(TomcatServletWebServerFactory factory) {
factory.setPort(8081);
factory.setContextPath("/hello");
factory.addEngineValves(new TraceValve() );
}
}
本期精华
今天我们学习了Spring Boot如何利用Web容器的API来启动Web容器、如何向Web容器注册Servlet以及如何定制化Web容器除了给Web容器配置参数还可以增加或者修改Web容器本身的组件。
课后思考
我在文章中提到通过ServletContextInitializer接口可以向Web容器注册Servlet那ServletContextInitializer跟Tomcat中的ServletContainerInitializer有什么区别和联系呢
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,也欢迎你把你的课后思考和心得记录下来,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。

View File

@ -0,0 +1,319 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
29 比较Jetty如何实现具有上下文信息的责任链
我们知道Tomcat和Jetty的核心功能是处理请求并且请求的处理者不止一个因此Tomcat和Jetty都实现了责任链模式其中Tomcat是通过Pipeline-Valve来实现的而Jetty是通过HandlerWrapper来实现的。HandlerWrapper中保存了下一个Handler的引用将各Handler组成一个链表像下面这样
WebAppContext -> SessionHandler -> SecurityHandler -> ServletHandler
这样链中的Handler从头到尾能被依次调用除此之外Jetty还实现了“回溯”的链式调用那就是从头到尾依次链式调用Handler的方法A完成后再回到头节点再进行一次链式调用只不过这一次调用另一个方法B。你可能会问一次链式调用不就够了吗为什么还要回过头再调一次呢这是因为一次请求到达时Jetty需要先调用各Handler的初始化方法之后再调用各Handler的请求处理方法并且初始化必须在请求处理之前完成。
而Jetty是通过ScopedHandler来做到这一点的那ScopedHandler跟HandlerWrapper有什么关系呢ScopedHandler是HandlerWrapper的子类我们还是通过一张图来回顾一下各种Handler的继承关系
从图上我们看到ScopedHandler是Jetty非常核心的一个Handler跟Servlet规范相关的Handler比如ContextHandler、SessionHandler、ServletHandler、WebappContext等都直接或间接地继承了ScopedHandler。
今天我就分析一下ScopedHandler是如何实现“回溯”的链式调用的。
HandlerWrapper
为了方便理解我们先来回顾一下HandlerWrapper的源码
public class HandlerWrapper extends AbstractHandlerContainer
{
protected Handler _handler;
@Override
public void handle(String target,
Request baseRequest,
HttpServletRequest request,
HttpServletResponse response)
throws IOException, ServletException
{
Handler handler=_handler;
if (handler!=null)
handler.handle(target,baseRequest, request, response);
}
}
从代码可以看到它持有下一个Handler的引用并且会在handle方法里调用下一个Handler。
ScopedHandler
ScopedHandler的父类是HandlerWrapperScopedHandler重写了handle方法在HandlerWrapper的handle方法的基础上引入了doScope方法。
public final void handle(String target,
Request baseRequest,
HttpServletRequest request,
HttpServletResponse response)
throws IOException, ServletException
{
if (isStarted())
{
if (_outerScope==null)
doScope(target,baseRequest,request, response);
else
doHandle(target,baseRequest,request, response);
}
}
上面的代码中是根据_outerScope是否为null来判断是使用doScope还是doHandle方法。那_outScope又是什么呢_outScope是ScopedHandler引入的一个辅助变量此外还有一个_nextScope变量。
protected ScopedHandler _outerScope;
protected ScopedHandler _nextScope;
private static final ThreadLocal<ScopedHandler> __outerScope= new ThreadLocal<ScopedHandler>();
我们看到__outerScope是一个ThreadLocal变量ThreadLocal表示线程的私有数据跟特定线程绑定。需要注意的是__outerScope实际上保存了一个ScopedHandler。
下面通过我通过一个例子来说明_outScope和_nextScope的含义。我们知道ScopedHandler继承自HandlerWrapper所以也是可以形成Handler链的Jetty的源码注释中给出了下面这样一个例子
ScopedHandler scopedA;
ScopedHandler scopedB;
HandlerWrapper wrapperX;
ScopedHandler scopedC;
scopedA.setHandler(scopedB);
scopedB.setHandler(wrapperX);
wrapperX.setHandler(scopedC)
经过上面的设置之后形成的Handler链是这样的
上面的过程只是设置了_handler变量那_outScope和_nextScope需要设置成什么样呢为了方便你理解我们先来看最后的效果图
从上图我们看到scopedA的_nextScope=scopedBscopedB的_nextScope=scopedC为什么scopedB的_nextScope不是WrapperX呢因为WrapperX不是一个ScopedHandler。scopedC的_nextScope是null因为它是链尾没有下一个节点。因此我们得出一个结论_nextScope指向下一个Scoped节点的引用由于WrapperX不是Scoped节点它没有_outScope和_nextScope变量。
注意到scopedA的_outerScope是nullscopedB和scopedC的_outScope都是指向scopedA即_outScope指向的是当前Handler链的头节点头节点本身_outScope为null。
弄清楚了_outScope和_nextScope的含义下一个问题就是对于一个ScopedHandler对象如何设置这两个值以及在何时设置这两个值。答案是在组件启动的时候下面是ScopedHandler中的doStart方法源码
@Override
protected void doStart() throws Exception
{
try
{
//请注意_outScope是一个实例变量而__outerScope是一个全局变量。先读取全局的线程私有变量__outerScope到_outerScope中
_outerScope=__outerScope.get();
//如果全局的__outerScope还没有被赋值说明执行doStart方法的是头节点
if (_outerScope==null)
//handler链的头节点将自己的引用填充到__outerScope
__outerScope.set(this);
//调用父类HandlerWrapper的doStart方法
super.doStart();
//各Handler将自己的_nextScope指向下一个ScopedHandler
_nextScope= getChildHandlerByClass(ScopedHandler.class);
}
finally
{
if (_outerScope==null)
__outerScope.set(null);
}
}
你可能会问为什么要设计这样一个全局的__outerScope这是因为这个变量不能通过方法参数在Handler链中进行传递但是在形成链的过程中又需要用到它。
你可以想象当scopedA调用start方法时会把自己填充到__scopeHandler中接着scopedA调用super.doStart。由于scopedA是一个HandlerWrapper类型并且它持有的_handler引用指向的是scopedB所以super.doStart实际上会调用scopedB的start方法。
这个方法里同样会执行scopedB的doStart方法不过这次__outerScope.get方法返回的不是null而是scopedA的引用所以scopedB的_outScope被设置为scopedA。
接着super.dostart会进入到scopedC也会将scopedC的_outScope指向scopedA。到了scopedC执行doStart方法时它的_handler属性为null因为它是Handler链的最后一个所以它的super.doStart会直接返回。接着继续执行scopedC的doStart方法的下一行代码
_nextScope=(ScopedHandler)getChildHandlerByClass(ScopedHandler.class)
对于HandlerWrapper来说getChildHandlerByClass返回的就是其包装的_handler对象这里返回的就是null。所以scopedC的_nextScope为null这段方法结束返回后继续执行scopedB中的doStart中同样执行这句代码
_nextScope=(ScopedHandler)getChildHandlerByClass(ScopedHandler.class)
因为scopedB的_handler引用指向的是scopedC所以getChildHandlerByClass返回的结果就是scopedC的引用即scopedB的_nextScope指向scopedC。
同理scopedA的_nextScope会指向scopedB。scopedA的doStart方法返回之后其_outScope为null。请注意执行到这里只有scopedA的_outScope为null所以doStart中finally部分的逻辑被触发这个线程的ThreadLocal变量又被设置为null。
finally
{
if (_outerScope==null)
__outerScope.set(null);
}
你可能会问费这么大劲设置_outScope和_nextScope的值到底有什么用如果你觉得上面的过程比较复杂可以跳过这个过程直接通过图来理解_outScope和_nextScope的值而这样设置的目的是用来控制doScope方法和doHandle方法的调用顺序。
实际上在ScopedHandler中对于doScope和doHandle方法是没有具体实现的但是提供了nextHandle和nextScope两个方法下面是它们的源码
public void doScope(String target,
Request baseRequest,
HttpServletRequest request,
HttpServletResponse response)
throws IOException, ServletException
{
nextScope(target,baseRequest,request,response);
}
public final void nextScope(String target,
Request baseRequest,
HttpServletRequest request,
HttpServletResponse response)
throws IOException, ServletException
{
if (_nextScope!=null)
_nextScope.doScope(target,baseRequest,request, response);
else if (_outerScope!=null)
_outerScope.doHandle(target,baseRequest,request, response);
else
doHandle(target,baseRequest,request, response);
}
public abstract void doHandle(String target,
Request baseRequest,
HttpServletRequest request,
HttpServletResponse response)
throws IOException, ServletException;
public final void nextHandle(String target,
final Request baseRequest,
HttpServletRequest request,
HttpServletResponse response)
throws IOException, ServletException
{
if (_nextScope!=null && _nextScope==_handler)
_nextScope.doHandle(target,baseRequest,request, response);
else if (_handler!=null)
super.handle(target,baseRequest,request,response);
}
从nextHandle和nextScope方法大致上可以猜到doScope和doHandle的调用流程。我通过一个调用栈来帮助你理解
A.handle(...)
A.doScope(...)
B.doScope(...)
C.doScope(...)
A.doHandle(...)
B.doHandle(...)
X.handle(...)
C.handle(...)
C.doHandle(...)
因此通过设置_outScope和_nextScope的值并且在代码中判断这些值并采取相应的动作目的就是让ScopedHandler链上的doScope方法在doHandle、handle方法之前执行。并且不同ScopedHandler的doScope都是按照它在链上的先后顺序执行的doHandle和handle方法也是如此。
这样ScopedHandler帮我们把调用框架搭好了它的子类只需要实现doScope和doHandle方法。比如在doScope方法里做一些初始化工作在doHanlde方法处理请求。
ContextHandler
接下来我们来看看ScopedHandler的子类ContextHandler是如何实现doScope和doHandle方法的。ContextHandler可以理解为Tomcat中的Context组件对应一个Web应用它的功能是给Servlet的执行维护一个上下文环境并且将请求转发到相应的Servlet。那什么是Servlet执行的上下文我们通过ContextHandler的构造函数来了解一下
private ContextHandler(Context context, HandlerContainer parent, String contextPath)
{
//_scontext就是Servlet规范中的ServletContext
_scontext = context == null?new Context():context;
//Web应用的初始化参数
_initParams = new HashMap<String, String>();
...
}
我们看到ContextHandler维护了ServletContext和Web应用的初始化参数。那ContextHandler的doScope方法做了些什么呢我们看看它的关键代码
public void doScope(String target, Request baseRequest, HttpServletRequest request, HttpServletResponse response) throws IOException, ServletException
{
...
//1.修正请求的URL去掉多余的'/',或者加上'/'
if (_compactPath)
target = URIUtil.compactPath(target);
if (!checkContext(target,baseRequest,response))
return;
if (target.length() > _contextPath.length())
{
if (_contextPath.length() > 1)
target = target.substring(_contextPath.length());
pathInfo = target;
}
else if (_contextPath.length() == 1)
{
target = URIUtil.SLASH;
pathInfo = URIUtil.SLASH;
}
else
{
target = URIUtil.SLASH;
pathInfo = null;
}
//2.设置当前Web应用的类加载器
if (_classLoader != null)
{
current_thread = Thread.currentThread();
old_classloader = current_thread.getContextClassLoader();
current_thread.setContextClassLoader(_classLoader);
}
//3. 调用nextScope
nextScope(target,baseRequest,request,response);
...
}
从代码我们看到在doScope方法里主要是做了一些请求的修正、类加载器的设置并调用nextScope请你注意nextScope调用是由父类ScopedHandler实现的。接着我们来ContextHandler的doHandle方法
public void doHandle(String target, Request baseRequest, HttpServletRequest request, HttpServletResponse response) throws IOException, ServletException
{
final DispatcherType dispatch = baseRequest.getDispatcherType();
final boolean new_context = baseRequest.takeNewContext();
try
{
//请求的初始化工作,主要是为请求添加ServletRequestAttributeListener监听器,并将"开始处理一个新请求"这个事件通知ServletRequestListener
if (new_context)
requestInitialized(baseRequest,request);
...
//继续调用下一个Handler下一个Handler可能是ServletHandler、SessionHandler ...
nextHandle(target,baseRequest,request,response);
}
finally
{
//同样一个Servlet请求处理完毕也要通知相应的监听器
if (new_context)
requestDestroyed(baseRequest,request);
}
}
从上面的代码我们看到ContextHandler在doHandle方法里分别完成了相应的请求处理工作。
本期精华
今天我们分析了Jetty中ScopedHandler的实现原理剖析了如何实现链式调用的“回溯”。主要是确定了doScope和doHandle的调用顺序doScope依次调用完以后再依次调用doHandle它的子类比如ContextHandler只需要实现doScope和doHandle方法而不需要关心它们被调用的顺序。
这背后的原理是ScopedHandler通过递归的方式来设置_outScope和_nextScope两个变量然后通过判断这些值来控制调用的顺序。递归是计算机编程的一个重要的概念在各种面试题中也经常出现如果你能读懂Jetty中的这部分代码毫无疑问你已经掌握了递归的精髓。
另外我们进行层层递归调用中需要用到一些变量比如ScopedHandler中的__outerScope它保存了Handler链中的头节点但是它不是递归方法的参数那参数怎么传递过去呢一种可能的办法是设置一个全局变量各Handler都能访问到这个变量。但这样会有线程安全的问题因此ScopedHandler通过线程私有数据ThreadLocal来保存变量这样既达到了传递变量的目的又没有线程安全的问题。
课后思考
ScopedHandler的doStart方法最后一步是将线程私有变量__outerScope设置成null为什么需要这样做呢
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,也欢迎你把你的课后思考和心得记录下来,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。

View File

@ -0,0 +1,204 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
30 热点问题答疑3Spring框架中的设计模式
在构思这个专栏的时候回想当时我是如何研究Tomcat和Jetty源码的除了理解它们的实现之外也从中学到了很多架构和设计的理念其中很重要的就是对设计模式的运用让我收获到不少经验。而且这些经验通过自己消化和吸收是可以把它应用到实际工作中去的。
在专栏的热点问题答疑第三篇我想跟你分享一些我对设计模式的理解。有关Tomcat和Jetty所运用的设计模式我在专栏里已经有所介绍今天想跟你分享一下Spring框架里的设计模式。Spring的核心功能是IOC容器以及AOP面向切面编程同样也是很多Web后端工程师每天都要打交道的框架相信你一定可以从中吸收到一些设计方面的精髓帮助你提升设计能力。
简单工厂模式
我们来考虑这样一个场景当A对象需要调用B对象的方法时我们需要在A中new一个B的实例我们把这种方式叫作硬编码耦合它的缺点是一旦需求发生变化比如需要使用C类来代替B时就要改写A类的方法。假如应用中有1000个类以硬编码的方式耦合了B那改起来就费劲了。于是简单工厂模式就登场了简单工厂模式又叫静态工厂方法其实质是由一个工厂类根据传入的参数动态决定应该创建哪一个产品类。
Spring中的BeanFactory就是简单工厂模式的体现BeanFactory是Spring IOC容器中的一个核心接口它的定义如下
public interface BeanFactory {
Object getBean(String name) throws BeansException;
<T> T getBean(String name, Class<T> requiredType);
Object getBean(String name, Object... args);
<T> T getBean(Class<T> requiredType);
<T> T getBean(Class<T> requiredType, Object... args);
boolean containsBean(String name);
boolean isSingleton(String name);
boolea isPrototype(String name);
boolean isTypeMatch(String name, ResolvableType typeToMatch);
boolean isTypeMatch(String name, Class<?> typeToMatch);
Class<?> getType(String name);
String[] getAliases(String name);
}
我们可以通过它的具体实现类比如ClassPathXmlApplicationContext来获取Bean
BeanFactory bf = new ClassPathXmlApplicationContext("spring.xml");
User userBean = (User) bf.getBean("userBean");
从上面代码可以看到使用者不需要自己来new对象而是通过工厂类的方法getBean来获取对象实例这是典型的简单工厂模式只不过Spring是用反射机制来创建Bean的。
工厂方法模式
工厂方法模式说白了其实就是简单工厂模式的一种升级或者说是进一步抽象,它可以应用于更加复杂的场景,灵活性也更高。在简单工厂中,由工厂类进行所有的逻辑判断、实例创建;如果不想在工厂类中进行判断,可以为不同的产品提供不同的工厂,不同的工厂生产不同的产品,每一个工厂都只对应一个相应的对象,这就是工厂方法模式。
Spring中的FactoryBean就是这种思想的体现FactoryBean可以理解为工厂Bean先来看看它的定义
public interface FactoryBean<T> {
T getObject()
Class<?> getObjectType();
boolean isSingleton();
}
我们定义一个类UserFactoryBean来实现FactoryBean接口主要是在getObject方法里new一个User对象。这样我们通过getBean(id) 获得的是该工厂所产生的User的实例而不是UserFactoryBean本身的实例像下面这样
BeanFactory bf = new ClassPathXmlApplicationContext("user.xml");
User userBean = (User) bf.getBean("userFactoryBean");
单例模式
单例模式是指一个类在整个系统运行过程中只允许产生一个实例。在Spring中Bean可以被定义为两种模式Prototype多例和Singleton单例Spring Bean默认是单例模式。那Spring是如何实现单例模式的呢答案是通过单例注册表的方式具体来说就是使用了HashMap。请注意为了方便你阅读我对代码进行了简化
public class DefaultSingletonBeanRegistry {
//使用了线程安全容器ConcurrentHashMap保存各种单实例对象
private final Map<String, Object> singletonObjects = new ConcurrentHashMap<String, Object>;
protected Object getSingleton(String beanName) {
//先到HashMap中拿Object
Object singletonObject = singletonObjects.get(beanName);
//如果没拿到通过反射创建一个对象实例并添加到HashMap中
if (singletonObject == null) {
singletonObjects.put(beanName,
Class.forName(beanName).newInstance());
}
//返回对象实例
return singletonObjects.get(beanName);
}
}
上面的代码逻辑比较清晰先到HashMap去拿单实例对象没拿到就创建一个添加到HashMap。
代理模式
所谓代理,是指它与被代理对象实现了相同的接口,客户端必须通过代理才能与被代理的目标类进行交互,而代理一般在交互的过程中(交互前后),进行某些特定的处理,比如在调用这个方法前做前置处理,调用这个方法后做后置处理。代理模式中有下面几种角色:
抽象接口:定义目标类及代理类的共同接口,这样在任何可以使用目标对象的地方都可以使用代理对象。
目标对象: 定义了代理对象所代表的目标对象,专注于业务功能的实现。
代理对象: 代理对象内部含有目标对象的引用,收到客户端的调用请求时,代理对象通常不会直接调用目标对象的方法,而是在调用之前和之后实现一些额外的逻辑。
代理模式的好处是,可以在目标对象业务功能的基础上添加一些公共的逻辑,比如我们想给目标对象加入日志、权限管理和事务控制等功能,我们就可以使用代理类来完成,而没必要修改目标类,从而使得目标类保持稳定。这其实是开闭原则的体现,不要随意去修改别人已经写好的代码或者方法。
代理又分为静态代理和动态代理两种方式。静态代理需要定义接口被代理对象目标对象与代理对象Proxy)一起实现相同的接口,我们通过一个例子来理解一下:
//抽象接口
public interface IStudentDao {
void save();
}
//目标对象
public class StudentDao implements IStudentDao {
public void save() {
System.out.println("保存成功");
}
}
//代理对象
public class StudentDaoProxy implements IStudentDao{
//持有目标对象的引用
private IStudentDao target;
public StudentDaoProxy(IStudentDao target){
this.target = target;
}
//在目标功能对象方法的前后加入事务控制
public void save() {
System.out.println("开始事务");
target.save();//执行目标对象的方法
System.out.println("提交事务");
}
}
public static void main(String[] args) {
//创建目标对象
StudentDao target = new StudentDao();
//创建代理对象,把目标对象传给代理对象,建立代理关系
StudentDaoProxy proxy = new StudentDaoProxy(target);
//执行的是代理的方法
proxy.save();
}
而Spring的AOP采用的是动态代理的方式而动态代理就是指代理类在程序运行时由JVM动态创建。在上面静态代理的例子中代理类StudentDaoProxy是我们自己定义好的在程序运行之前就已经编译完成。而动态代理代理类并不是在Java代码中定义的而是在运行时根据我们在Java代码中的“指示”动态生成的。那我们怎么“指示”JDK去动态地生成代理类呢
在Java的java.lang.reflect包里提供了一个Proxy类和一个InvocationHandler接口通过这个类和这个接口可以生成动态代理对象。具体来说有如下步骤
1.定义一个InvocationHandler类将需要扩展的逻辑集中放到这个类中比如下面的例子模拟了添加事务控制的逻辑。
public class MyInvocationHandler implements InvocationHandler {
private Object obj;
public MyInvocationHandler(Object obj){
this.obj=obj;
}
@Override
public Object invoke(Object proxy, Method method, Object[] args)
throws Throwable {
System.out.println("开始事务");
Object result = method.invoke(obj, args);
System.out.println("开始事务");
return result;
}
}
2.使用Proxy的newProxyInstance方法动态的创建代理对象
public static void main(String[] args) {
//创建目标对象StudentDao
IStudentDao stuDAO = new StudentDao();
//创建MyInvocationHandler对象
InvocationHandler handler = new MyInvocationHandler(stuDAO);
//使用Proxy.newProxyInstance动态的创建代理对象stuProxy
IStudentDao stuProxy = (IStudentDao)
Proxy.newProxyInstance(stuDAO.getClass().getClassLoader(), stuDAO.getClass().getInterfaces(), handler);
//动用代理对象的方法
stuProxy.save();
}
上面的代码实现和静态代理一样的功能,相比于静态代理,动态代理的优势在于可以很方便地对代理类的函数进行统一的处理,而不用修改每个代理类中的方法。
Spring实现了通过动态代理对类进行方法级别的切面增强我来解释一下这句话其实就是动态生成目标对象的代理类并在代理类的方法中设置拦截器通过执行拦截器中的逻辑增强了代理方法的功能从而实现AOP。
本期精华
今天我和你聊了Spring中的设计模式我记得我刚毕业那会儿拿到一个任务时我首先考虑的是怎么把功能实现了从不考虑设计的问题因此写出来的代码就显得比较稚嫩。后来随着经验的积累我会有意识地去思考这个场景是不是用个设计模式会更高大上呢以后重构起来是不是会更轻松呢慢慢我也就形成一个习惯那就是用优雅的方式去实现一个系统这也是每个程序员需要经历的过程。
今天我们学习了Spring的两大核心功能IOC和AOP中用到的一些设计模式主要有简单工厂模式、工厂方法模式、单例模式和代理模式。而代理模式又分为静态代理和动态代理。JDK提供实现动态代理的机制除此之外还可以通过CGLIB来实现有兴趣的同学可以理解一下它的原理。
课后思考
注意到在newProxyInstance方法中传入了目标类的加载器、目标类实现的接口以及MyInvocationHandler三个参数就能得到一个动态代理对象请你思考一下newProxyInstance方法是如何实现的。
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,也欢迎你把你的课后思考和心得记录下来,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。

View File

@ -0,0 +1,166 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
31 Logger组件Tomcat的日志框架及实战
每一个系统都有一些通用的模块比如日志模块、异常处理模块、工具类等对于Tomcat来说比较重要的通用模块有日志、Session管理和集群管理。从今天开始我会分三期来介绍通用模块今天这一期先来讲日志模块。
日志模块作为一个通用的功能在系统里通常会使用第三方的日志框架。Java的日志框架有很多比如JULJava Util Logging、Log4j、Logback、Log4j2、Tinylog等。除此之外还有JCLApache Commons Logging和SLF4J这样的“门面日志”。下面是SLF4J与日志框架Logback、Log4j的关系图
我先来解释一下什么是“门面日志”。“门面日志”利用了设计模式中的门面模式思想对外提供一套通用的日志记录的API而不提供具体的日志输出服务如果要实现日志输出需要集成其他的日志框架比如Log4j、Logback、Log4j2等。
这种门面模式的好处在于记录日志的API和日志输出的服务分离开代码里面只需要关注记录日志的API通过SLF4J指定的接口记录日志而日志输出通过引入JAR包的方式即可指定其他的日志框架。当我们需要改变系统的日志输出服务时不用修改代码只需要改变引入日志输出框架JAR包。
今天我们就来看看Tomcat的日志模块是如何实现的。默认情况下Tomcat使用自身的JULI作为Tomcat内部的日志处理系统。JULI的日志门面采用了JCL而JULI的具体实现是构建在Java原生的日志系统java.util.logging之上的所以在看JULI的日志系统之前我先简单介绍一下Java的日志系统。
Java日志系统
Java的日志包在java.util.logging路径下包含了几个比较重要的组件我们通过一张图来理解一下
从图上我们看到这样几个重要的组件:
Logger用来记录日志的类。
Handler规定了日志的输出方式如控制台输出、写入文件。
Level定义了日志的不同等级。
Formatter将日志信息格式化比如纯文本、XML。
我们可以通过下面的代码来使用这些组件:
public static void main(String[] args) {
Logger logger = Logger.getLogger("com.mycompany.myapp");
logger.setLevel(Level.FINE);
logger.setUseParentHandlers(false);
Handler hd = new ConsoleHandler();
hd.setLevel(Level.FINE);
logger.addHandler(hd);
logger.info("start log");
}
JULI
JULI对日志的处理方式与Java自带的基本一致但是Tomcat中可以包含多个应用而每个应用的日志系统应该相互独立。Java的原生日志系统是每个JVM有一份日志的配置文件这不符合Tomcat多应用的场景所以JULI重新实现了一些日志接口。
DirectJDKLog
Log的基础实现类是DirectJDKLog这个类相对简单就包装了一下Java的Logger类。但是它也在原来的基础上进行了一些修改比如修改默认的格式化方式。
LogFactory
Log使用了工厂模式来向外提供实例LogFactory是一个单例可以通过SeviceLoader为Log提供自定义的实现版本如果没有配置就默认使用DirectJDKLog。
private LogFactory() {
// 通过ServiceLoader尝试加载Log的实现类
ServiceLoader<Log> logLoader = ServiceLoader.load(Log.class);
Constructor<? extends Log> m=null;
for (Log log: logLoader) {
Class<? extends Log> c=log.getClass();
try {
m=c.getConstructor(String.class);
break;
}
catch (NoSuchMethodException | SecurityException e) {
throw new Error(e);
}
}
//如何没有定义Log的实现类discoveredLogConstructor为null
discoveredLogConstructor = m;
}
下面的代码是LogFactory的getInstance方法
public Log getInstance(String name) throws LogConfigurationException {
//如果discoveredLogConstructor为null也就没有定义Log类默认用DirectJDKLog
if (discoveredLogConstructor == null) {
return DirectJDKLog.getInstance(name);
}
try {
return discoveredLogConstructor.newInstance(name);
} catch (ReflectiveOperationException | IllegalArgumentException e) {
throw new LogConfigurationException(e);
}
}
Handler
在JULI中就自定义了两个HandlerFileHandler和AsyncFileHandler。FileHandler可以简单地理解为一个在特定位置写文件的工具类有一些写操作常用的方法如open、write(publish)、close、flush等使用了读写锁。其中的日志信息通过Formatter来格式化。
AsyncFileHandler继承自FileHandler实现了异步的写操作。其中缓存存储是通过阻塞双端队列LinkedBlockingDeque来实现的。当应用要通过这个Handler来记录一条消息时消息会先被存储到队列中而在后台会有一个专门的线程来处理队列中的消息取出的消息会通过父类的publish方法写入相应文件内。这样就可以在大量日志需要写入的时候起到缓冲作用防止都阻塞在写日志这个动作上。需要注意的是我们可以为阻塞双端队列设置不同的模式在不同模式下对新进入的消息有不同的处理方式有些模式下会直接丢弃一些日志
OVERFLOW_DROP_LAST丢弃栈顶的元素
OVERFLOW_DROP_FIRSH丢弃栈底的元素
OVERFLOW_DROP_FLUSH等待一定时间并重试不会丢失元素
OVERFLOW_DROP_CURRENT丢弃放入的元素
Formatter
Formatter通过一个format方法将日志记录LogRecord转化成格式化的字符串JULI提供了三个新的Formatter。
OnlineFormatter基本与Java自带的SimpleFormatter格式相同不过把所有内容都写到了一行中。
VerbatimFormatter只记录了日志信息没有任何额外的信息。
JdkLoggerFormatter格式化了一个轻量级的日志信息。
日志配置
Tomcat的日志配置文件为Tomcat文件夹下conf/logging.properties。我来拆解一下这个配置文件首先可以看到各种Handler的配置
handlers = 1catalina.org.apache.juli.AsyncFileHandler, 2localhost.org.apache.juli.AsyncFileHandler, 3manager.org.apache.juli.AsyncFileHandler, 4host-manager.org.apache.juli.AsyncFileHandler, java.util.logging.ConsoleHandler
.handlers = 1catalina.org.apache.juli.AsyncFileHandler, java.util.logging.ConsoleHandler
以1catalina.org.apache.juli.AsyncFileHandler为例数字是为了区分同一个类的不同实例catalina、localhost、manager和host-manager是Tomcat用来区分不同系统日志的标志后面的字符串表示了Handler具体类型如果要添加Tomcat服务器的自定义Handler需要在字符串里添加。
接下来是每个Handler设置日志等级、目录和文件前缀自定义的Handler也要在这里配置详细信息:
1catalina.org.apache.juli.AsyncFileHandler.level = FINE
1catalina.org.apache.juli.AsyncFileHandler.directory = ${catalina.base}/logs
1catalina.org.apache.juli.AsyncFileHandler.prefix = catalina.
1catalina.org.apache.juli.AsyncFileHandler.maxDays = 90
1catalina.org.apache.juli.AsyncFileHandler.encoding = UTF-8
Tomcat + SLF4J + Logback
在今天文章开头我提到SLF4J和JCL都是日志门面那它们有什么区别呢它们的区别主要体现在日志服务类的绑定机制上。JCL采用运行时动态绑定的机制在运行时动态寻找和加载日志框架实现。
SLF4J日志输出服务绑定则相对简单很多在编译时就静态绑定日志框架只需要提前引入需要的日志框架。另外Logback可以说Log4j的进化版在性能和可用性方面都有所提升。你可以参考官网上这篇文章来了解Logback的优势。
基于此我们来实战一下如何将Tomcat默认的日志框架切换成为“SLF4J + Logback”。具体的步骤是
1.根据你的Tomcat版本从这里下载所需要文件。解压后你会看到一个类似于Tomcat目录结构的文件夹。-
2.替换或拷贝下列这些文件到Tomcat的安装目录
3.删除<Tomcat>/conf/logging.properties-
4.启动Tomcat
本期精华
今天我们谈了日志框架与日志门面的区别以及Tomcat的日志模块是如何实现的。默认情况下Tomcat的日志模板叫作JULIJULI的日志门面采用了JCL而具体实现是基于Java默认的日志框架Java Util LoggingTomcat在Java Util Logging基础上进行了改造使得它自身的日志框架不会影响Web应用并且可以分模板配置日志的输出文件和格式。最后我分享了如何将Tomcat的日志模块切换到时下流行的“SLF4J + Logback”希望对你有所帮助。
课后思考
Tomcat独立部署时各种日志都输出到了相应的日志文件假如Spring Boot以内嵌式的方式运行Tomcat这种情况下Tomcat的日志都输出到哪里去了
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,也欢迎你把你的课后思考和心得记录下来,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。

View File

@ -0,0 +1,283 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
32 Manager组件Tomcat的Session管理机制解析
我们可以通过Request对象的getSession方法来获取Session并通过Session对象来读取和写入属性值。而Session的管理是由Web容器来完成的主要是对Session的创建和销毁除此之外Web容器还需要将Session状态的变化通知给监听者。
当然Session管理还可以交给Spring来做好处是与特定的Web容器解耦Spring Session的核心原理是通过Filter拦截Servlet请求将标准的ServletRequest包装一下换成Spring的Request对象这样当我们调用Request对象的getSession方法时Spring在背后为我们创建和管理Session。
那么Tomcat的Session管理机制我们还需要了解吗我觉得还是有必要因为只有了解这些原理我们才能更好的理解Spring Session以及Spring Session为什么设计成这样。今天我们就从Session的创建、Session的清理以及Session的事件通知这几个方面来了解Tomcat的Session管理机制。
Session的创建
Tomcat中主要由每个Context容器内的一个Manager对象来管理Session。默认实现类为StandardManager。下面我们通过它的接口来了解一下StandardManager的功能
public interface Manager {
public Context getContext();
public void setContext(Context context);
public SessionIdGenerator getSessionIdGenerator();
public void setSessionIdGenerator(SessionIdGenerator sessionIdGenerator);
public long getSessionCounter();
public void setSessionCounter(long sessionCounter);
public int getMaxActive();
public void setMaxActive(int maxActive);
public int getActiveSessions();
public long getExpiredSessions();
public void setExpiredSessions(long expiredSessions);
public int getRejectedSessions();
public int getSessionMaxAliveTime();
public void setSessionMaxAliveTime(int sessionMaxAliveTime);
public int getSessionAverageAliveTime();
public int getSessionCreateRate();
public int getSessionExpireRate();
public void add(Session session);
public void changeSessionId(Session session);
public void changeSessionId(Session session, String newId);
public Session createEmptySession();
public Session createSession(String sessionId);
public Session findSession(String id) throws IOException;
public Session[] findSessions();
public void load() throws ClassNotFoundException, IOException;
public void remove(Session session);
public void remove(Session session, boolean update);
public void addPropertyChangeListener(PropertyChangeListener listener)
public void removePropertyChangeListener(PropertyChangeListener listener);
public void unload() throws IOException;
public void backgroundProcess();
public boolean willAttributeDistribute(String name, Object value);
}
不出意外我们在接口中看到了添加和删除Session的方法另外还有load和unload方法它们的作用是分别是将Session持久化到存储介质和从存储介质加载Session。
当我们调用HttpServletRequest.getSession(true)时这个参数true的意思是“如果当前请求还没有Session就创建一个新的”。那Tomcat在背后为我们做了些什么呢
HttpServletRequest是一个接口Tomcat实现了这个接口具体实现类是org.apache.catalina.connector.Request。
但这并不是我们拿到的RequestTomcat为了避免把一些实现细节暴露出来还有基于安全上的考虑定义了Request的包装类叫作RequestFacade我们可以通过代码来理解一下
public class Request implements HttpServletRequest {}
public class RequestFacade implements HttpServletRequest {
protected Request request = null;
public HttpSession getSession(boolean create) {
return request.getSession(create);
}
}
因此我们拿到的Request类其实是RequestFacadeRequestFacade的getSession方法调用的是Request类的getSession方法我们继续来看Session具体是如何创建的
Context context = getContext();
if (context == null) {
return null;
}
Manager manager = context.getManager();
if (manager == null) {
return null;
}
session = manager.createSession(sessionId);
session.access();
从上面的代码可以看出Request对象中持有Context容器对象而Context容器持有Session管理器Manager这样通过Context组件就能拿到Manager组件最后由Manager组件来创建Session。
因此最后还是到了StandardManagerStandardManager的父类叫ManagerBase这个createSession方法定义在ManagerBase中StandardManager直接重用这个方法。
接着我们来看ManagerBase的createSession是如何实现的
@Override
public Session createSession(String sessionId) {
//首先判断Session数量是不是到了最大值最大Session数可以通过参数设置
if ((maxActiveSessions >= 0) &&
(getActiveSessions() >= maxActiveSessions)) {
rejectedSessions++;
throw new TooManyActiveSessionsException(
sm.getString("managerBase.createSession.ise"),
maxActiveSessions);
}
// 重用或者创建一个新的Session对象请注意在Tomcat中就是StandardSession
// 它是HttpSession的具体实现类而HttpSession是Servlet规范中定义的接口
Session session = createEmptySession();
// 初始化新Session的值
session.setNew(true);
session.setValid(true);
session.setCreationTime(System.currentTimeMillis());
session.setMaxInactiveInterval(getContext().getSessionTimeout() * 60);
String id = sessionId;
if (id == null) {
id = generateSessionId();
}
session.setId(id);// 这里会将Session添加到ConcurrentHashMap中
sessionCounter++;
//将创建时间添加到LinkedList中并且把最先添加的时间移除
//主要还是方便清理过期Session
SessionTiming timing = new SessionTiming(session.getCreationTime(), 0);
synchronized (sessionCreationTiming) {
sessionCreationTiming.add(timing);
sessionCreationTiming.poll();
}
return session
}
到此我们明白了Session是如何创建出来的创建出来后Session会被保存到一个ConcurrentHashMap中
protected Map<String, Session> sessions = new ConcurrentHashMap<>();
请注意Session的具体实现类是StandardSessionStandardSession同时实现了javax.servlet.http.HttpSession和org.apache.catalina.Session接口并且对程序员暴露的是StandardSessionFacade外观类保证了StandardSession的安全避免了程序员调用其内部方法进行不当操作。StandardSession的核心成员变量如下
public class StandardSession implements HttpSession, Session, Serializable {
protected ConcurrentMap<String, Object> attributes = new ConcurrentHashMap<>();
protected long creationTime = 0L;
protected transient volatile boolean expiring = false;
protected transient StandardSessionFacade facade = null;
protected String id = null;
protected volatile long lastAccessedTime = creationTime;
protected transient ArrayList<SessionListener> listeners = new ArrayList<>();
protected transient Manager manager = null;
protected volatile int maxInactiveInterval = -1;
protected volatile boolean isNew = false;
protected volatile boolean isValid = false;
protected transient Map<String, Object> notes = new Hashtable<>();
protected transient Principal principal = null;
}
Session的清理
我们再来看看Tomcat是如何清理过期的Session。在Tomcat热加载和热部署的文章里我讲到容器组件会开启一个ContainerBackgroundProcessor后台线程调用自己以及子容器的backgroundProcess进行一些后台逻辑的处理和Lifecycle一样这个动作也是具有传递性的也就是说子容器还会把这个动作传递给自己的子容器。你可以参考下图来理解这个过程。
其中父容器会遍历所有的子容器并调用其backgroundProcess方法而StandardContext重写了该方法它会调用StandardManager的backgroundProcess进而完成Session的清理工作下面是StandardManager的backgroundProcess方法的代码
public void backgroundProcess() {
// processExpiresFrequency 默认值为6而backgroundProcess默认每隔10s调用一次也就是说除了任务执行的耗时每隔 60s 执行一次
count = (count + 1) % processExpiresFrequency;
if (count == 0) // 默认每隔 60s 执行一次 Session 清理
processExpires();
}
/**
* 单线程处理,不存在线程安全问题
*/
public void processExpires() {
// 获取所有的 Session
Session sessions[] = findSessions();
int expireHere = 0 ;
for (int i = 0; i < sessions.length; i++) {
// Session 的过期是在isValid()方法里处理的
if (sessions[i]!=null && !sessions[i].isValid()) {
expireHere++;
}
}
}
backgroundProcess由Tomcat后台线程调用默认是每隔10秒调用一次但是Session的清理动作不能太频繁因为需要遍历Session列表会耗费CPU资源所以在backgroundProcess方法中做了取模处理backgroundProcess调用6次才执行一次Session清理也就是说Session清理每60秒执行一次。
Session事件通知
按照Servlet规范在Session的生命周期过程中要将事件通知监听者Servlet规范定义了Session的监听器接口
public interface HttpSessionListener extends EventListener {
//Session创建时调用
public default void sessionCreated(HttpSessionEvent se) {
}
//Session销毁时调用
public default void sessionDestroyed(HttpSessionEvent se) {
}
}
注意到这两个方法的参数都是HttpSessionEvent所以Tomcat需要先创建HttpSessionEvent对象然后遍历Context内部的LifecycleListener并且判断是否为HttpSessionListener实例如果是的话则调用HttpSessionListener的sessionCreated方法进行事件通知。这些事情都是在Session的setId方法中完成的
session.setId(id);
@Override
public void setId(String id, boolean notify) {
//如果这个id已经存在先从Manager中删除
if ((this.id != null) && (manager != null))
manager.remove(this);
this.id = id;
//添加新的Session
if (manager != null)
manager.add(this);
//这里面完成了HttpSessionListener事件通知
if (notify) {
tellNew();
}
}
从代码我们看到setId方法调用了tellNew方法那tellNew又是如何实现的呢
public void tellNew() {
// 通知org.apache.catalina.SessionListener
fireSessionEvent(Session.SESSION_CREATED_EVENT, null);
// 获取Context内部的LifecycleListener并判断是否为HttpSessionListener
Context context = manager.getContext();
Object listeners[] = context.getApplicationLifecycleListeners();
if (listeners != null && listeners.length > 0) {
//创建HttpSessionEvent
HttpSessionEvent event = new HttpSessionEvent(getSession());
for (int i = 0; i < listeners.length; i++) {
//判断是否是HttpSessionListener
if (!(listeners[i] instanceof HttpSessionListener))
continue;
HttpSessionListener listener = (HttpSessionListener) listeners[i];
//注意这是容器内部事件
context.fireContainerEvent("beforeSessionCreated", listener);
//触发Session Created 事件
listener.sessionCreated(event);
//注意这也是容器内部事件
context.fireContainerEvent("afterSessionCreated", listener);
}
}
}
上面代码的逻辑是先通过StandardContext将HttpSessionListener类型的Listener取出然后依次调用它们的sessionCreated方法。
本期精华
今天我们从Request谈到了Session的创建、销毁和事件通知里面涉及不少相关的类下面我画了一张图帮你理解和消化一下这些类的关系
Servlet规范中定义了HttpServletRequest和HttpSession接口Tomcat实现了这些接口但具体实现细节并没有暴露给开发者因此定义了两个包装类RequestFacade和StandardSessionFacade。
Tomcat是通过Manager来管理Session的默认实现是StandardManager。StandardContext持有StandardManager的实例并存放了HttpSessionListener集合Session在创建和销毁时会通知监听器。
课后思考
TCP连接的过期时间和Session的过期时间有什么区别
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,也欢迎你把你的课后思考和心得记录下来,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。

View File

@ -0,0 +1,297 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
33 Cluster组件Tomcat的集群通信原理
为了支持水平扩展和高可用Tomcat提供了集群部署的能力但与此同时也带来了分布式系统的一个通用问题那就是如何在集群中的多个节点之间保持数据的一致性比如会话Session信息。
要实现这一点基本上有两种方式一种是把所有Session数据放到一台服务器或者一个数据库中集群中的所有节点通过访问这台Session服务器来获取数据。另一种方式就是在集群中的节点间进行Session数据的同步拷贝这里又分为两种策略第一种是将一个节点的Session拷贝到集群中其他所有节点第二种是只将一个节点上的Session数据拷贝到另一个备份节点。
对于Tomcat的Session管理来说这两种方式都支持。今天我们就来看看第二种方式的实现原理也就是Tomcat集群通信的原理和配置方法最后通过官网上的一个例子来了解下Tomcat集群到底是如何工作的。
集群通信原理
要实现集群通信首先要知道集群中都有哪些成员。Tomcat是通过组播Multicast来实现的。那什么是组播呢为了理解组播我先来说说什么是“单播”。网络节点之间的通信就好像是人们之间的对话一样一个人对另外一个人说话此时信息的接收和传递只在两个节点之间进行比如你在收发电子邮件、浏览网页时使用的就是单播也就是我们熟悉的“点对点通信”。
如果一台主机需要将同一个消息发送多个主机逐个传输效率就会比较低于是就出现组播技术。组播是一台主机向指定的一组主机发送数据报包组播通信的过程是这样的每一个Tomcat节点在启动时和运行时都会周期性默认500毫秒发送组播心跳包同一个集群内的节点都在相同的组播地址和端口监听这些信息在一定的时间内默认3秒不发送组播报文的节点就会被认为已经崩溃了会从集群中删去。因此通过组播集群中每个成员都能维护一个集群成员列表。
集群通信配置
有了集群成员的列表集群中的节点就能通过TCP连接向其他节点传输Session数据。Tomcat通过SimpleTcpCluster类来进行会话复制In-Memory Replication。要开启集群功能只需要将server.xml里的这一行的注释去掉就行
变成这样:
虽然只是简单的一行配置但这一行配置等同于下面这样的配置也就是说Tomcat给我们设置了很多默认参数这些参数都跟集群通信有关。
<!--
SimpleTcpCluster是用来复制Session的组件。复制Session有同步和异步两种方式
同步模式下向浏览器的发送响应数据前需要先将Session拷贝到其他节点完
异步模式下无需等待Session拷贝完成就可响应。异步模式更高效但是同步模式
可靠性更高。
同步异步模式由channelSendOptions参数控制默认值是8为异步模式4是同步模式。
在异步模式下,可以通过加上"拷贝确认"Acknowledge来提高可靠性此时
channelSendOptions设为10
-->
<Cluster className="org.apache.catalina.ha.tcp.SimpleTcpCluster"
channelSendOptions="8">
<!--
Manager决定如何管理集群的Session信息。
Tomcat提供了两种ManagerBackupManager和DeltaManager。
BackupManager集群下的某一节点的Session将复制到一个备份节点。
DeltaManager 集群下某一节点的Session将复制到所有其他节点。
DeltaManager是Tomcat默认的集群Manager。
expireSessionsOnShutdown设置为true时一个节点关闭时
将导致集群下的所有Session失效
notifyListenersOnReplication集群下节点间的Session复制、
删除操作是否通知session listeners
maxInactiveInterval集群下Session的有效时间(单位:s)。
maxInactiveInterval内未活动的Session将被Tomcat回收。
默认值为1800(30min)
-->
<Manager className="org.apache.catalina.ha.session.DeltaManager"
expireSessionsOnShutdown="false"
notifyListenersOnReplication="true"/>
<!--
Channel是Tomcat节点之间进行通讯的工具。
Channel包括5个组件Membership、Receiver、Sender、
Transport、Interceptor
-->
<Channel className="org.apache.catalina.tribes.group.GroupChannel">
<!--
Membership维护集群的可用节点列表。它可以检查到新增的节点
也可以检查没有心跳的节点
className指定Membership使用的类
address组播地址
port组播端口
frequency发送心跳(向组播地址发送UDP数据包)的时间间隔(单位:ms)。
dropTimeMembership在dropTime(单位:ms)内未收到某一节点的心跳,
则将该节点从可用节点列表删除。默认值为3000。
-->
<Membership className="org.apache.catalina.tribes.membership.
McastService"
address="228.0.0.4"
port="45564"
frequency="500"
dropTime="3000"/>
<!--
Receiver用于各个节点接收其他节点发送的数据。
接收器分为两种BioReceiver(阻塞式)、NioReceiver(非阻塞式)
className指定Receiver使用的类
address接收消息的地址
port接收消息的端口
autoBind端口的变化区间如果port为4000autoBind为100
接收器将在4000-4099间取一个端口进行监听。
selectorTimeoutNioReceiver内Selector轮询的超时时间
maxThreads线程池的最大线程数
-->
<Receiver className="org.apache.catalina.tribes.transport.nio.
NioReceiver"
address="auto"
port="4000"
autoBind="100"
selectorTimeout="5000"
maxThreads="6"/>
<!--
Sender用于向其他节点发送数据Sender内嵌了Transport组件
Transport真正负责发送消息。
-->
<Sender className="org.apache.catalina.tribes.transport.
ReplicationTransmitter">
<!--
Transport分为两种bio.PooledMultiSender(阻塞式)
和nio.PooledParallelSender(非阻塞式)PooledParallelSender
是从tcp连接池中获取连接可以实现并行发送即集群中的节点可以
同时向其他所有节点发送数据而互不影响。
-->
<Transport className="org.apache.catalina.tribes.
transport.nio.PooledParallelSender"/>
</Sender>
<!--
Interceptor : Cluster的拦截器
TcpFailureDetectorTcpFailureDetector可以拦截到某个节点关闭
的信息并尝试通过TCP连接到此节点以确保此节点真正关闭从而更新集
群可用节点列表
-->
<Interceptor className="org.apache.catalina.tribes.group.
interceptors.TcpFailureDetector"/>
<!--
MessageDispatchInterceptor查看Cluster组件发送消息的
方式是否设置为Channel.SEND_OPTIONS_ASYNCHRONOUS如果是
MessageDispatchInterceptor先将等待发送的消息进行排队
然后将排好队的消息转给Sender。
-->
<Interceptor className="org.apache.catalina.tribes.group.
interceptors.MessageDispatchInterceptor"/>
</Channel>
<!--
Valve : Tomcat的拦截器
ReplicationValve在处理请求前后打日志过滤不涉及Session变化的请求。
-->
<Valve className="org.apache.catalina.ha.tcp.ReplicationValve"
filter=""/>
<Valve className="org.apache.catalina.ha.session.
JvmRouteBinderValve"/>
<!--
Deployer用于集群的farm功能监控应用中文件的更新以保证集群中所有节点
应用的一致性如某个用户上传文件到集群中某个节点的应用程序目录下Deployer
会监测到这一操作并把文件拷贝到集群中其他节点相同应用的对应目录下以保持
所有应用的一致,这是一个相当强大的功能。
-->
<Deployer className="org.apache.catalina.ha.deploy.FarmWarDeployer"
tempDir="/tmp/war-temp/"
deployDir="/tmp/war-deploy/"
watchDir="/tmp/war-listen/"
watchEnabled="false"/>
<!--
ClusterListener : 监听器监听Cluster组件接收的消息
使用DeltaManager时Cluster接收的信息通过ClusterSessionListener
传递给DeltaManager从而更新自己的Session列表。
-->
<ClusterListener className="org.apache.catalina.ha.session.
ClusterSessionListener"/>
</Cluster>
从上面的的参数列表可以看到默认情况下Session管理组件DeltaManager会在节点之间拷贝SessionDeltaManager采用的一种all-to-all的工作方式即集群中的节点会把Session数据向所有其他节点拷贝而不管其他节点是否部署了当前应用。当集群节点数比较少时比如少于4个这种all-to-all的方式是不错的选择但是当集群中的节点数量比较多时数据拷贝的开销成指数级增长这种情况下可以考虑BackupManagerBackupManager只向一个备份节点拷贝数据。
在大体了解了Tomcat集群实现模型后就可以对集群作出更优化的配置了。Tomcat推荐了一套配置使用了比DeltaManager更高效的BackupManager并且通过ReplicationValve设置了请求过滤。
这里还请注意在一台服务器部署多个节点时需要修改Receiver的侦听端口另外为了在节点间高效地拷贝数据所有Tomcat节点最好采用相同的配置具体配置如下
<Cluster className="org.apache.catalina.ha.tcp.SimpleTcpCluster"
channelSendOptions="6">
<Manager className="org.apache.catalina.ha.session.BackupManager"
expireSessionsOnShutdown="false"
notifyListenersOnReplication="true"
mapSendOptions="6"/>
<Channel className="org.apache.catalina.tribes.group.
GroupChannel">
<Membership className="org.apache.catalina.tribes.membership.
McastService"
address="228.0.0.4"
port="45564"
frequency="500"
dropTime="3000"/>
<Receiver className="org.apache.catalina.tribes.transport.nio.
NioReceiver"
address="auto"
port="5000"
selectorTimeout="100"
maxThreads="6"/>
<Sender className="org.apache.catalina.tribes.transport.
ReplicationTransmitter">
<Transport className="org.apache.catalina.tribes.transport.
nio.PooledParallelSender"/>
</Sender>
<Interceptor className="org.apache.catalina.tribes.group.
interceptors.TcpFailureDetector"/>
<Interceptor className="org.apache.catalina.tribes.group.
interceptors.MessageDispatchInterceptor"/>
<Interceptor className="org.apache.catalina.tribes.group.
interceptors.ThroughputInterceptor"/>
</Channel>
<Valve className="org.apache.catalina.ha.tcp.ReplicationValve"
filter=".*\.gif|.*\.js|.*\.jpeg|.*\.jpg|.*\.png|.*\
.htm|.*\.html|.*\.css|.*\.txt"/>
<Deployer className="org.apache.catalina.ha.deploy.FarmWarDeployer"
tempDir="/tmp/war-temp/"
deployDir="/tmp/war-deploy/"
watchDir="/tmp/war-listen/"
watchEnabled="false"/>
<ClusterListener className="org.apache.catalina.ha.session.
ClusterSessionListener"/>
</Cluster>
集群工作过程
Tomcat的官网给出了一个例子来说明Tomcat集群模式下是如何工作的以及Tomcat集群是如何实现高可用的。比如集群由Tomcat A和Tomcat B两个Tomcat实例组成按照时间先后顺序发生了如下事件
1. Tomcat A启动
Tomcat A启动过程中当Host对象被创建时一个Cluster组件默认是SimpleTcpCluster被关联到这个Host对象。当某个应用在web.xml中设置了Distributable时Tomcat将为此应用的上下文环境创建一个DeltaManager。SimpleTcpCluster启动Membership服务和Replication服务。
2. Tomcat B启动在Tomcat A之后启动
首先Tomcat B会执行和Tomcat A一样的操作然后SimpleTcpCluster会建立一个由Tomcat A和Tomcat B组成的Membership。接着Tomcat B向集群中的Tomcat A请求Session数据如果Tomcat A没有响应Tomcat B的拷贝请求Tomcat B会在60秒后time out。在Session数据拷贝完成之前Tomcat B不会接收浏览器的请求。
3. Tomcat A接收HTTP请求创建Session 1
Tomcat A响应客户请求在把结果发送回客户端之前ReplicationValve会拦截当前请求如果Filter中配置了不需拦截的请求类型这一步就不会进行默认配置下拦截所有请求如果发现当前请求更新了Session就调用Replication服务建立TCP连接将Session拷贝到Membership列表中的其他节点即Tomcat B。在拷贝时所有保存在当前Session中的可序列化的对象都会被拷贝而不仅仅是发生更新的部分。
4. Tomcat A崩溃
当Tomcat A崩溃时Tomcat B会被告知Tomcat A已从集群中退出然后Tomcat B就会把Tomcat A从自己的Membership列表中删除。并且Tomcat B的Session更新时不再往Tomcat A拷贝同时负载均衡器会把后续的HTTP请求全部转发给Tomcat B。在此过程中所有的Session数据不会丢失。
5. Tomcat B接收Tomcat A的请求
Tomcat B正常响应本应该发往Tomcat A的请求因为Tomcat B保存了Tomcat A的所有Session数据。
6. Tomcat A重新启动
Tomcat A按步骤1、2操作启动加入集群并从Tomcat B拷贝所有Session数据拷贝完成后开始接收请求。
7. Tomcat A接收请求Session 1被用户注销
Tomcat继续接收发往Tomcat A的请求Session 1设置为失效。请注意这里的失效并非因为Tomcat A处于非活动状态超过设置的时间而是应用程序执行了注销的操作比如用户登出而引起的Session失效。这时Tomcat A向Tomcat B发送一个Session 1 Expired的消息Tomcat B收到消息后也会把Session 1设置为失效。
8. Tomcat B接收到一个新请求创建Session 2
同理这个新的Session也会被拷贝到Tomcat A。
9. Tomcat A上的Session 2过期
因超时原因引起的Session失效Tomcat A无需通知Tomcat BTomcat B同样知道Session 2已经超时。因此对于Tomcat集群有一点非常重要所有节点的操作系统时间必须一致。不然会出现某个节点Session已过期而在另一节点此Session仍处于活动状态的现象。
本期精华
今天我谈了Tomcat的集群工作原理和配置方式还通过官网上的一个例子说明了Tomcat集群的工作过程。Tomcat集群对Session的拷贝支持两种方式DeltaManager和BackupManager。
当集群中节点比较少时可以采用DeltaManager因为Session数据在集群中各个节点都有备份任何一个节点崩溃都不会对整体造成影响可靠性比较高。
当集群中节点数比较多时可以采用BackupManager这是因为一个节点的Session只会拷贝到另一个节点数据拷贝的开销比较少同时只要这两个节点不同时崩溃Session数据就不会丢失。
课后思考
在Tomcat官方推荐的配置里ReplicationValve被配置成下面这样
<Valve className="org.apache.catalina.ha.tcp.ReplicationValve"
filter=".*\.gif|.*\.js|.*\.jpeg|.*\.jpg|.*\.png|.*\
.htm|.*\.html|.*\.css|.*\.txt"/>
你是否注意到filter的值是一些JS文件或者图片等这是为什么呢
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,也欢迎你把你的课后思考和心得记录下来,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。

View File

@ -0,0 +1,149 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
34 JVM GC原理及调优的基本思路
和Web应用程序一样Tomcat作为一个Java程序也跑在JVM中因此如果我们要对Tomcat进行调优需要先了解JVM调优的原理。而对于JVM调优来说主要是JVM垃圾收集的优化一般来说是因为有问题才需要优化所以对于JVM GC来说如果你观察到Tomcat进程的CPU使用率比较高并且在GC日志中发现GC次数比较频繁、GC停顿时间长这表明你需要对GC进行优化了。
在对GC调优的过程中我们不仅需要知道GC的原理更重要的是要熟练使用各种监控和分析工具具备GC调优的实战能力。CMS和G1是时下使用率比较高的两款垃圾收集器从Java 9开始采用G1作为默认垃圾收集器而G1的目标也是逐步取代CMS。所以今天我们先来简单回顾一下两种垃圾收集器CMS和G1的区别接着通过一个例子帮你提高GC调优的实战能力。
CMS vs G1
CMS收集器将Java堆分为年轻代Young或年老代Old。这主要是因为有研究表明超过90的对象在第一次GC时就被回收掉但是少数对象往往会存活较长的时间。
CMS还将年轻代内存空间分为幸存者空间Survivor和伊甸园空间Eden。新的对象始终在Eden空间上创建。一旦一个对象在一次垃圾收集后还幸存就会被移动到幸存者空间。当一个对象在多次垃圾收集之后还存活时它会移动到年老代。这样做的目的是在年轻代和年老代采用不同的收集算法以达到较高的收集效率比如在年轻代采用复制-整理算法,在年老代采用标记-清理算法。因此CMS将Java堆分成如下区域
与CMS相比G1收集器有两大特点
G1可以并发完成大部分GC的工作这期间不会“Stop-The-World”。
G1使用非连续空间这使G1能够有效地处理非常大的堆。此外G1可以同时收集年轻代和年老代。G1并没有将Java堆分成三个空间Eden、Survivor和Old而是将堆分成许多通常是几百个非常小的区域。这些区域是固定大小的默认情况下大约为2MB。每个区域都分配给一个空间。 G1收集器的Java堆如下图所示
图上的U表示“未分配”区域。G1将堆拆分成小的区域一个最大的好处是可以做局部区域的垃圾回收而不需要每次都回收整个区域比如年轻代和年老代这样回收的停顿时间会比较短。具体的收集过程是
将所有存活的对象将从收集的区域复制到未分配的区域比如收集的区域是Eden空间把Eden中的存活对象复制到未分配区域这个未分配区域就成了Survivor空间。理想情况下如果一个区域全是垃圾意味着一个存活的对象都没有则可以直接将该区域声明为“未分配”。
为了优化收集时间G1总是优先选择垃圾最多的区域从而最大限度地减少后续分配和释放堆空间所需的工作量。这也是G1收集器名字的由来——Garbage-First。
GC调优原则
GC是有代价的因此我们调优的根本原则是每一次GC都回收尽可能多的对象也就是减少无用功。因此我们在做具体调优的时候针对CMS和G1两种垃圾收集器分别有一些相应的策略。
CMS收集器
对于CMS收集器来说最重要的是合理地设置年轻代和年老代的大小。年轻代太小的话会导致频繁的Minor GC并且很有可能存活期短的对象也不能被回收GC的效率就不高。而年老代太小的话容纳不下从年轻代过来的新对象会频繁触发单线程Full GC导致较长时间的GC暂停影响Web应用的响应时间。
G1收集器
对于G1收集器来说我不推荐直接设置年轻代的大小这一点跟CMS收集器不一样这是因为G1收集器会根据算法动态决定年轻代和年老代的大小。因此对于G1收集器我们需要关心的是Java堆的总大小-Xmx
此外G1还有一个较关键的参数是-XX:MaxGCPauseMillis = n这个参数是用来限制最大的GC暂停时间目的是尽量不影响请求处理的响应时间。G1将根据先前收集的信息以及检测到的垃圾量估计它可以立即收集的最大区域数量从而尽量保证GC时间不会超出这个限制。因此G1相对来说更加“智能”使用起来更加简单。
内存调优实战
下面我通过一个例子实战一下Java堆设置得过小导致频繁的GC我们将通过GC日志分析工具来观察GC活动并定位问题。
1.首先我们建立一个Spring Boot程序作为我们的调优对象代码如下
@RestController
public class GcTestController {
private Queue<Greeting> objCache = new ConcurrentLinkedDeque<>();
@RequestMapping("/greeting")
public Greeting greeting() {
Greeting greeting = new Greeting("Hello World!");
if (objCache.size() >= 200000) {
objCache.clear();
} else {
objCache.add(greeting);
}
return greeting;
}
}
@Data
@AllArgsConstructor
class Greeting {
private String message;
}
上面的代码就是创建了一个对象池当对象池中的对象数到达200000时才清空一次用来模拟年老代对象。
2.用下面的命令启动测试程序:
java -Xmx32m -Xss256k -verbosegc -Xlog:gc*,gc+ref=debug,gc+heap=debug,gc+age=trace:file=gc-%p-%t.log:tags,uptime,time,level:filecount=2,filesize=100m -jar target/demo-0.0.1-SNAPSHOT.jar
我给程序设置的堆的大小为32MB目的是能让我们看到Full GC。除此之外我还打开了verbosegc日志请注意这里我使用的版本是Java 12默认的垃圾收集器是G1。
3.使用JMeter压测工具向程序发送测试请求访问的路径是/greeting。
4.使用GCViewer工具打开GC日志我们可以看到这样的图
我来解释一下这张图:
图中上部的蓝线表示已使用堆的大小我们看到它周期的上下震荡这是我们的对象池要扩展到200000才会清空。
图底部的绿线表示年轻代GC活动从图上看到当堆的使用率上去了会触发频繁的GC活动。
图中的竖线表示Full GC从图上看到伴随着Full GC蓝线会下降这说明Full GC收集了年老代中的对象。
基于上面的分析我们可以得出一个结论那就是Java堆的大小不够。我来解释一下为什么得出这个结论
GC活动频繁年轻代GC绿色线和年老代GC黑色线都比较密集。这说明内存空间不够也就是Java堆的大小不够。
Java的堆中对象在GC之后能够被回收说明不是内存泄漏。
我们通过GCViewer还发现累计GC暂停时间有55.57秒,如下图所示:
因此我们的解决方案是调大Java堆的大小像下面这样
java -Xmx2048m -Xss256k -verbosegc -Xlog:gc*,gc+ref=debug,gc+heap=debug,gc+age=trace:file=gc-%p-%t.log:tags,uptime,time,level:filecount=2,filesize=100m -jar target/demo-0.0.1-SNAPSHOT.jar
生成的新的GC log分析图如下
你可以看到没有发生Full GC并且年轻代GC也没有那么频繁了并且累计GC暂停时间只有3.05秒。
本期精华
今天我们首先回顾了CMS和G1两种垃圾收集器背后的设计思路以及它们的区别接着分析了GC调优的总体原则。
对于CMS来说我们要合理设置年轻代和年老代的大小。你可能会问该如何确定它们的大小呢这是一个迭代的过程可以先采用JVM的默认值然后通过压测分析GC日志。
如果我们看年轻代的内存使用率处在高位导致频繁的Minor GC而频繁GC的效率又不高说明对象没那么快能被回收这时年轻代可以适当调大一点。
如果我们看年老代的内存使用率处在高位导致频繁的Full GC这样分两种情况如果每次Full GC后年老代的内存占用率没有下来可以怀疑是内存泄漏如果Full GC后年老代的内存占用率下来了说明不是内存泄漏我们要考虑调大年老代。
对于G1收集器来说我们可以适当调大Java堆因为G1收集器采用了局部区域收集策略单次垃圾收集的时间可控可以管理较大的Java堆。
课后思考
如果把年轻代和年老代都设置得很大,会有什么问题?
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,也欢迎你把你的课后思考和心得记录下来,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。

View File

@ -0,0 +1,181 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
35 如何监控Tomcat的性能
专栏上一期我们分析了JVM GC的基本原理以及监控和分析工具今天我们接着来聊如何监控Tomcat的各种指标因为只有我们掌握了这些指标和信息才能对Tomcat内部发生的事情一目了然让我们明白系统的瓶颈在哪里进而做出调优的决策。
在今天的文章里我们首先来看看到底都需要监控Tomcat哪些关键指标接着来具体学习如何通过JConsole来监控它们。如果系统没有暴露JMX接口我们还可以通过命令行来查看Tomcat的性能指标。
Web应用的响应时间是我们关注的一个重点最后我们通过一个实战案例来看看Web应用的下游服务响应时间比较长的情况下Tomcat的各项指标是什么样子的。
Tomcat的关键指标
Tomcat的关键指标有吞吐量、响应时间、错误数、线程池、CPU以及JVM内存。
我来简单介绍一下这些指标背后的意义。其中前三个指标是我们最关心的业务指标Tomcat作为服务器就是要能够又快有好地处理请求因此吞吐量要大、响应时间要短并且错误数要少。
而后面三个指标是跟系统资源有关的当某个资源出现瓶颈就会影响前面的业务指标比如线程池中的线程数量不足会影响吞吐量和响应时间但是线程数太多会耗费大量CPU也会影响吞吐量当内存不足时会触发频繁地GC耗费CPU最后也会反映到业务指标上来。
那如何监控这些指标呢Tomcat可以通过JMX将上述指标暴露出来的。JMXJava Management Extensions即Java管理扩展是一个为应用程序、设备、系统等植入监控管理功能的框架。JMX使用管理MBean来监控业务资源这些MBean在JMX MBean服务器上注册代表JVM中运行的应用程序或服务。每个MBean都有一个属性列表。JMX客户端可以连接到MBean Server来读写MBean的属性值。你可以通过下面这张图来理解一下JMX的工作原理
Tomcat定义了一系列MBean来对外暴露系统状态接下来我们来看看如何通过JConsole来监控这些指标。
通过JConsole监控Tomcat
首先我们需要开启JMX的远程监听端口具体来说就是设置若干JVM参数。我们可以在Tomcat的bin目录下新建一个名为setenv.sh的文件或者setenv.bat根据你的操作系统类型然后输入下面的内容
export JAVA_OPTS="${JAVA_OPTS} -Dcom.sun.management.jmxremote"
export JAVA_OPTS="${JAVA_OPTS} -Dcom.sun.management.jmxremote.port=9001"
export JAVA_OPTS="${JAVA_OPTS} -Djava.rmi.server.hostname=x.x.x.x"
export JAVA_OPTS="${JAVA_OPTS} -Dcom.sun.management.jmxremote.ssl=false"
export JAVA_OPTS="${JAVA_OPTS} -Dcom.sun.management.jmxremote.authenticate=false"
重启Tomcat这样JMX的监听端口9001就开启了接下来通过JConsole来连接这个端口。
jconsole x.x.x.x:9001
我们可以看到JConsole的主界面
前面我提到的需要监控的关键指标有吞吐量、响应时间、错误数、线程池、CPU以及JVM内存接下来我们就来看看怎么在JConsole上找到这些指标。
吞吐量、响应时间、错误数
在MBeans标签页下选择GlobalRequestProcessor这里有Tomcat请求处理的统计信息。你会看到Tomcat中的各种连接器展开“http-nio-8080”你会看到这个连接器上的统计信息其中maxTime表示最长的响应时间processingTime表示平均响应时间requestCount表示吞吐量errorCount就是错误数。
线程池
选择“线程”标签页可以看到当前Tomcat进程中有多少线程如下图所示
图的左下方是线程列表右边是线程的运行栈这些都是非常有用的信息。如果大量线程阻塞通过观察线程栈能看到线程阻塞在哪个函数有可能是I/O等待或者是死锁。
CPU
在主界面可以找到CPU使用率指标请注意这里的CPU使用率指的是Tomcat进程占用的CPU不是主机总的CPU使用率。
JVM内存
选择“内存”标签页你能看到Tomcat进程的JVM内存使用情况。
你还可以查看JVM各内存区域的使用情况大的层面分堆区和非堆区。堆区里有分为Eden、Survivor和Old。选择“VM Summary”标签可以看到虚拟机内的详细信息。
命令行查看Tomcat指标
极端情况下如果Web应用占用过多CPU或者内存又或者程序中发生了死锁导致Web应用对外没有响应监控系统上看不到数据这个时候需要我们登陆到目标机器通过命令行来查看各种指标。
1.首先我们通过ps命令找到Tomcat进程拿到进程ID。
2.接着查看进程状态的大致信息通过cat/proc/<pid>/status命令
3.监控进程的CPU和内存资源使用情况
4.查看Tomcat的网络连接比如Tomcat在8080端口上监听连接请求通过下面的命令查看连接列表
你还可以分别统计处在“已连接”状态和“TIME_WAIT”状态的连接数
5.通过ifstat来查看网络流量大致可以看出Tomcat当前的请求数和负载状况。
实战案例
在这个实战案例中我们会创建一个Web应用根据传入的参数latency来休眠相应的秒数目的是模拟当前的Web应用在访问下游服务时遇到的延迟。然后用JMeter来压测这个服务通过JConsole来观察Tomcat的各项指标分析和定位问题。
主要的步骤有:
1.创建一个Spring Boot程序加入下面代码所示的一个RestController
@RestController
public class DownStreamLatency {
@RequestMapping("/greeting/latency/{seconds}")
public Greeting greeting(@PathVariable long seconds) {
try {
Thread.sleep(seconds * 1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
Greeting greeting = new Greeting("Hello World!");
return greeting;
}
}
从上面的代码我们看到程序会读取URL传过来的seconds参数先休眠相应的秒数再返回请求。这样做的目的是客户端压测工具能够控制服务端的延迟。
为了方便观察Tomcat的线程数跟延迟之间的关系还需要加大Tomcat的最大线程数我们可以在application.properties文件中加入这样一行
server.tomcat.max-threads=1000server.tomcat.max-threads=1000
2.启动JMeter开始压测这里我们将压测的线程数设置为100
请你注意的是我们还需要将客户端的Timeout设置为1000毫秒这是因为JMeter的测试线程在收到响应之前不会发出下一次请求这就意味我们没法按照固定的吞吐量向服务端加压。而加了Timeout以后JMeter会有固定的吞吐量向Tomcat发送请求。
3.开启测试这里分三个阶段第一个阶段将服务端休眠时间设为2秒然后暂停一段时间。第二和第三阶段分别将休眠时间设置成4秒和6秒。
4.最后我们通过JConsole来观察结果
下面我们从线程数、内存和CPU这三个指标来分析Tomcat的性能问题。
首先看线程数在第一阶段时间之前线程数大概是40第一阶段压测开始后线程数增长到250。为什么是250呢这是因为JMeter每秒会发出100个请求每一个请求休眠2秒因此Tomcat需要200个工作线程来干活此外Tomcat还有一些其他线程用来处理网络通信和后台任务所以总数是250左右。第一阶段压测暂停后线程数又下降到40这是因为线程池会回收空闲线程。第二阶段测试开始后线程数涨到了420这是因为每个请求休眠了4秒同理我们看到第三阶段测试的线程数是620。
我们再来看CPU在三个阶段的测试中CPU的峰值始终比较稳定这是因为JMeter控制了总体的吞吐量因为服务端用来处理这些请求所需要消耗的CPU基本也是一样的。
各测试阶段的内存使用量略有增加,这是因为线程数增加了,创建线程也需要消耗内存。
从上面的测试结果我们可以得出一个结论对于一个Web应用来说下游服务的延迟越大Tomcat所需要的线程数越多但是CPU保持稳定。所以如果你在实际工作碰到线程数飙升但是CPU没有增加的情况这个时候你需要怀疑你的Web应用所依赖的下游服务是不是出了问题响应时间是否变长了。
本期精华
今天我们学习了Tomcat中的关键的性能指标以及如何监控这些指标主要有吞吐量、响应时间、错误数、线程池、CPU以及JVM内存。
在实际工作中我们需要通过观察这些指标来诊断系统遇到的性能问题找到性能瓶颈。如果我们监控到CPU上升这时我们可以看看吞吐量是不是也上升了如果是那说明正常如果不是的话可以看看GC的活动如果GC活动频繁并且内存居高不下基本可以断定是内存泄漏。
课后思考
请问工作中你如何监控Web应用的健康状态遇到性能问题的时候是如何做问题定位的呢
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,也欢迎你把你的课后思考和心得记录下来,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。

View File

@ -0,0 +1,99 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
36 Tomcat I_O和线程池的并发调优
上一期我们谈到了如何监控Tomcat的性能指标在这个基础上今天我们接着聊如何对Tomcat进行调优。
Tomcat的调优涉及I/O模型和线程池调优、JVM内存调优以及网络优化等今天我们来聊聊I/O模型和线程池调优由于Web应用程序跑在Tomcat的工作线程中因此Web应用对请求的处理时间也直接影响Tomcat整体的性能而Tomcat和Web应用在运行过程中所用到的资源都来自于操作系统因此调优需要将服务端看作是一个整体来考虑。
所谓的I/O调优指的是选择NIO、NIO.2还是APR而线程池调优指的是给Tomcat的线程池设置合适的参数使得Tomcat能够又快又好地处理请求。
I/O模型的选择
I/O调优实际上是连接器类型的选择一般情况下默认都是NIO在绝大多数情况下都是够用的除非你的Web应用用到了TLS加密传输而且对性能要求极高这个时候可以考虑APR因为APR通过OpenSSL来处理TLS握手和加/解密。OpenSSL本身用C语言实现它还对TLS通信做了优化所以性能比Java要高。
那你可能会问那什么时候考虑选择NIO.2我的建议是如果你的Tomcat跑在Windows平台上并且HTTP请求的数据量比较大可以考虑NIO.2这是因为Windows从操作系统层面实现了真正意义上的异步I/O如果传输的数据量比较大异步I/O的效果就能显现出来。
如果你的Tomcat跑在Linux平台上建议使用NIO这是因为Linux内核没有很完善地支持异步I/O模型因此JVM并没有采用原生的Linux异步I/O而是在应用层面通过epoll模拟了异步I/O模型只是Java NIO的使用者感觉不到而已。因此可以这样理解在Linux平台上Java NIO和Java NIO.2底层都是通过epoll来实现的但是Java NIO更加简单高效。
线程池调优
跟I/O模型紧密相关的是线程池线程池的调优就是设置合理的线程池参数。我们先来看看Tomcat线程池中有哪些关键参数
这里面最核心的就是如何确定maxThreads的值如果这个参数设置小了Tomcat会发生线程饥饿并且请求的处理会在队列中排队等待导致响应时间变长如果maxThreads参数值过大同样也会有问题因为服务器的CPU的核数有限线程数太多会导致线程在CPU上来回切换耗费大量的切换开销。
那maxThreads设置成多少才算是合适呢为了理解清楚这个问题我们先来看看什么是利特尔法则Littles Law
利特尔法则
系统中的请求数 = 请求的到达速率 × 每个请求处理时间
其实这个公式很好理解,我举个我们身边的例子:我们去超市购物结账需要排队,但是你是如何估算一个队列有多长呢?队列中如果每个人都买很多东西,那么结账的时间就越长,队列也会越长;同理,短时间一下有很多人来收银台结账,队列也会变长。因此队列的长度等于新人加入队列的频率乘以平均每个人处理的时间。
计算出了队列的长度,那么我们就创建相应数量的线程来处理请求,这样既能以最快的速度处理完所有请求,同时又没有额外的线程资源闲置和浪费。
假设一个单核服务器在接收请求:
如果每秒10个请求到达平均处理一个请求需要1秒那么服务器任何时候都有10个请求在处理即需要10个线程。
如果每秒10个请求到达平均处理一个请求需要2秒那么服务器在每个时刻都有20个请求在处理因此需要20个线程。
如果每秒10000个请求到达平均处理一个请求需要1秒那么服务器在每个时刻都有10000个请求在处理因此需要10000个线程。
因此可以总结出一个公式:
线程池大小 = 每秒请求数 × 平均请求处理时间
这是理想的情况也就是说线程一直在忙着干活没有被阻塞在I/O等待上。实际上任务在执行中线程不可避免会发生阻塞比如阻塞在I/O等待上等待数据库或者下游服务的数据返回虽然通过非阻塞I/O模型可以减少线程的等待但是数据在用户空间和内核空间拷贝过程中线程还是阻塞的。线程一阻塞就会让出CPU线程闲置下来就好像工作人员不可能24小时不间断地处理客户的请求解决办法就是增加工作人员的数量一个人去休息另一个人再顶上。对应到线程池就是增加线程数量因此I/O密集型应用需要设置更多的线程。
线程I/O时间与CPU时间
至此我们又得到一个线程池个数的计算公式,假设服务器是单核的:
线程池大小 = 线程I/O阻塞时间 + 线程CPU时间 / 线程CPU时间
其中线程I/O阻塞时间 + 线程CPU时间 = 平均请求处理时间
对比一下两个公式,你会发现,平均请求处理时间在两个公式里都出现了,这说明请求时间越长,需要更多的线程是毫无疑问的。
不同的是第一个公式是用每秒请求数来乘以请求处理时间而第二个公式用请求处理时间来除以线程CPU时间请注意CPU时间是小于请求处理时间的。
虽然这两个公式是从不同的角度来看待问题的,但都是理想情况,都有一定的前提条件。
请求处理时间越长需要的线程数越多但前提是CPU核数要足够如果一个CPU来支撑10000 TPS并发创建10000个线程显然不合理会造成大量线程上下文切换。
请求处理过程中I/O等待时间越长需要的线程数越多前提是CUP时间和I/O时间的比率要计算的足够准确。
请求进来的速率越快需要的线程数越多前提是CPU核数也要跟上。
实际场景下如何确定线程数
那么在实际情况下,线程池的个数如何确定呢?这是一个迭代的过程,先用上面两个公式大概算出理想的线程数,再反复压测调整,从而达到最优。
一般来说如果系统的TPS要求足够大用第一个公式算出来的线程数往往会比公式二算出来的要大。我建议选取这两个值中间更靠近公式二的值。也就是先设置一个较小的线程数然后进行压测当达到系统极限时错误数增加或者响应时间大幅增加再逐步加大线程数当增加到某个值再增加线程数也无济于事甚至TPS反而下降那这个值可以认为是最佳线程数。
线程池中其他的参数最好就用默认值能不改就不改除非在压测的过程发现了瓶颈。如果发现了问题就需要调整比如maxQueueSize如果大量任务来不及处理都堆积在maxQueueSize中会导致内存耗尽这个时候就需要给maxQueueSize设一个限制。当然这是一个比较极端的情况了。
再比如minSpareThreads参数默认是25个线程如果你发现系统在闲的时候用不到25个线程就可以调小一点如果系统在大部分时间都比较忙线程池中的线程总是远远多于25个这个时候你就可以把这个参数调大一点因为这样线程池就不需要反复地创建和销毁线程了。
本期精华
今天我们学习了I/O调优也就是如何选择连接器的类型以及在选择过程中有哪些需要注意的地方。
后面还聊到Tomcat线程池的各种参数其中最重要的参数是最大线程数maxThreads。理论上我们可以通过利特尔法则或者CPU时间与I/O时间的比率计算出一个理想值这个值只具有指导意义因为它受到各种资源的限制实际场景中我们需要在理想值的基础上进行压测来获得最佳线程数。
课后思考
其实调优很多时候都是在找系统瓶颈假如有个状况系统响应比较慢但CPU的用率不高内存有所增加通过分析Heap Dump发现大量请求堆积在线程池的队列中请问这种情况下应该怎么办呢
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,也欢迎你把你的课后思考和心得记录下来,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。

View File

@ -0,0 +1,171 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
37 Tomcat内存溢出的原因分析及调优
作为Java程序员我们几乎都会碰到java.lang.OutOfMemoryError异常但是你知道有哪些原因可能导致JVM抛出OutOfMemoryError异常吗
JVM在抛出java.lang.OutOfMemoryError时除了会打印出一行描述信息还会打印堆栈跟踪因此我们可以通过这些信息来找到导致异常的原因。在寻找原因前我们先来看看有哪些因素会导致OutOfMemoryError其中内存泄漏是导致OutOfMemoryError的一个比较常见的原因最后我们通过一个实战案例来定位内存泄漏。
内存溢出场景及方案
java.lang.OutOfMemoryError: Java heap space
JVM无法在堆中分配对象时会抛出这个异常导致这个异常的原因可能有三种
内存泄漏。Java应用程序一直持有Java对象的引用导致对象无法被GC回收比如对象池和内存池中的对象无法被GC回收。
配置问题。有可能是我们通过JVM参数指定的堆大小或者未指定的默认大小对于应用程序来说是不够的。解决办法是通过JVM参数加大堆的大小。
finalize方法的过度使用。如果我们想在Java类实例被GC之前执行一些逻辑比如清理对象持有的资源可以在Java类中定义finalize方法这样JVM GC不会立即回收这些对象实例而是将对象实例添加到一个叫“java.lang.ref.Finalizer.ReferenceQueue”的队列中执行对象的finalize方法之后才会回收这些对象。Finalizer线程会和主线程竞争CPU资源但由于优先级低所以处理速度跟不上主线程创建对象的速度因此ReferenceQueue队列中的对象就越来越多最终会抛出OutOfMemoryError。解决办法是尽量不要给Java类定义finalize方法。
java.lang.OutOfMemoryError: GC overhead limit exceeded
出现这种OutOfMemoryError的原因是垃圾收集器一直在运行但是GC效率很低比如Java进程花费超过98的CPU时间来进行一次GC但是回收的内存少于2的JVM堆并且连续5次GC都是这种情况就会抛出OutOfMemoryError。
解决办法是查看GC日志或者生成Heap Dump确认一下是不是内存泄漏如果不是内存泄漏可以考虑增加Java堆的大小。当然你还可以通过参数配置来告诉JVM无论如何也不要抛出这个异常方法是配置-XX:-UseGCOverheadLimit但是我并不推荐这么做因为这只是延迟了OutOfMemoryError的出现。
java.lang.OutOfMemoryError: Requested array size exceeds VM limit
从错误消息我们也能猜到抛出这种异常的原因是“请求的数组大小超过JVM限制”应用程序尝试分配一个超大的数组。比如应用程序尝试分配512MB的数组但最大堆大小为256MB则将抛出OutOfMemoryError并且请求的数组大小超过VM限制。
通常这也是一个配置问题JVM堆太小或者是应用程序的一个Bug比如程序错误地计算了数组的大小导致尝试创建一个大小为1GB的数组。
java.lang.OutOfMemoryError: MetaSpace
如果JVM的元空间用尽则会抛出这个异常。我们知道JVM元空间的内存在本地内存中分配但是它的大小受参数MaxMetaSpaceSize的限制。当元空间大小超过MaxMetaSpaceSize时JVM将抛出带有MetaSpace字样的OutOfMemoryError。解决办法是加大MaxMetaSpaceSize参数的值。
java.lang.OutOfMemoryError: Request size bytes for reason. Out of swap space
当本地堆内存分配失败或者本地内存快要耗尽时Java HotSpot VM代码会抛出这个异常VM会触发“致命错误处理机制”它会生成“致命错误”日志文件其中包含崩溃时线程、进程和操作系统的有用信息。如果碰到此类型的OutOfMemoryError你需要根据JVM抛出的错误信息来进行诊断或者使用操作系统提供的DTrace工具来跟踪系统调用看看是什么样的程序代码在不断地分配本地内存。
java.lang.OutOfMemoryError: Unable to create native threads
抛出这个异常的过程大概是这样的:
Java程序向JVM请求创建一个新的Java线程。
JVM本地代码Native Code代理该请求通过调用操作系统API去创建一个操作系统级别的线程Native Thread。
操作系统尝试创建一个新的Native Thread需要同时分配一些内存给该线程每一个Native Thread都有一个线程栈线程栈的大小由JVM参数-Xss决定。
由于各种原因,操作系统创建新的线程可能会失败,下面会详细谈到。
JVM抛出“java.lang.OutOfMemoryError: Unable to create new native thread”错误。
因此关键在于第四步线程创建失败JVM就会抛出OutOfMemoryError那具体有哪些因素会导致线程创建失败呢
1.内存大小限制我前面提到Java创建一个线程需要消耗一定的栈空间并通过-Xss参数指定。请你注意的是栈空间如果过小可能会导致StackOverflowError尤其是在递归调用的情况下但是栈空间过大会占用过多内存而对于一个32位Java应用来说用户进程空间是4GB内核占用1GB那么用户空间就剩下3GB因此它能创建的线程数大致可以通过这个公式算出来
Max memory3GB = [-Xmx] + [-XX:MaxMetaSpaceSize] + number_of_threads * [-Xss]
不过对于64位的应用由于虚拟进程空间近乎无限大因此不会因为线程栈过大而耗尽虚拟地址空间。但是请你注意64位的Java进程能分配的最大内存数仍然受物理内存大小的限制。
2. ulimit限制在Linux下执行ulimit -a你会看到ulimit对各种资源的限制。
其中的“max user processes”就是一个进程能创建的最大线程数我们可以修改这个参数
3. 参数sys.kernel.threads-max限制。这个参数限制操作系统全局的线程数通过下面的命令可以查看它的值。
这表明当前系统能创建的总的线程是63752。当然我们调整这个参数具体办法是
在/etc/sysctl.conf配置文件中加入sys.kernel.threads-max = 999999。
4. 参数sys.kernel.pid_max限制这个参数表示系统全局的PID号数值的限制每一个线程都有IDID的值超过这个数线程就会创建失败。跟sys.kernel.threads-max参数一样我们也可以将sys.kernel.pid_max调大方法是在/etc/sysctl.conf配置文件中加入sys.kernel.pid_max = 999999。
对于线程创建失败的OutOfMemoryError除了调整各种参数我们还需要从程序本身找找原因看看是否真的需要这么多线程有可能是程序的Bug导致创建过多的线程。
内存泄漏定位实战
我们先创建一个Web应用不断地new新对象放到一个List中来模拟Web应用中的内存泄漏。然后通过各种工具来观察GC的行为最后通过生成Heap Dump来找到泄漏点。
内存泄漏模拟程序比较简单创建一个Spring Boot应用定义如下所示的类
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.util.LinkedList;
import java.util.List;
@Component
public class MemLeaker {
private List<Object> objs = new LinkedList<>();
@Scheduled(fixedRate = 1000)
public void run() {
for (int i = 0; i < 50000; i++) {
objs.add(new Object());
}
}
}
这个程序做的事情就是每隔1秒向一个List中添加50000个对象。接下来运行并通过工具观察它的GC行为
1.运行程序并打开verbosegc将GC的日志输出到gc.log文件中。
java -verbose:gc -Xloggc:gc.log -XX:+PrintGCDetails -jar mem-0.0.1-SNAPSHOT.jar
2.使用jstat命令观察GC的过程
jstat -gc 94223 2000 1000
94223是程序的进程ID2000表示每隔2秒执行一次1000表示持续执行1000次。下面是命令的输出
其中每一列的含义是:
S0C第一个Survivor区总的大小
S1C第二个Survivor区总的大小
S0U第一个Survivor区已使用内存的大小
S1U第二个Survivor区已使用内存的大小。
后面的列相信从名字你也能猜出是什么意思了其中E代表EdenO代表OldM代表MetadataYGC表示Minor GC的总时间YGCT表示Minor GC的次数FGC表示Full GC。
通过这个工具你能大概看到各个内存区域的大小、已经GC的次数和所花的时间。verbosegc参数对程序的影响比较小因此很适合在生产环境现场使用。
3.通过GCViewer工具查看GC日志用GCViewer打开第一步产生的gc.log会看到这样的图
图中红色的线表示年老代占用的内存你会看到它一直在增加而黑色的竖线表示一次Full GC。你可以看到后期JVM在频繁地Full GC但是年老代的内存并没有降下来这是典型的内存泄漏的特征。
除了内存泄漏我们还可以通过GCViewer来观察Minor GC和Full GC的频次已及每次的内存回收量。
4.为了找到内存泄漏点我们通过jmap工具生成Heap Dump
jmap -dump:live,format=b,file=94223.bin 94223
5.用Eclipse Memory Analyzer打开Dump文件通过内存泄漏分析得到这样一个分析报告
从报告中可以看到JVM内存中有一个长度为4000万的List至此我们也就找到了泄漏点。
本期精华
今天我讲解了常见的OutOfMemoryError的场景以及解决办法我们在实际工作中要根据具体的错误信息去分析背后的原因尤其是Java堆内存不够时需要生成Heap Dump来分析看是不是内存泄漏排除内存泄漏之后我们再调整各种JVM参数否则根本的问题原因没有解决的话调整JVM参数也无济于事。
课后思考
请你分享一下平时在工作中遇到了什么样的OutOfMemoryError以及你是怎么解决的。
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,也欢迎你把你的课后思考和心得记录下来,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。

View File

@ -0,0 +1,122 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
38 Tomcat拒绝连接原因分析及网络优化
专栏上一期我们分析各种JVM OutOfMemory错误的原因和解决办法今天我们来看看网络通信中可能会碰到的各种错误。网络通信方面的错误和异常也是我们在实际工作中经常碰到的需要理解异常背后的原理才能更快更精准地定位问题从而找到解决办法。
下面我会先讲讲Java Socket网络编程常见的异常有哪些然后通过一个实验来重现其中的Connection reset异常并且通过配置Tomcat的参数来解决这个问题。
常见异常
java.net.SocketTimeoutException
指超时错误。超时分为连接超时和读取超时连接超时是指在调用Socket.connect方法的时候超时而读取超时是调用Socket.read方法时超时。请你注意的是连接超时往往是由于网络不稳定造成的但是读取超时不一定是网络延迟造成的很有可能是下游服务的响应时间过长。
java.net.BindException: Address already in use: JVM_Bind
指端口被占用。当服务器端调用new ServerSocket(port)或者Socket.bind函数时如果端口已经被占用就会抛出这个异常。我们可以用netstat an命令来查看端口被谁占用了换一个没有被占用的端口就能解决。
java.net.ConnectException: Connection refused: connect
指连接被拒绝。当客户端调用new Socket(ip, port)或者Socket.connect函数时可能会抛出这个异常。原因是指定IP地址的机器没有找到或者是机器存在但这个机器上没有开启指定的监听端口。
解决办法是从客户端机器ping一下服务端IP假如ping不通可以看看IP是不是写错了假如能ping通需要确认服务端的服务是不是崩溃了。
java.net.SocketException: Socket is closed
指连接已关闭。出现这个异常的原因是通信的一方主动关闭了Socket连接调用了Socket的close方法接着又对Socket连接进行了读写操作这时操作系统会报“Socket连接已关闭”的错误。
java.net.SocketException: Connection reset/Connect reset by peer: Socket write error
指连接被重置。这里有两种情况分别对应两种错误第一种情况是通信的一方已经将Socket关闭可能是主动关闭或者是因为异常退出这时如果通信的另一方还在写数据就会触发这个异常Connect reset by peer如果对方还在尝试从TCP连接中读数据则会抛出Connection reset异常。
为了避免这些异常发生,在编写网络通信程序时要确保:
程序退出前要主动关闭所有的网络连接。
检测通信的另一方的关闭连接操作,当发现另一方关闭连接后自己也要关闭该连接。
java.net.SocketException: Broken pipe
指通信管道已坏。发生这个异常的场景是通信的一方在收到“Connect reset by peer: Socket write error”后如果再继续写数据则会抛出Broken pipe异常解决方法同上。
java.net.SocketException: Too many open files
指进程打开文件句柄数超过限制。当并发用户数比较大时服务器可能会报这个异常。这是因为每创建一个Socket连接就需要一个文件句柄此外服务端程序在处理请求时可能也需要打开一些文件。
你可以通过lsof -p pid命令查看进程打开了哪些文件是不是有资源泄露也就是说进程打开的这些文件本应该被关闭但由于程序的Bug而没有被关闭。
如果没有资源泄露可以通过设置增加最大文件句柄数。具体方法是通过ulimit -a来查看系统目前资源限制通过ulimit -n 10240修改最大文件数。
Tomcat网络参数
接下来我们看看Tomcat两个比较关键的参数maxConnections和acceptCount。在解释这个参数之前先简单回顾下TCP连接的建立过程客户端向服务端发送SYN包服务端回复SYNACK同时将这个处于SYN_RECV状态的连接保存到半连接队列。客户端返回ACK包完成三次握手服务端将ESTABLISHED状态的连接移入accept队列等待应用程序Tomcat调用accept方法将连接取走。这里涉及两个队列
半连接队列保存SYN_RECV状态的连接。队列长度由net.ipv4.tcp_max_syn_backlog设置。
accept队列保存ESTABLISHED状态的连接。队列长度为min(net.core.somaxconnbacklog)。其中backlog是我们创建ServerSocket时指定的参数最终会传递给listen方法
int listen(int sockfd, int backlog);
如果我们设置的backlog大于net.core.somaxconnaccept队列的长度将被设置为net.core.somaxconn而这个backlog参数就是Tomcat中的acceptCount参数默认值是100但请注意net.core.somaxconn的默认值是128。你可以想象在高并发情况下当Tomcat来不及处理新的连接时这些连接都被堆积在accept队列中而acceptCount参数可以控制accept队列的长度超过这个长度时内核会向客户端发送RST这样客户端会触发上文提到的“Connection reset”异常。
而Tomcat中的maxConnections是指Tomcat在任意时刻接收和处理的最大连接数。当Tomcat接收的连接数达到maxConnections时Acceptor线程不会再从accept队列中取走连接这时accept队列中的连接会越积越多。
maxConnections的默认值与连接器类型有关NIO的默认值是10000APR默认是8192。
所以你会发现Tomcat的最大并发连接数等于maxConnections + acceptCount。如果acceptCount设置得过大请求等待时间会比较长如果acceptCount设置过小高并发情况下客户端会立即触发Connection reset异常。
Tomcat网络调优实战
接下来我们通过一个直观的例子来加深对上面两个参数的理解。我们先重现流量高峰时accept队列堆积的情况这样会导致客户端触发“Connection reset”异常然后通过调整参数解决这个问题。主要步骤有
1.下载和安装压测工具JMeter。解压后打开我们需要创建一个测试计划、一个线程组、一个请求和如下图所示。
测试计划:
线程组线程数这里设置为1000模拟大流量
请求请求的路径是Tomcat自带的例子程序
2.启动Tomcat。
3.开启JMeter测试在View Results Tree中会看到大量失败的请求请求的响应里有“Connection reset”异常也就是前面提到的当accept队列溢出时服务端的内核发送了RST给客户端使得客户端抛出了这个异常。
4.修改内核参数,在/etc/sysctl.conf中增加一行net.core.somaxconn=2048然后执行命令sysctl -p。
5.修改Tomcat参数acceptCount为2048重启Tomcat。
6.再次启动JMeter测试这一次所有的请求会成功也看不到异常了。我们可以通过下面的命令看到系统中ESTABLISHED的连接数增大了这是因为我们加大了accept队列的长度。
本期精华
在Socket网络通信过程中我们不可避免地会碰到各种Java异常了解这些异常产生的原因非常关键通过这些信息我们大概知道问题出在哪里如果一时找不到问题代码我们还可以通过网络抓包工具来分析数据包。
在这个基础上我们还分析了Tomcat中两个比较重要的参数acceptCount和maxConnections。acceptCount用来控制内核的TCP连接队列长度maxConnections用于控制Tomcat层面的最大连接数。在实战环节我们通过调整acceptCount和相关的内核参数somaxconn增加了系统的并发度。
课后思考
在上面的实验中我们通过netstat命令发现有大量的TCP连接处在TIME_WAIT状态请问这是为什么它可能会带来什么样的问题呢
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,也欢迎你把你的课后思考和心得记录下来,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。

View File

@ -0,0 +1,134 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
39 Tomcat进程占用CPU过高怎么办
在性能优化这个主题里前面我们聊过了Tomcat的内存问题和网络相关的问题接下来我们看一下CPU的问题。CPU资源经常会成为系统性能的一个瓶颈这其中的原因是多方面的可能是内存泄露导致频繁GC进而引起CPU使用率过高又可能是代码中的Bug创建了大量的线程导致CPU上下文切换开销。
今天我们就来聊聊Tomcat进程的CPU使用率过高怎么办以及怎样一步一步找到问题的根因。
“Java进程CPU使用率高”的解决思路是什么
通常我们所说的CPU使用率过高这里面其实隐含着一个用来比较高与低的基准值比如JVM在峰值负载下的平均CPU利用率为40如果CPU使用率飙到80%就可以被认为是不正常的。
典型的JVM进程包含多个Java线程其中一些在等待工作另一些则正在执行任务。在单个Java程序的情况下线程数可以非常低而对于处理大量并发事务的互联网后台来说线程数可能会比较高。
对于CPU的问题最重要的是要找到是哪些线程在消耗CPU通过线程栈定位到问题代码如果没有找到个别线程的CPU使用率特别高我们要怀疑到是不是线程上下文切换导致了CPU使用率过高。下面我们通过一个实例来学习CPU问题定位的过程。
定位高CPU使用率的线程和代码
1.写一个模拟程序来模拟CPU使用率过高的问题这个程序会在线程池中创建4096个线程。代码如下
@SpringBootApplication
@EnableScheduling
public class DemoApplication {
//创建线程池其中有4096个线程。
private ExecutorService executor = Executors.newFixedThreadPool(4096);
//全局变量,访问它需要加锁。
private int count;
//以固定的速率向线程池中加入任务
@Scheduled(fixedRate = 10)
public void lockContention() {
IntStream.range(0, 1000000)
.forEach(i -> executor.submit(this::incrementSync));
}
//具体任务就是将count数加一
private synchronized void incrementSync() {
count = (count + 1) % 10000000;
}
public static void main(String[] args) {
SpringApplication.run(DemoApplication.class, args);
}
}
2.在Linux环境下启动程序
java -Xss256k -jar demo-0.0.1-SNAPSHOT.jar
请注意这里我将线程栈大小指定为256KB。对于测试程序来说操作系统默认值8192KB过大因为我们需要创建4096个线程。
3.使用top命令我们看到Java进程的CPU使用率达到了262.3%注意到进程ID是4361。
4.接着我们用更精细化的top命令查看这个Java进程中各线程使用CPU的情况
#top -H -p 4361
从图上我们可以看到有个叫“scheduling-1”的线程占用了较多的CPU达到了42.5%。因此下一步我们要找出这个线程在做什么事情。
5.为了找出线程在做什么事情我们需要用jstack命令生成线程快照具体方法是
jstack 4361
jstack的输出比较大你可以将输出写入文件
jstack 4361 > 4361.log
然后我们打开4361.log定位到第4步中找到的名为“scheduling-1”的线程发现它的线程栈如下
从线程栈中我们看到了AbstractExecutorService.submit这个函数调用说明它是Spring Boot启动的周期性任务线程向线程池中提交任务这个线程消耗了大量CPU。
进一步分析上下文切换开销
一般来说通过上面的过程我们就能定位到大量消耗CPU的线程以及有问题的代码比如死循环。但是对于这个实例的问题你是否发现这样一个情况Java进程占用的CPU是262.3% 而“scheduling-1”线程只占用了42.5%的CPU那还有将近220%的CPU被谁占用了呢
不知道你注意到没有我们在第4步用top -H -p 4361命令看到的线程列表中还有许多名为“pool-1-thread-x”的线程它们单个的CPU使用率不高但是似乎数量比较多。你可能已经猜到这些就是线程池中干活的线程。那剩下的220%的CPU是不是被这些线程消耗了呢
要弄清楚这个问题我们还需要看jstack的输出结果主要是看这些线程池中的线程是不是真的在干活还是在“休息”呢
通过上面的图我们发现这些“pool-1-thread-x”线程基本都处于WAITING的状态那什么是WAITING状态呢或者说Java线程都有哪些状态呢你可以通过下面的图来理解一下
从图上我们看到“Blocking”和“Waiting”是两个不同的状态我们要注意它们的区别
Blocking指的是一个线程因为等待临界区的锁Lock或者synchronized关键字而被阻塞的状态请你注意的是处于这个状态的线程还没有拿到锁。
Waiting指的是一个线程拿到了锁但是需要等待其他线程执行某些操作。比如调用了Object.wait、Thread.join或者LockSupport.park方法时进入Waiting状态。前提是这个线程已经拿到锁了并且在进入Waiting状态前操作系统层面会自动释放锁当等待条件满足外部调用了Object.notify或者LockSupport.unpark方法线程会重新竞争锁成功获得锁后才能进入到Runnable状态继续执行。
回到我们的“pool-1-thread-x”线程这些线程都处在“Waiting”状态从线程栈我们看到这些线程“等待”在getTask方法调用上线程尝试从线程池的队列中取任务但是队列为空所以通过LockSupport.park调用进到了“Waiting”状态。那“pool-1-thread-x”线程有多少个呢通过下面这个命令来统计一下结果是4096正好跟线程池中的线程数相等。
你可能好奇了那剩下的220%的CPU到底被谁消耗了呢分析到这里我们应该怀疑CPU的上下文切换开销了因为我们看到Java进程中的线程数比较多。下面我们通过vmstat命令来查看一下操作系统层面的线程上下文切换活动
如果你还不太熟悉vmstat可以在这里学习如何使用vmstat和查看结果。其中cs那一栏表示线程上下文切换次数in表示CPU中断次数我们发现这两个数字非常高基本证实了我们的猜测线程上下文切切换消耗了大量CPU。那么问题来了具体是哪个进程导致的呢
我们停止Spring Boot测试程序再次运行vmstat命令会看到in和cs都大幅下降了这样就证实了引起线程上下文切换开销的Java进程正是4361。
本期精华
当我们遇到CPU过高的问题时首先要定位是哪个进程的导致的之后可以通过top -H -p pid命令定位到具体的线程。其次还要通jstack查看线程的状态看看线程的个数或者线程的状态如果线程数过多可以怀疑是线程上下文切换的开销我们可以通过vmstat和pidstat这两个工具进行确认。
课后思考
哪些情况可能导致程序中的线程数失控,产生大量线程呢?
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,也欢迎你把你的课后思考和心得记录下来,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。

View File

@ -0,0 +1,182 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
40 谈谈Jetty性能调优的思路
关于Tomcat的性能调优前面我主要谈了工作经常会遇到的有关JVM GC、监控、I/O和线程池以及CPU的问题定位和调优今天我们来看看Jetty有哪些调优的思路。
关于Jetty的性能调优官网上给出了一些很好的建议分为操作系统层面和Jetty本身的调优我们将分别来看一看它们具体是怎么做的最后再通过一个实战案例来学习一下如何确定Jetty的最佳线程数。
操作系统层面调优
对于Linux操作系统调优来说我们需要加大一些默认的限制值这些参数主要可以在/etc/security/limits.conf中或通过sysctl命令进行配置其实这些配置对于Tomcat来说也是适用的下面我来详细介绍一下这些参数。
TCP缓冲区大小
TCP的发送和接收缓冲区最好加大到16MB可以通过下面的命令配置
sysctl -w net.core.rmem_max = 16777216
sysctl -w net.core.wmem_max = 16777216
sysctl -w net.ipv4.tcp_rmem =“4096 87380 16777216”
sysctl -w net.ipv4.tcp_wmem =“4096 16384 16777216”
TCP队列大小
net.core.somaxconn控制TCP连接队列的大小默认值为128在高并发情况下明显不够用会出现拒绝连接的错误。但是这个值也不能调得过高因为过多积压的TCP连接会消耗服务端的资源并且会造成请求处理的延迟给用户带来不好的体验。因此我建议适当调大推荐设置为4096。
sysctl -w net.core.somaxconn = 4096
net.core.netdev_max_backlog用来控制Java程序传入数据包队列的大小可以适当调大。
sysctl -w net.core.netdev_max_backlog = 16384
sysctl -w net.ipv4.tcp_max_syn_backlog = 8192
sysctl -w net.ipv4.tcp_syncookies = 1
端口
如果Web应用程序作为客户端向远程服务器建立了很多TCP连接可能会出现TCP端口不足的情况。因此最好增加使用的端口范围并允许在TIME_WAIT中重用套接字
sysctl -w net.ipv4.ip_local_port_range =“1024 65535”
sysctl -w net.ipv4.tcp_tw_recycle = 1
文件句柄数
高负载服务器的文件句柄数很容易耗尽,这是因为系统默认值通常比较低,我们可以在/etc/security/limits.conf中为特定用户增加文件句柄数
用户名 hard nofile 40000
用户名 soft nofile 40000
拥塞控制
Linux内核支持可插拔的拥塞控制算法如果要获取内核可用的拥塞控制算法列表可以通过下面的命令
sysctl net.ipv4.tcp_available_congestion_control
这里我推荐将拥塞控制算法设置为cubic
sysctl -w net.ipv4.tcp_congestion_control = cubic
Jetty本身的调优
Jetty本身的调优主要是设置不同类型的线程的数量包括Acceptor和Thread Pool。
Acceptors
Acceptor的个数accepts应该设置为大于等于1并且小于等于CPU核数。
Thread Pool
限制Jetty的任务队列非常重要。默认情况下队列是无限的因此如果在高负载下超过Web应用的处理能力Jetty将在队列上积压大量待处理的请求。并且即使负载高峰过去了Jetty也不能正常响应新的请求这是因为仍然有很多请求在队列等着被处理。
因此对于一个高可靠性的系统我们应该通过使用有界队列立即拒绝过多的请求也叫快速失败。那队列的长度设置成多大呢应该根据Web应用的处理速度而定。比如如果Web应用每秒可以处理100个请求当负载高峰到来我们允许一个请求可以在队列积压60秒那么我们就可以把队列长度设置为60 × 100 = 6000。如果设置得太低Jetty将很快拒绝请求无法处理正常的高峰负载以下是配置示例
<Configure id="Server" class="org.eclipse.jetty.server.Server">
<Set name="ThreadPool">
<New class="org.eclipse.jetty.util.thread.QueuedThreadPool">
<!-- specify a bounded queue -->
<Arg>
<New class="java.util.concurrent.ArrayBlockingQueue">
<Arg type="int">6000</Arg>
</New>
</Arg>
<Set name="minThreads">10</Set>
<Set name="maxThreads">200</Set>
<Set name="detailedDump">false</Set>
</New>
</Set>
</Configure>
那如何配置Jetty的线程池中的线程数呢跟Tomcat一样你可以根据实际压测如果I/O越密集线程阻塞越严重那么线程数就可以配置多一些。通常情况增加线程数需要更多的内存因此内存的最大值也要跟着调整所以一般来说Jetty的最大线程数应该在50到500之间。
Jetty性能测试
接下来我们通过一个实验来测试一下Jetty的性能。我们可以在这里下载Jetty的JAR包。
第二步我们创建一个Handler这个Handler用来向客户端返回“Hello World”并实现一个main方法根据传入的参数创建相应数量的线程池。
public class HelloWorld extends AbstractHandler {
@Override
public void handle(String target, Request baseRequest, HttpServletRequest request, HttpServletResponse response) throws IOException, ServletException {
response.setContentType("text/html; charset=utf-8");
response.setStatus(HttpServletResponse.SC_OK);
response.getWriter().println("<h1>Hello World</h1>");
baseRequest.setHandled(true);
}
public static void main(String[] args) throws Exception {
//根据传入的参数控制线程池中最大线程数的大小
int maxThreads = Integer.parseInt(args[0]);
System.out.println("maxThreads:" + maxThreads);
//创建线程池
QueuedThreadPool threadPool = new QueuedThreadPool();
threadPool.setMaxThreads(maxThreads);
Server server = new Server(threadPool);
ServerConnector http = new ServerConnector(server,
new HttpConnectionFactory(new HttpConfiguration()));
http.setPort(8000);
server.addConnector(http);
server.start();
server.join();
}
}
第三步我们编译这个Handler得到HelloWorld.class。
javac -cp jetty.jar HelloWorld.java
第四步启动Jetty server并且指定最大线程数为4。
java -cp .:jetty.jar HelloWorld 4
第五步启动压测工具Apache Bench。关于Apache Bench的使用你可以参考这里。
ab -n 200000 -c 100 http://localhost:8000/
上面命令的意思是向Jetty server发出20万个请求开启100个线程同时发送。
经过多次压测测试结果稳定以后在Linux 4核机器上得到的结果是这样的
从上面的测试结果我们可以看到20万个请求在9.99秒内处理完成RPS达到了20020。 不知道你是否好奇为什么我把最大线程数设置为4呢是不是有点小
别着急接下来我们就试着逐步加大最大线程数直到找到最佳值。下面这个表格显示了在其他条件不变的情况下只调整线程数对RPS的影响。
我们发现一个有意思的现象线程数从4增加到6RPS确实增加了。但是线程数从6开始继续增加RPS不但没有跟着上升反而下降了而且线程数越多RPS越低。
发生这个现象的原因是测试机器的CPU只有4核而我们测试的程序做得事情比较简单没有I/O阻塞属于CPU密集型程序。对于这种程序最大线程数可以设置为比CPU核心稍微大一点点。那具体设置成多少是最佳值呢我们需要根据实验里的步骤反复测试。你可以看到在我们这个实验中当最大线程数为6也就CPU核数的1.5倍时,性能达到最佳。
本期精华
今天我们首先学习了Jetty调优的基本思路主要分为操作系统级别的调优和Jetty本身的调优其中操作系统级别也适用于Tomcat。接着我们通过一个实例来寻找Jetty的最佳线程数在测试中我们发现对于CPU密集型应用将最大线程数设置CPU核数的1.5倍是最佳的。因此,在我们的实际工作中,切勿将线程池直接设置得很大,因为程序所需要的线程数可能会比我们想象的要小。
课后思考
我在今天文章前面提到Jetty的最大线程数应该在50到500之间。但是我们的实验中测试发现最大线程数为6时最佳这是不是矛盾了
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,也欢迎你把你的课后思考和心得记录下来,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。

View File

@ -0,0 +1,80 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
41 热点问题答疑4 Tomcat和Jetty有哪些不同
作为专栏最后一个模块的答疑文章我想是时候总结一下Tomcat和Jetty的区别了。专栏里也有同学给我留言询问有关Tomcat和Jetty在系统选型时需要考虑的地方今天我也会通过一个实战案例来比较一下Tomcat和Jetty在实际场景下的表现帮你在做选型时有更深的理解。
我先来概括一下Tomcat和Jetty两者最大的区别。大体来说Tomcat的核心竞争力是成熟稳定因为它经过了多年的市场考验应用也相当广泛对于比较复杂的企业级应用支持得更加全面。也因为如此Tomcat在整体结构上比Jetty更加复杂功能扩展方面可能不如Jetty那么方便。
而Jetty比较年轻设计上更加简洁小巧配置也比较简单功能也支持方便地扩展和裁剪比如我们可以把Jetty的SessionHandler去掉以节省内存资源因此Jetty还可以运行在小型的嵌入式设备中比如手机和机顶盒。当然我们也可以自己开发一个Handler加入Handler链中用来扩展Jetty的功能。值得一提的是Hadoop和Solr都嵌入了Jetty作为Web服务器。
从设计的角度来看Tomcat的架构基于一种多级容器的模式这些容器组件具有父子关系所有组件依附于这个骨架而且这个骨架是不变的我们在扩展Tomcat的功能时也需要基于这个骨架因此Tomcat在设计上相对来说比较复杂。当然Tomcat也提供了较好的扩展机制比如我们可以自定义一个Valve但相对来说学习成本还是比较大的。而Jetty采用Handler责任链模式。由于Handler之间的关系比较松散Jetty提供HandlerCollection可以帮助开发者方便地构建一个Handler链同时也提供了ScopeHandler帮助开发者控制Handler链的访问顺序。关于这部分内容你可以回忆一下专栏里讲的回溯方式的责任链模式。
说了一堆理论你可能觉得还是有点抽象接下来我们通过一个实例来压测一下Tomcat和Jetty看看在同等流量压力下Tomcat和Jetty分别表现如何。需要说明的是通常我们从吞吐量、延迟和错误率这三个方面来比较结果。
测试的计划是这样的我们还是用专栏第36期中的Spring Boot应用程序。首先用Spring Boot默认的Tomcat作为内嵌式Web容器经过一轮压测后将内嵌式的Web容器换成Jetty再做一轮测试然后比较结果。为了方便观察各种指标我在本地开发机器上做这个实验。
我们会在每个请求的处理过程中休眠1秒适当地模拟Web应用的I/O等待时间。JMeter客户端的线程数为100压测持续10分钟。在JMeter中创建一个Summary Report在这个页面上可以看到各种统计指标。
第一步压测Tomcat。启动Spring Boot程序和JMeter持续10分钟以下是测试结果结果分为两部分
吞吐量、延迟和错误率
资源使用情况
第二步我们将Spring Boot的Web容器替换成Jetty具体步骤是在pom.xml文件中的spring-boot-starter-web依赖修改下面这样
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<exclusions>
<exclusion>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-tomcat</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jetty</artifactId>
</dependency>
编译打包启动Spring Boot再启动JMeter压测以下是测试结果
吞吐量、延迟和错误率
资源使用情况
下面我们通过一个表格来对比Tomcat和Jetty
从表格中的数据我们可以看到:
Jetty在吞吐量和响应速度方面稍有优势并且Jetty消耗的线程和内存资源明显比Tomcat要少这也恰好说明了Jetty在设计上更加小巧和轻量级的特点。
但是Jetty有2.45%的错误率而Tomcat没有任何错误并且我经过多次测试都是这个结果。因此我们可以认为Tomcat比Jetty更加成熟和稳定。
当然由于测试场景的限制以上数据并不能完全反映Tomcat和Jetty的真实能力。但是它可以在我们做选型的时候提供一些参考如果系统的目标是资源消耗尽量少并且对稳定性要求没有那么高可以选择轻量级的Jetty如果你的系统是比较关键的企业级应用建议还是选择Tomcat比较稳妥。
最后用一句话总结Tomcat和Jetty的区别Tomcat好比是一位工作多年比较成熟的工程师轻易不会出错、不会掉链子但是他有自己的想法不会轻易做出改变。而Jetty更像是一位年轻的后起之秀脑子转得很快可塑性也很强但有时候也会犯一点小错误。
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,也欢迎你把你的课后思考和心得记录下来,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。

View File

@ -0,0 +1,35 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
特别放送 如何持续保持对学习的兴趣?
你好我是李号双。今天我们抛开技术本身的内容来聊聊专栏或者一门新技术的学习方法我也分享一下自己是如何啃下Tomcat和Jetty源码的。
专栏如今已经更新完了五个模块我们学习了Tomcat和Jetty的整体架构、连接器、容器和通用组件这些内容可以说是Tomcat和Jetty的设计核心。在日常工作的使用中我们使用到了Tomcat和Jetty提供的功能我希望通过学习专栏还能帮你了解这些功能是如何实现的以及Tomcat和Jetty在设计时都考虑了哪些地方。
所以在学习专栏时你不妨思考这样一个问题假如让你来设计并实现一个Web容器你会怎么做呢如何合理设计顶层模块如何考虑方方面面的需求比如最基本的功能需求是加载和运行Web程序最重要的非功能需求是高性能、高并发。你可以顺着这两条线先思考下你会怎么做然后再回过头来看看Tomcat和Jetty是如何做到的。这样的学习方法其实就在有意识地训练自己独立设计一个系统的能力不管是对于学习这个专栏还是其他技术带着问题再去学习都会有所帮助。
说完关于专栏的学习方法下面我必须要鼓励一下坚持学习到现在的你。专栏从第三模块开始开始讲解连接器、容器和通用组件的设计和原理有些内容可能比较偏向底层确实难度比较大如果对底层源码不熟悉或者不感兴趣学习起来会有些痛苦。但是我之所以设计了这部分内容就是希望能够揭开Tomcat和Jetty的内部细节因为任何一个优秀的中间件之所以可以让用户使用比较容易其内部一定都是很复杂的。这也从侧面传递出一个信号美好的东西都是有代价的需要也值得我们去付出时间和精力。
我和你一样我们都身处IT行业这个行业技术更新迭代非常快因此我们需要以一个开放的心态持续学习。而学习恰恰又是一个反人性的过程甚至是比较痛苦的尤其是有些技术框架本身比较庞大设计得非常复杂我们在学习初期很容易遇到“挫折感”一些技术点怎么想也想不明白往往也会有放弃的想法。我同样经历过这个过程我的经验是找到适合自己的学习方法非常重要同样关键的是要保持学习的兴趣和动力。
举个我学习Spring框架的例子记得当时我在接触Spring框架的时候一开始就钻进一个模块开始啃起了源代码。由于Spring框架本身比较庞杂分很多模块当时给我最直观的感受就是看不懂我不明白代码为什么要这么写为什么设计得这么“绕”。这里面的问题是首先我还没弄清楚森林长什么样子就盯着树叶看很可能是盲人摸象看不到全貌和整体的设计思路。第二个问题是我还没学会用Spring就开始研究它是如何设计的结果可想而知也遇到了挫折。后来我逐渐总结出一些学习新技术的小经验在学习一门技术的时候一定要先看清它的全貌我推荐先看官方文档看看都有哪些模块、整体上是如何设计的。接着我们先不要直接看源码而是要动手跑一跑官网上的例子或者用这个框架实现一个小系统关键是要学会怎么使用。只有在这个基础上才能深入到特定模块去研究设计思路或者深入到某一模块源码之中。这样在学习的过程中按照一定的顺序一步一步来就能够即时获得成就感有了成就感你才会更加专注才会愿意花更多时间和精力去深入研究。因此要保持学习的兴趣我觉得有两个方面比较重要
第一个是我们需要带着明确的目标去学习。比如某些知识点是面试的热点那学习目标就是彻底理解和掌握它当被问到相关问题时你的回答能够使得面试官对你刮目相看有时候往往凭着某一个亮点就能影响最后的录用结果。再比如你想掌握一门新技术来解决工作上的问题那你的学习目标应该是不但要掌握清楚原理还要能真正的将新技术合理运用到实际工作中解决实际问题产生实际效果。我们学习了Tomcat和Jetty的责任链模式是不是在实际项目中的一些场景下就可以用到这种设计呢再比如学习了调优方法是不是可以在生产环境里解决性能问题呢总之技术需要变现才有学习动力。
第二个是一定要动手实践。只有动手实践才会让我们对技术有最直观的感受。有时候我们听别人讲经验和理论感觉似乎懂了但是过一段时间便又忘记了。如果我们动手实践了特别是在这个过程中碰到了一些问题通过网上查找资料或者跟同事讨论解决了问题这便是你积累的宝贵经验想忘记都难。另外适当的动手实践能够树立起信心培养起兴趣这跟玩游戏上瘾有点类似通过打怪升级一点点积累起成就感。比如学习了Tomcat的线程池实现我们就可以自己写一个定制版的线程池学习了Tomcat的类加载器我们也可以自己动手写一个类加载器。
专栏更新到现在,内容最难的部分已经结束,在后面的实战调优模块,我在设计内容时都安排了实战环节。毕竟调优本身就是一个很贴近实际场景的话题,应该基于特定场景,去解决某个性能问题,而不是为了调优而调优。所以这部分内容也更贴近实际工作场景,你可以尝试用我前面讲的方法,带着问题学习后面的专栏。
调优的过程中需要一些知识储备比如我们需要掌握操作系统、JVM以及网络通信的原理这些原理在专栏前面的文章也讲到过。虽然涉及很多原理也很复杂并不是说要面面俱到我们也不太容易深入到每个细节所以最关键的是要弄懂相关参数的含义比如JVM内存的参数、GC的参数、Linux内核的相关参数等。
除此之外,调优的过程还需要借助大量的工具,包括性能监控工具、日志分析工具、网络抓包工具和流量压测工具等,熟练使用这些工具也是每一个后端程序员必须掌握的看家本领,因此在实战环节,我也设计了一些场景来带你熟悉这些工具。
说了那么多,就是希望你保持对学习的热情,树立明确的目标,再加上亲自动手实践。专栏学习到现在这个阶段,是时候开始动手实践了,希望你每天都能积累一点,每天都能有所进步。
最后欢迎你在留言区分享一下你学习一门新技术的方法和心得,与我和其他同学一起讨论。

View File

@ -0,0 +1,35 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
结束语 静下心来,品味经典
从专栏上线发布到现在,不知不觉三个月时间过去了,感谢你的一路陪伴,今天到了说再见的时候,我想简单回顾一下专栏的内容,并且聊聊我的一些感受。
Tomcat和Jetty发展这么多年已经比较成熟稳定。这些年技术发展迭代速度又很快在一个“追新求快”的时代Tomcat和Jetty作为Java Web开发的必备工具似乎变成了“熟悉的陌生人”。对于很多新同学来说虽然有些Tomcat和Jetty的知识点在面试中会碰到但从侧面来说Tomcat和Jetty似乎没有那么“火”那是不是说如今就没有必要深入学习Tomcat和Jetty了呢只要会用就行呢要回答这个问题我先讲讲为什么我选择这个主题来写专栏吧。我写这个专栏的初心还是希望我们可以静下心来细细品味经典的开源作品从而进一步提升我们的“内功”。“内功”这个词有些抽象具体来说就是学习大牛们如何设计、架构一个中间件软件系统并且让这些经验可以为自己所用。作为一名IT从业者我认为我们很有必要深入思考一下这些大牛为什么能够创造出这些优秀的作品并且能引领技术的发展呢。
不知道你发现没有,美好的事物往往是整洁而优雅的。但这并不等于简单,而是要将复杂的系统分解成一个个小模块,并且各个模块的职责划分也要清晰合理。与此相反的是凌乱无序,比如你看到一堆互相纠缠在一起的电线,可能会感到不适。
同样的道理当我们在设计一个软件系统时追求的目标也应该是整洁和优雅。我觉得首先需要合理划分功能模块主要是分清楚“变与不变”的边界因为变化往往会给系统实现带来混乱因此需要将“变”的因素控制、隔离起来。如果你发现一个软件系统里有大量if else语句、大量的重复代码、大量的相互依赖那么这个系统多半还有提高的空间所以分清楚“变与不变”十分重要。
从宏观上看中间件实现的功能基本上是稳定不变的它们往往会实现一些协议和规范比如Tomcat作为一个“HTTP服务器 + Servlet容器”它向开发人员屏蔽应用层协议和网络通信细节我们拿到的是一个标准的Request和Response对象而具体业务逻辑则作为变化点交给我们来实现。
从微观上来看Tomcat内部也隔离了变化点和不变点比如Tomcat和Jetty都采用了基于组件化的设计其目的就是为了实现“搭积木式”的高度定制化而组件的生命周期管理有一些共性被提取出来成为接口和抽象类而具体子类实现变化点。
其实当下流行的微服务也是这个思路,首先按照功能将单体应用拆成微服务,拆分的过程中要注意从众多微服务中提取一些共性,而这些共性就会成为一些核心的基础服务,或者成为一些通用库。
设计模式往往是封装变化的一把利器我在专栏里也谈到不少Tomcat和Jetty所采用的设计模式合理地运用设计模式能让我们的代码看起来优雅且整洁。
除此之外我们在编写程序时应该时刻考虑到高性能尤其是开发基础的中间件系统在大数据量、高并发情况下可能一行代码的改动会带来明显的性能提升。高效意味着合理的数据存储和流动方式换句话说其实就是合理地运用数据结构和算法举个最简单的例子在某个场景是选择数组还是链表。如果你深入了解过Tomcat你会发现在许多实际场景中Tomcat都会有针对性的选择所以对于一些常见的数据结构和算法虽然我们不需要深入到实现细节但是一定要知道在什么场景下用哪个。
此外写高性能程序还意味着你需要掌握操作系统底层原理并且深入到JVM底层的实现细节比如我们调用了一个Java APIJVM和操作系统在背后为我们做了什么呢挖得更深一点我们对程序的理解也就更深刻也许就是因为深入的这一小步能够让我们在竞争中脱颖而出。
不知不觉我从Tomcat和Jetty的学习谈到了如何优雅地设计一个复杂的系统。由点及面你可以把Tomcat和Jetty当作一个支点从我们身边“熟悉又陌生”的Tomcat和Jetty入手不光掌握它们的使用更能从它们的源码中汲取经验提升自己的系统设计能力。学习这件事千万不能浮躁很难做到一口吃成大胖子最重要的是需要静下心慢慢体会和思考。我看到不少同学的留言从提问的内容我能感受到你们的好奇心和思考有些问题我也还要去查阅源码才能回答上来在这个过程中我自己也主动或被动的学到不少东西所以说多和同行们交流也非常有必要。
学习永远在路上,最后祝我们一起进步!

View File

@ -0,0 +1,166 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
01 导读Docker 核心技术预览
2017年3月 Docker 官方发布消息,原 SAP 高管 Steve Singh 接任 Ben Golub 成为新的首席执行官。这是一个特别时期经过四年的长足发展Docker 已经成为一家高速发展的云计算开源技术领导品牌,但是其商业模式至今不太明朗。
Steve 的商业经验至少可以说是当前最合适成为 Docker 领军人物的人选,在距离 IPO 上市近在咫尺的路上,本文期望通过深入预览 Docker 核心技术的概念,让大家全面了解 Docker 技术对云计算发展的深远意义。
背景
由 PaaS 到 Container
2013年2月前 Gluster 的 CEOBen Golub 和 dotCloud 的 CEOSolomon Hykes 坐在一起聊天时Solomon 谈到想把 dotCloud 内部使用的 Container 容器技术开源出来,并围绕这项技术开一家新公司并提供商业技术支持。
这一想法源于28岁的 Solomon 在研发 dotCloud 的 PaaS 云平台时发现的机会。通过使用 Ubuntu 社区的 LXCLinux Container技术可以便捷地构建应用环境镜像并发布到主机平台运行。这种容器技术可以把开发者从日常部署升级应用环境配置的繁杂工作中解脱出来。事实上自从进入 DevOps 理念之后开发团队迫切需要从各种混乱的部署环境中解脱出来。他们越深入交谈越觉得这是一次云计算技术的变革。不久之后在2013年3月 Docker 0.1发布,从此拉开了基于云计算容器技术的变革序幕。
Docker 简介
Docker 是 Docker Inc 公司开源的一项基于 Ubuntu LXC 技术之上构建的应用打包运行时引擎,源代码托管在 GitHub 上,完全基于 Go 语言开发并遵守 Apache License 2.0 协议开源。 Docker 在2014年6月召开的 DockerCon 2014 技术大会上,吸引了 IBM、Google、RedHat 等业界知名公司的关注和技术支持。无论是从 GitHub 上的代码活跃度,还是开源巨头红帽宣布在 RHEL7 中正式支持 Docker 技术,都可以说明 Docker 技术是一项创新型的技术解决方案,就连 Google 公司的 Compute Engine 也很快支持 Docker 在其之上运行。国内 BATJ百度云、阿里云、腾讯云、京东云也相继推出容器服务紧追云计算发展趋势。
Docker 技术解决以下问题:
复杂的环境配置管理:从各种 OS 环境到各种中间件环境以及各种应用环境。
在软件行业中任何应用要做到成功发布开发团队需要关心的东西太多且难于统一管理这个问题普遍存在并需要直接面对。Docker 技术旨在简化部署多种应用实例环境依赖,如 Web 应用、后台应用、数据库应用、大数据应用例如Hadoop集群、消息队列例如Kafka等等都可以打包成一个镜像部署。如图所示
云计算时代的到来AWS 的成功引导开发者将应用转移到云上解决了硬件管理的问题然而软件配置和管理相关的问题依然存在AWS CloudFormation 是这个方向的业界标准样例模板可参考这里。Docker 的出现正好能帮助软件开发者开阔思路,尝试新的软件管理方法来解决这个问题。
虚拟化手段的变化:云时代采用标配硬件来降低成本,采用虚拟化手段来满足用户按需分配的资源需求以及保证可用性和隔离性。然而无论是 KVM 还是 Xen在 Docker 看来都在浪费资源,因为用户需要的是高效运行环境而非 OSGuestOS 既浪费资源又难于管理,轻量级的 LXC 更加灵活和快速。如图所示:
容器技术的便携性LXC 在 Linux 2.6 的 Kernel 里就已经存在了,但是其设计之初并非为云计算考虑的,缺少标准化的描述手段和容器的可便携性,决定其构建出的环境难于分发和标准化管理(相对于 KVM 之类 image 和 snapshot 的概念。Docker 就在这个问题上做出了实质性的创新方法。
Docker 的 Hello World
以 Fedora25 作为主机为例,直接安装
$ sudo dnf install docker-ce
启动 Docker 后台 Daemon
$ sudo systemctl start docker
跑第一个 Hello World 容器实例:
$ sudo docker run hello-world
随后可以看到在命令行控制台中打印出经典的Hello World 字符串。
核心技术预览
Docker 核心是一个操作系统级虚拟化方法,理解起来可能并不像 VM 那样直观。我们从虚拟化方法的四个方面:隔离性、可配额/可度量、便携性、安全性来详细介绍 Docker 的技术细节。
隔离性Linux Namespace(ns)
每个用户实例之间相互隔离,互不影响。一般的硬件虚拟化方法给出的方法是 VM而 LXC 给出的方法是 container更细一点讲就是 kernel namespace。其中 pid、net、ipc、mnt、uts、user 等 namespace 将 container 的进程、网络、消息、文件系统、UTS“UNIX Time-sharing System”和用户空间隔离开。
pid namespace
不同用户的进程就是通过 pid namespace 隔离开的,且不同 namespace 中可以有相同 pid。所有的 LXC 进程在 Docker中的父进程为 Docker 进程,每个 lxc 进程具有不同的 namespace。同时由于允许嵌套因此可以很方便地实现 Docker in Docker。
net namespace
有了 pid namespace每个 namespace 中的 pid 能够相互隔离,但是网络端口还是共享 host 的端口。网络隔离是通过 net namespace 实现的,每个 net namespace 有独立的 network devicesIP addressesIP routing tables/proc/net 目录。这样每个 container 的网络就能隔离开来。Docker 默认采用 veth 的方式将 container 中的虚拟网卡同 host 上的一个 docker bridgedocker0连接在一起。
ipc namespace
container 中进程交互还是采用 linux 常见的进程间交互方法interprocess communication - IPC包括常见的信号量、消息队列和共享内存。然而同 VM 不同的是container 的进程间交互实际上还是 host 上具有相同 pid namespace 中的进程间交互,因此需要在 IPC 资源申请时加入 namespace 信息——每个 IPC 资源有一个唯一的32位 ID。
mnt namespace
类似 chroot将一个进程放到一个特定的目录执行。mnt namespace 允许不同 namespace 的进程看到的文件结构不同,这样每个 namespace 中的进程所看到的文件目录就被隔离开了。同 chroot 不同,每个 namespace 中的 container 在/proc/mounts 的信息只包含所在 namespace 的 mount point。
uts namespace
UTS“UNIX Time-sharing System”namespace 允许每个 container 拥有独立的 hostname 和 domain name使其在网络上可以被视作一个独立的节点而非 Host 上的一个进程。
user namespace
每个 container 可以有不同的 user 和 group id也就是说可以在 container 内部用 container 内部的用户执行程序而非 Host 上的用户。
可配额/可度量 Control Groups (cgroups)
cgroups 实现了对资源的配额和度量。 cgroups 的使用非常简单,提供类似文件的接口,在/cgroup 目录下新建一个文件夹即可新建一个 group在此文件夹中新建 task 文件并将pid 写入该文件即可实现对该进程的资源控制。groups 可以限制 blkio、cpu、cpuacct、cpuset、devices、freezer、memory、net_cls、ns 九大子系统的资源,以下是每个子系统的详细说明:
有序列表 blkio 这个子系统设置限制每个块设备的输入输出控制。例如:磁盘,光盘以及 usb 等等。
cpu 这个子系统使用调度程序为 cgroup 任务提供 cpu 的访问。
cpuacct 产生 cgroup 任务的 cpu 资源报告。
cpuset 如果是多核心的 cpu这个子系统会为 cgroup 任务分配单独的 cpu 和内存。
devices 允许或拒绝 cgroup 任务对设备的访问。
freezer 暂停和恢复 cgroup 任务。
memory 设置每个 cgroup 的内存限制以及产生内存资源报告。
net_cls 标记每个网络包以供 cgroup 方便使用。
ns 名称空间子系统。
以上九个子系统之间也存在着一定的关系。详情请参阅官方文档。
便携性
AUFSAnotherUnionFS是一种 Union FS简单来说就是支持将不同目录挂载到同一个虚拟文件系统下unite several directories into a single virtual filesystem的文件系统。更进一步地理解AUFS 支持为每一个成员目录类似Git Branch设定 readonly、readwrite 和 whiteout-able 权限。同时 AUFS 里有一个类似分层的概念,对 readonly 权限的 branch 可以逻辑上进行修改(增量地,不影响 readonly 部分的)。
通常 Union FS 有两个用途,一方面可以实现不借助 LVM、RAID 将多个 disk 挂到同一个目录下;另一个更常用的就是将一个 readonly 的 branch 和一个 writeable 的 branch 联合在一起Live CD 正是基于此方法可以允许在 OS image 不变的基础上允许用户在其上进行一些写操作。Docker 在 AUFS 上构建的 container image 也正是如此,接下来我们从启动 container 中的 linux 为例来介绍 Docker 对 AUFS 特性的运用。
典型的启动Linux运行需要两个FSbootfs + rootfs
bootfsboot file system主要包含 bootloader 和 kernelbootloader 主要是引导加载 kernel当 boot 成功后 kernel 被加载到内存中后 bootfs 就被 umount 了。 rootfsroot file system包含的就是典型 Linux 系统中的/dev/proc/bin/etc 等标准目录和文件。
对于不同的 linux 发行版bootfs 基本是一致的。但 rootfs 会有差别,因此不同的发行版可以公用 bootfs。如下图
典型的 Linux 在启动后,首先将 rootfs 设置为 readonly进行一系列检查然后将其切换为 “readwrite”供用户使用。在 Docker 中,初始化时也是将 rootfs 以 readonly 方式加载并检查,然而接下来利用 union mount 的方式将一个 readwrite 文件系统挂载在 readonly 的 rootfs 之上,并且允许再次将下层的 FSfile system设定为 readonly并且向上叠加这样一组 readonly 和一个 writeable 的结构构成一个 container 的运行时态,每一个 FS 被称作一个 FS 层。如下图:
得益于 AUFS 的特性,每一个对 readonly 层文件/目录的修改都只会存在于上层的 writeable 层中。这样由于不存在竞争,多个 container 可以共享 readonly 的 FS 层。所以 Docker 将 readonly 的 FS 层称作“image”-——对于 container 而言整个 rootfs 都是 read-write 的,但事实上所有的修改都写入最上层的 writeable 层中image 不保存用户状态,只用于模板、新建和复制使用。
上层的 image 依赖下层的 image因此 Docker 中把下层的 image 称作父 image没有父 image 的 image 称作 base image。因此想要从一个 image 启动一个 containerDocker 会先加载这个 image 和依赖的父 images 以及 base image用户的进程运行在 writeable 的 layer 中。所有 parent image 中的数据信息以及 ID、网络和 lxc 管理的资源限制等具体 container 的配置,构成一个 Docker 概念上的 container。如下图
安全性AppArmorSELinuxGRSEC
安全永远是相对的这里有三个方面可以考虑Docker 的安全特性:
由 kernel namespaces 和 cgroups 实现的 Linux 系统固有的安全标准;
Docker Deamon 的安全接口;
Linux 本身的安全加固解决方案,例如 AppArmorSELinux
由于安全属于非常具体的技术,这里不在赘述,请直接参阅 Docker 官方文档。
总结
Docker 社区一直在面对技术挑战从容地给出自己的解决方案。云计算发展至今有很多重要的问题没有得到妥善解决Docker 正在尝试让主流厂商接受并应用它的解决方案。至此,以上 Docker 技术的预览到此告一段落,笔者也希望读者能结合自己的实际情况,尝试使用 Docker 技术。只有在亲自体会的基础之上Docker 技术才会产生更大的商业价值。

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,131 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
03 基于 Docker 的 DevOps 实践
敏捷开发已经流行了很长时间,如今有越来越多的传统企业开始践行敏捷开发所提倡的以人为中心、迭代、循序渐进的开发理念。
在这样的场景下引入 Docker 技术,首要目的就是使用 Docker 提供的虚拟化方式,给开发团队建立一套可以复用的开发环境,让开发环境可以通过 Image 的形式分享给项目的所有开发成员,以简化开发环境的搭建。但是,在没有 Docker 技术之前就已经有类如 Vagrant 的开发环境分发技术软件开发者一样可以创建类似需求的环境配置流程。所以在本地开发环境方面Docker 技术的优势并不能很好的发挥出来。笔者认为 Docker 的优点在于通过简化CI持续集成、CD持续交付的构建流程让开发者把更多的精力用在开发上。
每家公司都有自己的开发技术栈,我们需要结合实际情况对其进行持续改进,优化自己的构建流程。当我们准备迈出第一步时,我们首先要确立一张构建蓝图,做到胸有成竹,这样接下来的事情才会很快实现。
这张时序图概括了目前敏捷开发流程的所有环节。结合以上时序图给出的蓝图框架,本文的重点是讲解引入 Docker 技术到每个环节中的实践经验。
组建持续发布的敏捷团队
开发团队在引入 Docker 技术的时候,最大的问题是没有可遵循的业界标准。大家常常以最佳实践为口号,引入多种工具链,导致在使用 Docker 的过程中没有侧重点。涉及到 Docker 选型,又在工具学习上花费大量时间,而不是选用合适的工具以组建可持续发布产品的开发团队。基于这样的场景,我们可以把“简单易用”的原则作为评判标准,引入到 Docker 技术工具选型的参考中。开发团队在引入 Docker 技术的过程中,首先需要解决的是让团队成员尽快掌握 Docker 命令行的使用。在熟悉了 Docker 命令行之后,团队需要解决几个关键问题具体如下:
Base Image 的选择比如phusion-baseimage。
管理Docker应用配置工具的选择比如Ansible、Tereform。
Host主机系统的选择比如CoreOS、Atomic、Ubuntu。
Base Image
包括了操作系统命令行和类库的最小集合一旦启用所有应用都需要以它为基础创建应用镜像。Ubuntu 作为官方使用的默认版本,是目前最易用的版本,但系统没有经过优化,可以考虑使用第三方有划过的版本,比如 phusion-baseimage。
对于选择 RHEL、CentOS 分支的 Base Image提供安全框架 SELinux 的使用、块级存储文件系统 devicemapper 等技术,这些特性是不能和 Ubuntu 分支通用的。另外需要注意的是,使用的操作系统分支不同,其裁剪系统的方法也完全不同,所以大家在选择操作系统时一定要慎重。
管理 Docker 应用配置工具的选择
主要用于基于 Dockerfile 创建 Image 的配置管理。我们需要结合开发团队的现状,选择一款团队熟悉的工具作为通用工具。配置工具有很多种选择,其中 Ansible 作为后起之秀,在配置管理的使用中体验非常简单易用,推荐大家参考使用。
Host 主机系统
是 Docker 后台进程的运行环境。从开发角度来看,它就是一台普通的单机 OS 系统我们仅部署Docker 后台进程以及集群工具,所以希望 Host 主机系统的开销越小越好。这里推荐给大家的 Host 主机系统是 CoreOS它是目前开销最小的主机系统。另外还有红帽的开源 Atomic 主机系统有基于Fedora、CentOS、RHEL多个版本的分支选择也是不错的候选对象。
另外一种情况是选择最小安装操作系统自己定制Host 主机系统。如果你的团队有这个实力,可以考虑自己定制这样的系统。
持续集成的构建系统
当开发团队把代码提交到 Git 应用仓库的那一刻,我相信所有的开发者都希望有一个系统能帮助他们把这个应用程序部署到应用服务器上,以节省不必要的人工成本。但是,复杂的应用部署场景,让这个想法实现起来并不简单。
首先,我们需要有一个支持 Docker 的构建系统,这里推荐 Jenkins。它的主要特点是项目开源、方便定制、使用简单。Jenkins 可以方便的安装各种第三方插件,从而方便快捷的集成第三方的应用。
通过 Jenkins 系统的 Job 触发机制,我们可以方便的创建各种类型的集成 Job 用例,但缺乏统一标准的 Job 用例使用方法,会导致项目 Job 用例使用的混乱,难于管理维护,这也让开发团队无法充分利用好集成系统的优势,当然这也不是我们期望的结果。所以,敏捷实践方法提出了一个可以持续交付的概念 DeploymentPipeline管道部署。通过Docker 技术,我们可以很方便的理解并实施这个方法。
Jenkins 的管道部署把部署的流程形象化成为一个长长的管道,每间隔一小段会有一个节点,也就是 Job完成这个 Job 工作后才可以进入下一个环节。形式如下:
大家看到上图中的每一块面板在引入 Docker 技术之后,就可以使用 Docker 把任务模块化,然后做成有针对性的 Image 用来跑需要的任务。每一个任务 Image 的创建工作又可以在开发者自己的环境中完成,类似的场景可以参考下图:
所以,使用 Docker 之后,任务的模块化很自然地被定义出来。通过管道图,可以查看每一步的执行时间。开发者也可以针对任务的需要,为每一个任务定义严格的性能标准,已作为之后测试工作的参考基础。
最佳的发布环境
应用经过测试接下来我们需要把它发布到测试环境和生产环境。这个阶段中如何更合理地使用Docker 也是一个难点,开发团队需要考虑如何打造一个可伸缩扩展的分发环境。其实,这个环境就是基于 Docker 的私有云,更进一步我们可能期望的是提供 API 接口的 PaaS 云服务。为了构建此 PaaS 服务,这里推荐几款非常热门的工具方便大家参考,通过这些工具可以定制出企业私有的 PaaS 服务。
Google Kubernetes
Google的一个容器集群管理工具它提出两个概念
Cluster control planeAKA master集群控制面板内部包括多个组件来支持容器集群需要的功能扩展。
The Kubernetes Node计算节点通过自维护的策略来保证主机上服务的可用性当集群控制面板发布指令后也是异步通过 etcd 来存储和发布指令,没有集群控制链路层面的依赖。
通过官方架构设计文档的介绍,可以详细的了解每个组件的设计思想。这是目前业界唯一在生产环境部署经验的基础上推出的开源容器方案,目前是 CNCF 推荐的容器管理系统的行业参考标准。
Docker swarmkit
SwarmKit 是一个分布式集群调度平台作为docker 一个新的集群调度开源项目,它大量借鉴了 Kubernetes 和 Apache Mesos 的优秀概念和最佳实践,通过内嵌到 docker daemon 中实现对开发用户的引入。实际上它可以被看做 Docker Swarm 的2.0版本。目前业界云原生应用基金会并没有收录此集群方案,所以开发者在选型时会慎重考虑。
Apache Mesos + Marathon(DCOS)
Apache Mesos 系统是一套资源管理调度集群系统,生产环境使用它可以实现应用集群。此系统是由 Twitter 发起的 Apache 开源项目。在这个集群系统里,我们可以使用 Zookeeper 开启3个Mesos master 服务当3个 Mesos master 通过 zookeeper 交换信息后会选出 Leader 服务,这时发给其它两台 Slave Messos Master 上的请求会转发到 Messos master Leader 服务。Mesos slave 服务器在开启后会把内存、存储空间和 CPU 资源信息发给 Messos master。
Mesos 是一个框架,在设计它的时候只是为了用它执行 Job 来做数据分析。它并不能运行一个比如 Web 服务 Nginx 这样长时间运行的服务,所以我们需要借助 marathon 来支持这个需求。
marathon 有自己的 REST API我们可以创建如下的配置文件 Docker.json
{
"container": {
"type": "DOCKER",
"docker": {
"image": "libmesos/ubuntu"
}
},
"id": "ubuntu",
"instances": "1",
"cpus": "0.5",
"mem": "512",
"uris": [],
"cmd": "while sleep 10; do date -u +%T; done"
}
然后调用
curl -X POST -H “Content-Type: application/json” http://:8080/v2/apps [email protected]
我们就可以创建出一个 Web 服务在 Mesos 集群上。对于 Marathon 的具体案例,可以参考官方案例。
结论
Docker 的 DevOps 实践方案,是一套灵活简单的敏捷解决方案。它克服了之前集群工具复杂、难用的困境,使用统一的 Docker 应用容器概念部署软件应用。通过引入 Docker 技术,开发团队在面对复杂的生产环境中,可以结合自己团队的实际情况,定制出适合自己基础架构的开发测试运维一体化解决方案。

View File

@ -0,0 +1,113 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
04 容器云平台的构建实践
容器云平台是 Gartner 近些年提出来的云管理平台Cloud Management PlatformCMP的企业架构转型衍生品参考 Gartner 的定义如下:
云管理平台CMP是提供对公有云、私有云和混合云整合管理的产品。
从容器化角度总结起来就是两块,第一是功能需求,管理容器运行引擎、容器编排、容器网络、容器存储、监控报警日志。第二是非功能需求,可用性,兼容性,安全和易用性,负载优化等。容器云平台建设的目标是使企业业务应用被更好的运营管理起来。
从云平台的建设步骤来说,大致需要经过以下步骤来梳理实践,顺序不限:
1.选择运行时容器引擎的基准参考。
实际情况是当前容器运行引擎可以选择的品类并不多,只有 Docker 家的组件是最容易搭建的,所以业界选型的时候,都是默认首选以 Docker 组件作为基准来选型环境配置。当然随着云原生基金会Cloud Native Computing FoundationCNCF接纳下当前几乎所有业界领先的云计算厂商成为其成员单位从而从侧面奠基了以通用容器运行时接口CRI为基础的 cri-o 系列容器引擎的流行,参考 CNCF 的架构鸟瞰图可以看到容器运行引擎的最新的发展走向。
从 CNCF 指导下应用上云的趋势来看,已经在模糊私有云计算资源和公有云计算资源的界限,容器运行引擎也不在是 Docker 一家独有业界已经偏向选择去除厂商绑定的开源通用容器运行时接口CRI对接的容器引擎。这种趋势也明显从 DockerCon17 大会上看到 Docker 宣布支持 Kubernetes 一样,容器引擎已经有了新的架构体系可以参考和扩展。如图:
由于社区的快速变革,很多读者可能已经无法详细梳理和理解 CRI-containerd 和 CRI-O 的一些细微差别。所以我还要把 CRI-O 的架构图放在这里方便大家做对比。
2.容器云平台涉及到多租户环境下多个计算节点的资源有效利用和颗粒度更细的资源控制。
Kubernetes 无疑是最佳的开源项目来支撑云平台的实践。Kubernetes 的架构设计是声明式的 API 和一系列独立、可组合的控制器来保证应用总是在期望的状态。这种设计本身考虑的就是云环境下网络的不可靠性。这种声明式 API 的设计在实践中是优于上一代命令式 API 的设计理念。考虑到云原生系统的普及,未来 Kubernetes 生态圈会是类似 Openstack 一样的热点,所以大家的技术栈选择上,也要多往 Kubernetes 方向上靠拢。如图:
3.容器网络其实从容器云平台建设初期就是重要梳理的对象。
容器引擎是基于单机的容器管理能力网络默认是基于veth pair 的网桥模式,如图所示:
这种网络模型在云计算下无法跨主机通信,一般的做法需要考虑如何继承原有网络方案。所以 CNCF 框架下定义有容器网络接口CNI标准这个标准就是定义容器网络接入的规范帮助其他既有的网络方案能平滑接入容器网络空间内。自从有了 CNI 之后很多协议扩展有了实现OpenSwitch、Calico、Fannel、Weave 等项目有了更具体的落地实践。从企业选型的角度来看当前网络环境下,我们仍然需要根据不同场景认真分析才可以获得更好的收益。常见的场景中
物理网络大都还是二层网络控制面,使用原生的 MacVlan/IPVlan 技术是比较原生的技术。
从虚拟网络角度入手,容器网络的选择很多,三层 Overlay 网络最为广泛推荐。
还有从云服务商那里可以选择的网络环境都是受限的网络,最优是对接云服务的网络方案,或者就是完全放弃云平台的建设由服务商提供底层方案。
网络性能损耗和安全隔离是最头疼的网络特性。使用容器虚拟网桥一定会有损耗,只有最终嫁接到硬件控制器层面来支撑才能彻底解决此类性能损耗问题。所有从场景出发,网络驱动的选择评估可以用过网络工具的实际压测来得到一些数据的支撑。参考例子:
docker run -it --rm networkstatic/iperf3 -c 172.17.0.163
Connecting to host 172.17.0.163, port 5201
[ 4] local 172.17.0.191 port 51148 connected to 172.17.0.163 port 5201
[ ID] Interval Transfer Bandwidth Retr Cwnd
[ 4] 0.00-1.00 sec 4.16 GBytes 35.7 Gbits/sec 0 468 KBytes
[ 4] 1.00-2.00 sec 4.10 GBytes 35.2 Gbits/sec 0 632 KBytes
[ 4] 2.00-3.00 sec 4.28 GBytes 36.8 Gbits/sec 0 1.02 MBytes
[ 4] 3.00-4.00 sec 4.25 GBytes 36.5 Gbits/sec 0 1.28 MBytes
[ 4] 4.00-5.00 sec 4.20 GBytes 36.0 Gbits/sec 0 1.37 MBytes
[ 4] 5.00-6.00 sec 4.23 GBytes 36.3 Gbits/sec 0 1.40 MBytes
[ 4] 6.00-7.00 sec 4.17 GBytes 35.8 Gbits/sec 0 1.40 MBytes
[ 4] 7.00-8.00 sec 4.14 GBytes 35.6 Gbits/sec 0 1.40 MBytes
[ 4] 8.00-9.00 sec 4.29 GBytes 36.8 Gbits/sec 0 1.64 MBytes
[ 4] 9.00-10.00 sec 4.15 GBytes 35.7 Gbits/sec 0 1.68 MBytes
- - - - - - - - - - - - - - - - - - - - - - - - -
[ ID] Interval Transfer Bandwidth Retr
[ 4] 0.00-10.00 sec 42.0 GBytes 36.1 Gbits/sec 0 sender
[ 4] 0.00-10.00 sec 42.0 GBytes 36.0 Gbits/sec receiver
iperf Done.
对于网络安全的需求,一种是策略性的网络速度的限制,还有一种是策略上的租户网络隔离,类似 VPC。这块比较有想法的参考开源项目是 cilium如图
4.容器存储是容器应用持久化必须解决的问题。
从容器提出来之后,业界就一直在探索如何在分布式场景下对接一套分布式存储来支撑有状态应用。可惜的是,在 CNCF 的容器存储接口CSI定义之下目前还没有最终完成参考实现所有大家只能参考一下规范。在没有统一接口之前我们只能一对一的实现当前的存储接口来调用分布式存储。好在存储并没有太多的选择除了商用存储之外开源领域可以选择的无非是 GlusterFS 和 Ceph。一种是作为块存储存在一种是作为文件存储存在。
从容器使用角度来讲,文件存储是应用场景最多的案例,所以使用 Gluster 类来支持就可以在短时间内实现有状态应用的扩展。这里特别需要提醒一句,容器分布式存储的想法有很多种,并不一定要局限在现有存储方案中,只需要实现 FUSE 协议就可以打造自己的存储,可以参考京东云的容器存储实现 Containerfs 获得灵感:
5.容器云平台定制化需求最多的地方就是管理平台的功能布局和功能范围。
云平台常常只覆盖底层组件80%左右的功能映射并不是完全100%匹配。所有通用型云平台的设计实现需要从各家的场景需求出发,大致分为 DevOps 领域的集成开发平台,也可以是支撑微服务的管控平台。两个方向差距非常大,难以放在一起展现,大家的做法就是在行业专家理解的基础之上进行裁剪。目前行业可以参考的案例有 Rancher 的面板,还有 Openshift 的面板,并且谷歌原生的容器面板也是可以参考,如图:
6.镜像仓库的建设和管理,大家往往趋向于对管理颗粒度的把控。这块,可以参考的开源项目有 Harbor。
围绕镜像仓库的扩展需求还是非常多的,比如和 CI/CD 的集成,帮助用户从源码层面就自动构建并推入到仓库中。从镜像的分发能不能提供更多的接口,不仅仅是 Docker pull 的方式,可能需要通过 Agent 提前加载镜像也是一种业务需求。相信不久就会有对应的方案来解决这块的扩展问题。
7.还有非功能的需求也是需要考虑的。
比如云平台的高可用怎么实现是需要考虑清楚的。一般分布式系统都有三个副本的主控节点所有从方便性来讲会把云管理平台放在3台主控节点上复用部署通过Haproxy 和 Keeplived 等技术实现面板访问入口的高可用。还有当云平台还有 DB 需求时,需要单独的数据库主备模式作为 DB 高可用的选项,当然选择分布式 DB 作为支持也是可选项,当时这块就需要把 DB 服务化了。
当你真实引入这些组件部署之后,会发现需要冗余的组件是很多的,无状态的组件和有状态的组件并不能随便的混部,需要根据业务场景归类好。通常从可用性上来讲是应该抽离出来单独放把云管理平台部署两台机器上做高可用。其他部分中容器调度集群系统本身就是分布式设计,天然就有高可用的布局,可以直接利用。从应用上 Kubernets 开始很多分布式的优势会立即受益,我们主要的关心重点在于对集群控制器的业务需求扩展实现和算法调度管理。
8.微服务尤其是 Google Istio 的推出对服务网格化的需求,给容器云平台注入了新的实际的微服务场景,可以预见是未来容器云平台应用的一个重要场景。如下图所示。
弱化网关的单入口性,把网关做成了业务控制面板,可以任意的调度用户的请求流量。这是对上一代以 API 网关为中心的微服务的进化,必将引起软件架构的变革。
综上所述,云平台的构建实践不是一蹴而就的。需要结合业务场景在方方面面给予规划并分而治之。技术栈的不断迭代,让云计算开始有了很多新内容可以学习和实践。但是,很多历史遗留的应用的容器化工作还是非常棘手的。附加上流程变革的时间进度,我们还是需要在很多方面折中并给出一些冗余的方案来适配传统业务体系的需求。所有,通过以上功能性和非功能性的需求参考,相信可以加快企业构建云平台的步伐并给予一些必要的指导参考。

View File

@ -0,0 +1,157 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
05 容器网络机制和多主机网络实践
概述
容器网络不是新技术它是云计算虚拟化技术互联互通的基础核心技术。一般意义的网络都是主机与主机之间的通信颗粒度局限在物理层面的网卡接口。随着虚拟化技术的发展以应用为中心的新网络结构逐渐明朗清晰。容器技术就是让依赖环境可以跟着应用绑定打包并随需启动并互联。容器技术的特点也对网络技术的发展起到了互推的作用当网络不在持久化存在的时候软件定义网络SDN技术的能力就会体现的更充分。
容器主机网络模型
Docker 内建的网络模型是 Bridge Network。这种网络是基于主机内部模型的网络设计之初也是为了解决单机模式下容器之间的互联互通问题。如图
Veth pair 技术源于 Linux 网络模型的虚拟设备,比如 TAP 设备方便主机上应用程序接收网络数据而创建。TAP 设备只能监听到网卡接口上的数据流量,如果想连接多个网络命名空间,就需要用到 Veth pair 技术来打通连接。容器网络之间的互通就是通过这个做到的,但是细心的读者可以看到,图上主机网卡和 docker0 网桥是没有连接的,不能数据互联。为了让容器与外界网络相连,首先要保证主机能允许转发 IP 数据包,另外需要让 iptables 能指定特定的 IP 链路。通过系统参数 ip_forward 来调节开关,如:
$ sysctl net.ipv4.conf.all.forwarding
net.ipv4.conf.all.forwarding = 0
$ sysctl net.ipv4.conf.all.forwarding=1
$ sysctl net.ipv4.conf.all.forwarding
net.ipv4.conf.all.forwarding = 1
另外,当 Docker 后台程序起来后,会自动添加转发规则到 Docker 过滤链上,如下图:
$ sudo iptables -t filter -L
Chain INPUT (policy ACCEPT)
target prot opt source destination
ACCEPT tcp -- anywhere anywhere tcp dpt:domain
ACCEPT udp -- anywhere anywhere udp dpt:domain
ACCEPT tcp -- anywhere anywhere tcp dpt:bootps
ACCEPT udp -- anywhere anywhere udp dpt:bootps
Chain FORWARD (policy ACCEPT)
target prot opt source destination
DOCKER-ISOLATION all -- anywhere anywhere
DOCKER all -- anywhere anywhere
ACCEPT all -- anywhere anywhere ctstate RELATED,ESTABLISHED
ACCEPT all -- anywhere anywhere
ACCEPT all -- anywhere anywhere
DOCKER all -- anywhere anywhere
ACCEPT all -- anywhere anywhere ctstate RELATED,ESTABLISHED
ACCEPT all -- anywhere anywhere
ACCEPT all -- anywhere anywhere
DOCKER all -- anywhere anywhere
ACCEPT all -- anywhere anywhere ctstate RELATED,ESTABLISHED
ACCEPT all -- anywhere anywhere
ACCEPT all -- anywhere anywhere
ACCEPT all -- anywhere anywhere
DROP all -- anywhere anywhere
Chain OUTPUT (policy ACCEPT)
target prot opt source destination
Chain DOCKER (3 references)
target prot opt source destination
Chain DOCKER-ISOLATION (1 references)
target prot opt source destination
DROP all -- anywhere anywhere
DROP all -- anywhere anywhere
DROP all -- anywhere anywhere
DROP all -- anywhere anywhere
DROP all -- anywhere anywhere
DROP all -- anywhere anywhere
RETURN all -- anywhere anywhere
另外衍生出来的问题是,所有 Docker 容器启动时都需要显示指定端口参数,这样做是因为由于需要 iptable 规则来开启端口映射能力。
跨越主机的容器网络模型
如果需要让容器网络可以跨越主机访问,最原生的方式是 Macvlan 驱动支持的二层网络模型。VLAN 技术是网络组网的基本技术在网络环境中很容易获得所以由此产生的用户映像是能不能打破主机和容器的网络间隙把他们放在一个网络控制面上协作。Macvlan 技术就是为了这个需求而设计的,它实现了容器网络和主机网络的原生互联。当然,需要支持 Macvlan 也是需要准备一些基础环境的:
Docker 版本必须在1.12.0+以上
Linux kernel v3.93.19 and 4.0+才内置支持Macvlan 驱动
Macvlan 技术是一种真实的网络虚拟化技术比其他Linux Bridge 更加轻量级。相比 Linux Bridge性能更高。因为它跳过了主机网卡和容器网络直接的转发过程容器网卡接口直接对接主机网口可以视作为主机网络的延伸。这样的网络让外部访问容器变的非常简便不在需要端口映射如下图所示
为了让容器网络支持多个分组可以考虑采用802.1q 的 VALN tagging 技术实现。这种技术的好处对于小规模主机网络下容器网络的搭建非常合适。这块通过如下图可以解释清楚:
容器网络标准 CNI
容器网络接口CNI是云原生基金会支持项目属于云计算领域容器行业标准。它包含了定义容器网络插件规范和示范。因为 CNI 仅仅聚焦在容器之间的互联和容器销毁后的网络配置清理,所以它的标准简洁并容易实现。
标准包含两部分CNI Plugin 旨在配置网络信息,另外定义了 IPAM Plugin 旨在分配 IP管理 IP。这个接口有更广泛的适用性适应多种容器标准。如图
网络插件是独立的可执行文件,被上层的容器管理平台调用。网络插件只有两件事情要做:把容器加入到网络以及把容器从网络中删除。
调用插件的数据通过两种方式传递:环境变量和标准输入。
一般插件需要三种类型的数据:容器相关的信息,比如 ns 的文件、容器 id 等网络配置的信息包括网段、网关、DNS 以及插件额外的信息等;还有就是 CNI 本身的信息,比如 CNI 插件的位置、添加网络还是删除网络等。
把容器加入到网络
调用插件的时候,这些参数会通过环境变量进行传递:
CNI_COMMAND要执行的操作可以是 ADD把容器加入到某个网络、DEL把容器从某个网络中删除、VERSION
CNI_CONTAINERID容器的 ID比如 ipam 会把容器 ID 和分配的 IP 地址保存下来。可选的参数,但是推荐传递过去。需要保证在管理平台上是唯一的,如果容器被删除后可以循环使用
CNI_NETNS容器的 network namespace 文件,访问这个文件可以在容器的网络 namespace 中操作
CNI_IFNAME要配置的 interface 名字,比如 eth0
CNI_ARGS额外的参数是由分号;分割的键值对,比如 “FOO=BAR;ABC=123”
CNI_PATHCNI 二进制文件查找的路径列表,多个路径用分隔符 : 分隔
网络信息主要通过标准输入,作为 JSON 字符串传递给插件,必须的参数包括:
cniVersionCNI 标准的版本号。因为 CNI 在演化过程中,不同的版本有不同的要求
name网络的名字在集群中应该保持唯一
type网络插件的类型也就是 CNI 可执行文件的名称
args额外的信息类型为字典
ipMasq是否在主机上为该网络配置 IP masquerade
ipamIP 分配相关的信息,类型为字典
dnsDNS 相关的信息,类型为字典
CNI 作为一个网络协议标准,它有很强的扩展性和灵活性。如果用户对某个插件有额外的需求,可以通过输入中的 args 和环境变量 CNI_ARGS 传输然后在插件中实现自定义的功能这大大增加了它的扩展性CNI 插件把 main 和 ipam 分开,用户可以自由组合它们,而且一个 CNI 插件也可以直接调用另外一个 CNI 插件,使用起来非常灵活。如果要实现一个继承性的 CNI 插件也不复杂,可以编写自己的 CNI 插件,根据传入的配置调用 main 中已经有的插件,就能让用户自由选择容器的网络。
容器网络实践
容器网络的复杂之处在于应用的环境是千变万化的,一招鲜的容器网络模型并不能适用于应用规模的扩张。因为所谓实践,无外乎是在众多网络方案中选择合适自己的网络方案。
一切应用为王,网络性能指标是指导我们选择方案的最佳指南针。主机网络和容器网络互联互通的问题,是首先需要考虑的。当前比较合适的容器网络以 Macvlan/SR-IOV 为主。考虑原因还是尽量在兼容原有网络硬件的集成之上能更方便的集成网络。这块的方案需要软硬件上的支持,如果条件有限制,可能很难实现。比如你的容器网络本来就构建在 Openstack 的虚拟网络中。
退而求其次,当前最普遍的方案就是 Vxlan/overlay 的方案,这种网络方案是虚拟网络,和外界通信需要使用边界网关通信。这块主要的支持者是 Kubernetes 集群。比如常用的 Flannel 方案,主要被用户质疑的地方就是网络效率的损耗。 当然Vxlan 方案的优秀选择 openswitch可能是最强有力的支持者。通过 OVS 方便,可以得到一个业界最好的网络通信方案。当遇到生产级瓶颈时,可以考虑使用硬件控制器来代替 OVS 的控制器组件来加速网络。目前 Origin 的方案中选择的就是 OVS 方案,可以认为是当前比较好的选择。
当然,开源的 overlay 方案中有比较优秀的方案比如 Calico 方案,它借用了 BGP 协议作为主机与主机之间边界的路由通信可以很好的解决小集群模式下的高效网络传输。Calico 的背后公司也是借用此技术在社区中推出商业硬件解决方案。从国内的中小型企业的网络规模来说,此种网络完全可以满足网络需要。
展望网络发展趋势
容器网络互联已经不在是棘手的问题,可行的实现就在手边。目前用户进一步的使用中,对网络的限流和安全策略有了更多的需求。这也催生了如 cilium 这样的开源项目,旨在利用 Linux 原生的伯克利包过滤Berkeley Packet FilterBPF技术实现网络流量的安全审计和流量导向。如图
所以,容器网络的发展正在接近应用生命周期的循环中,从限流,到安全策略,再到可能的虚拟网络 NFV 的构建都有可能改变我们的容器世界。
参考:
容器网络接口标准

View File

@ -0,0 +1,129 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
06 Docker 日志机制与监控实践
概述
日志和监控是容器云平台系统最常见的必备组件形象一点形容其原理就是咖啡和伴侣一样必须配套使用让你的应用运行的更贴合用户满意的服务运营目标SLO。当容器技术被大量行业采用之后我们遇到了一个很自然的问题容器化后应用日志怎么收集监控报警怎么做。这些问题一直困扰着容器行业的从业者直到以 Google Borgmon 为理论基础的 Prometheus 开源项目发布EFK 日志系统的容器化实践落地,得以促成本篇文章的完成。
EFK 日志系统的容器化实践
日志系统涉及采集、展现和存储三个方面的设计。从采集方面来说,单台容器主机上的采集进程应该是多功能接口的、可以提供插件机制的日志组件才能满足一般采集的需求。那么到了容器这个领域,日志分为控制台日志和应用业务日志两类。对于容器控制台接口,需要通过容器进程开放的接口来采集,如图:
容器默认采用的是日志驱动为 json-file 模式,采集效率极低还占用大量 IO 读写效能,基本无法适应生产环境需要。在我们生产实践推荐中,偏向于采用系统提供的日志系统 systemd-journal 来接收日志采集,然后通过 fluentd 采集代理进程,把相应的日志按照业务规则分类汇聚,发送到 Elasticsearch 这样的中央日志管理系统。由于业务日志量的规模扩大,日志采集的流量速率会让中央日志系统处理负载过高,导致业务日志处理不过来。所以通常采用流式消息队列服务 Kafka 作为日志存储的异步缓冲,可以极大的缓解日志流量,并高效的解决日志采集的汇聚难题。
CNCF 云原生计算基金会推荐的采集解决方案是 Fluentd作为行业标杆的托管项目这个项目的插件是非常丰富的。所以当你在考虑选择日志采集方案的时候Fluentd 是当前一站式解决容器日志采集方案的首选,如下图:
因为 Fluentd 是一套 ruby 编写的日志采集框架,很难让人信服其海量的日志处理能力。所以在今年早些时候推出了基于 C 语言编写的高性能日志转发工具 fluentbit可以完美承上输入层起下输出层如图
日志收集到之后,会通过相应的过滤插件汇聚清洗日志条目并聚合到日志中心系统,系统用户通过可视化界面可以检索自己需要的日志信息。
随着 CNCF 在全球范围内吸收了业界主流云计算厂商,导致日志收集又遇到另一个需要解决的问题,那就是 Kubernetes 集群的日志收集问题。所以,我需要逐步按照收集的纬度给予介绍分析。首先,最基本的是 Pod 的日志信息,注意它并不等同于 Docker 容器的控制台日志。
例如 Pod 任务counter-pod.yaml
apiVersion: v1
kind: Pod
metadata:
name: counter
spec:
containers:
- name: count
image: busybox
args: [/bin/sh, -c,
'i=0; while true; do echo "$i: $(date)"; i=$((i+1)); sleep 1; done']
发布这个 Pod 到集群中:
$ kubectl create -f https://k8s.io/docs/tasks/debug-application-cluster/counter-pod.yaml
pod "counter" created
查看日志:
$ kubectl logs counter
0: Mon Jan 1 00:00:00 UTC 2001
1: Mon Jan 1 00:00:01 UTC 2001
2: Mon Jan 1 00:00:02 UTC 2001
...
Kubernetes 默认使用容器的 json-file 驱动来写入日志文件,并使用 logrotate 来收敛日志大小。
除了 Pod 之外,我们还需要考虑 Kubernetes 系统组件的日志收集工作。例如这样的场景:
Scheduler 和 kube-proxy 是容器化运行
Kubelet 和 Docker 是非容器化运行
对于容器化的系统组件,他们都是采用 glog 来写入日志的并存入 /var/log 目录下可以采用logrotate 来按大小分割日志。对于非容器化的系统组件,直接采用系统内建的 systemd-journal 收集即可。
当然对于分布式系统的日志收集,还可以通过发布日志采集容器组件的方式来采集日志。最好的方式是采用 sidecar 的方式,每个 Pod 中加入一个日志采集器,方便日志的采集流式进入日志系统中。
当应用日志需要落盘的时候,这种 sidecar 模式的日志采集方式尤其灵活,值得推荐采用。
容器监控实践
容器监控需要关心的指标范畴主要集中在主机、集群、容器、应用以及报警规则和报警推送。监控的指标也大多放在了 CPU、RAM、NETWORK 三个纬度上面。当然业务应用如果是 Java 系统,还有收集 JMX 的需求存在,从容器角度来讲仅需要暴露 JMX 端口即可。很多开始做容器监控的从业者会考虑使用现有基础监控设施 Zabbix 来做容器监控。但是从业界发展趋势上来说,采用 Prometheus 的解决方案会是主流方案。首先,我们可以通过 Prometheus 的架构来了解监控的流程架构图如下:
它采用 Pull 模式来主动收集监控信息,并可以采用 Grafana 定制出需要的监控大屏面板。从收集探针角度Prometheus 有很多输出指标的插件可以使用。注意插件 exporter 的工作目的是能把监控数据缓存起来,供 Prometheus 服务器来主动抓取数据。从生产级别 HA 的需求来看,目前 Prometheus 并没有提供。所有我们需要给 Prometheus Server 和 AlertManager 两个组件提供 HA 的解决方案。
HA Prometheus
当前可以实施的方案是建立两套一模一样配置的Prometheus 服务,各自独立配置并本地存储监控数据并独立报警。因为上面介绍了 PULL 的拉取采集方式,对于两个独立的 Prometheus 服务来说是完全可行的,不需要在客户端配置两份监控服务地址。记住两套 Prometheus Server 必须独立,保证一台当机不会影响另外一台 Server 的使用。
HA AlertManager
AlertManager 的 HA 配置是复杂的毕竟有两个Prometheus Server 会同时触发报警给 AlertManager用户被报警两遍并不是一个好主意。当前 HA 还在开发过程中采用了Mesh技术帮助 AlertManager 能协调出哪一个接受者可以报告这次警告。
另外,通过 PromSQL 的 DSL 语法,可以定制出任何关心的监控指标:如图:
定义报警规则的例子如下:
task:requests:rate10s =
rate(requests{job=”web”}[10s])
同时我们还关注到当前 Prometheus 2.0 即将发布 GA从 RC 版本透露新特性是时间序列数据存储的自定义实现,参考了 Facebook 的 GorillaFacebooks “Gorilla” paper有兴趣的可以关注一下。
另外Prometheus 还有一个痛点就是系统部署比较麻烦,现在推荐的方式是采用 Operator 的模式发布到K8S 集群中提供服务Prometheus Operator效率高并且云原生架构实现。
总结
Docker 日志机制已经没有什么技巧可以优化。这个也证明了容器技术的成熟度已经瓜熟蒂落,并且在日常应用运维中可以很好的实施完成。主要的实践重点在于日志体系的灵活性和日志数据处理能力方面的不断磨合和升级,这是容器技术本身无法支撑的,还需要用户结合自身情况选择发展路线。
对于监控系统,时间序列数据库的性能尤为重要。老版本的 Prometheus 基本都是在采集性能上得不到有效的发挥这次2.0版本完全重写了一遍 tsdb经过评测发现比老版本性能提升3-4倍让人刮目相看。期待正式版本的推出可以让这套云原生的监控系统得到更好的发展。
参考:
Kubernetes Logging Architecture
HA AlertManager setup (slide)
https://fabxc.org/tsdb/

View File

@ -0,0 +1,233 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
07 自动化部署分布式容器云平台实践
概述
当前云计算场景中部署一套 Kubernetes 集群系统是最常见的容器需求。在初期阶段,大量的部署经验都依赖于前人设计实现的自动化部署工具之上,比如 Ansible。但是为什么这样的自动化工具并不能彻底解决所有 Kubernetes 集群的安装问题呢,主要的矛盾在于版本的升级更新动作在分布式系统的部署过程中,由于步骤复杂,无法提供统一的自动化框架来支持。
Ansible 需要撰写大量的有状态的情况来覆盖各种可能发生的部署阶段并做出判断。这种二次判断的操作对于 Ansible 这种自动化工具是无法适应的。Ansible 这样的工具期望行为一致性,如果发生可能发生的情况,将无法有效的保证后续的步奏能有效的安装。通过本文分享的 Kubernetes 社区中提供的安装套件可以帮助大家结合实践现在适合自己的部署分布式容器云平台的方法和工具链。
Kubernetes Operationskops
生产级别 k8s 安装、升级和管理
Ansible 部署 k8s 需要投入很多精力来维护集群知识的 roles 和 inventory在日常分布式系统中会带来很多不确定的异常很难维护。所以社区提供了 kops期望能像 kubectl 一样来管理集群部署的问题。目前实现了 AWS 的支持GCE 支持属于 Beta 阶段vSphere 处于 alpha 阶段,其他平台属于计划中。对于中国区的 AWS可以选用 cn-north-1 可用区来支持。
1、配置 AWS 信息
AWS Access Key ID [None]:
AWS Secret Access Key [None]:
Default region name [None]:
Default output format [None]:
注意需要声明可用区信息
> export AWS_REGION=$(aws configure get region)
>
>
> ```
2、DNS 配置
因为工作区没有 AWS 的 Route53 支持,我们通过使用 gossip 技术可以绕过去这个限制。
3、集群状态存储
创建独立的 S3 区来存储集群安装状态。
```shell
aws s3api create-bucket --bucket prefix-example-com-state-store --create-bucket-configuration LocationConstraint=$AWS_REGION
4、创建第一个 k8s 集群
在中国区执行安装的时候,会遇到网络不稳定的情况,使用如下的环境声明可以缓解此类问题:
## Setup vars
KUBERNETES_VERSION=$(curl -fsSL --retry 5 "https://dl.k8s.io/release/stable.txt")
KOPS_VERSION=$(curl -fsSL --retry 5 "https://api.github.com/repos/kubernetes/kops/releases/latest" | grep 'tag_name' | cut -d\" -f4)
ASSET_BUCKET="some-asset-bucket"
ASSET_PREFIX=""
# Please note that this filename of cni asset may change with kubernetes version
CNI_FILENAME=cni-0799f5732f2a11b329d9e3d51b9c8f2e3759f2ff.tar.gz
export KOPS_BASE_URL=https://s3.cn-north-1.amazonaws.com.cn/$ASSET_BUCKET/kops/$KOPS_VERSION/
export CNI_VERSION_URL=https://s3.cn-north-1.amazonaws.com.cn/$ASSET_BUCKET/kubernetes/network-plugins/$CNI_FILENAME
## Download assets
KUBERNETES_ASSETS=(
network-plugins/$CNI_FILENAME
release/$KUBERNETES_VERSION/bin/linux/amd64/kube-apiserver.tar
release/$KUBERNETES_VERSION/bin/linux/amd64/kube-controller-manager.tar
release/$KUBERNETES_VERSION/bin/linux/amd64/kube-proxy.tar
release/$KUBERNETES_VERSION/bin/linux/amd64/kube-scheduler.tar
release/$KUBERNETES_VERSION/bin/linux/amd64/kubectl
release/$KUBERNETES_VERSION/bin/linux/amd64/kubelet
)
for asset in "${KUBERNETES_ASSETS[@]}"; do
dir="kubernetes/$(dirname "$asset")"
mkdir -p "$dir"
url="https://storage.googleapis.com/kubernetes-release/$asset"
wget -P "$dir" "$url"
[ "${asset##*.}" != "gz" ] && wget -P "$dir" "$url.sha1"
[ "${asset##*.}" == "tar" ] && wget -P "$dir" "${url%.tar}.docker_tag"
done
KOPS_ASSETS=(
"images/protokube.tar.gz"
"linux/amd64/nodeup"
"linux/amd64/utils.tar.gz"
)
for asset in "${KOPS_ASSETS[@]}"; do
kops_path="kops/$KOPS_VERSION/$asset"
dir="$(dirname "$kops_path")"
mkdir -p "$dir"
url="https://kubeupv2.s3.amazonaws.com/kops/$KOPS_VERSION/$asset"
wget -P "$dir" "$url"
wget -P "$dir" "$url.sha1"
done
## Upload assets
aws s3api create-bucket --bucket $ASSET_BUCKET --create-bucket-configuration LocationConstraint=$AWS_REGION
for dir in "kubernetes" "kops"; do
aws s3 sync --acl public-read "$dir" "s3://$ASSET_BUCKET/$ASSET_PREFIX$dir"
done
创建集群的时候加上参数:
--kubernetes-version https://s3.cn-north-1.amazonaws.com.cn/$ASSET_BUCKET/kubernetes/release/$KUBERNETES_VERSION
另外,还有一些镜像是托管在 gcr.io 中的比如pause-amd64 dns等。需要自行下载并提交部署到所有机器上才能做到离线安装。这里有一个技巧是通过自建的 Dockerfile 中加上
FROM gcr.io/google_containers/pause-amd64
一行,并通过 Docker Cloud 自动构建的功能,把 pause-amd64 这样的镜像同步到 docker hub 中,方便国内的 AWS 主机可以下载使用。
kubeadm——官方安装 k8s 集群命令行工具
kubeadm 主要的目的就为简化部署集群的难度提供一键式指令如kubeadm init 和 kubeadm join 让用户在安装集群的过程中获得平滑的用户体验。
kubeadm init
初始化的过程被严格定义成多个阶段来分步骤跟踪集群的状态。有些参数必须需要调优:
apiserver-advertise-address 这个地址是用来让 API Server 来通告其他集群组件的 IP 地址。
apiserver-bind-port 这个端口是 API Server 的端口默认是6443。
apiserver-cert-extra-sans 附加的主机名字或地址,并加入到证书中。例如:
--apiserver-cert-extra-sans=kubernetes.example.com,kube.example.com,10.100.245.1
cert-dir 证书地址,默认在 /etc/kubernetes/pki。
config kubeadm 的配置文件。
dry-run 这个参数告诉 kubeadm 不要执行,只是显示执行步骤。
feature-gates 通过键值对来激活 alpha/experimental 的特性。
kubernetes-version 集群初始化版本号。
node-name 主机名称。
pod-network-cidr 选择 pod 的网络网段。
service-cidr 服务 IP 地址网段。
service-dns-domain 服务域名,默认 cluster.local。
skip-preflight-checks 默认 kubeadm 运行一系列事前检查来确认系统的有效性。
skip-token-print 去掉默认打印 token 的行为。
--token 指定 token 的字符串。
token-ttl 配置 token 的过期时间默认24个小时。
kubeadm join
两种连接方式:
通过共享 token 和 ip 地址和 root CA key 来加入集群。
kubeadm join --discovery-token abcdef.1234567890abcdef --discovery-token-ca-cert-hash sha256:1234..cdef 1.2.3.4:6443
使用配置文件
kubeadm join --discovery-file path/to/file.conf
kubeadm config
kubeadm v1.8.0+ 将自动创建 ConfigMap 提供kubeadm init 需要的所有参数。
kubeadm reset
取消 kubeadm init 或者 kubeadm join 对集群做的改动。
kubeadm token
管理集群需要的 token。
还有kubeadm 可以配置使用其他 docker runtime比如 cri-o 容器引擎。
$ cat > /etc/systemd/system/kubelet.service.d/20-cri.conf <<EOF
Environment="KUBELET_EXTRA_ARGS=--container-runtime=remote --container-runtime-endpoint=$RUNTIME_ENDPOINT --feature-gates=AllAlpha=true"
EOF
$ systemctl daemon-reload
通过初始化后,就可以调用 cri-o 引擎了。
kubeadm 配置自定义镜像
默认kubeadm 会拉取 gcr.io/google_containers 下的镜像。必须通过配置文件覆盖默认的镜像仓库的地址。
imageRepository 去掉。gcr.io/google_containers 的值。
unifiedControlPlaneImage 提供面板镜像。
etcd.image 是 etcd 的镜像。
kubeadm 支持云端集成
通过指定cloud-provider 参数可以实现云端 k8s 集群的部署。比如阿里云就实现了一套 cloud provider 帮助用户在阿里云一键部署一套集群。从当前社区的热度来看k8s 社区重点专注在kubeadm的扩展第三方的 cloud provider 可以自行实现功能kubeadm 可以通过参数的方式调用阿里云的基础组件。
总结
从 Ansible 自动化工具开始K8S 集群作为典型的分布式集群系统安装范本,社区在不断的优化用户体验。我们期望集群能够自举的完成系统级配置,并且通过 kubeadm 的方式帮助用户简单的、平滑的升级集群。实现这个 kubeadm可以帮助任意系统管理员不在为分布式系统的安装犯愁只需要一行命令就可以完成集群的搭建。所有生产级别的经验都被固化在 kubeadm 的代码中,我们通过参数加以调优,实现集群的生产级别的部署工作。

View File

@ -0,0 +1,109 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
00 开篇词JVM一块难啃的骨头
你好,我是你的 JVM 讲师李国,曾任京东金融、陌陌科技高级架构师,专注分享基础架构方面的知识。
JVM 目前情况
我在工作期间因为接触的都是比较底层的中间件和操作系统会涉及大量高并发场景下的调优工作。其中JVM 的调优和故障排查,是非常重要的一项工作内容。
许多同学对 JVM 有一些恐惧这是可以理解的。JVM 是“Java 虚拟机”的意思,“虚拟”这两个字,证明了它要实现一个庞大的生态,有点类似于“操作系统”,内容肯定是非常多的。
而随着互联网进入下半场好公司对程序员的要求也水涨船高各大互联网公司的岗位描述中JVM 几乎是逃不掉的关键词,我们举几个来自拉勾网的 JD 实例。
你会发现,在 Java 高级工程师岗位要求中JVM 几乎成了必须掌握的技能点,而在面经里涉及 JVM 的知识也数不胜数,本专栏各课时涉及的知识点,也正是各大厂 Java 高级工程师面试的高频考题。
只要你是在做 Java 方面的工作JVM 便是必备的知识。
JVM 在学习过程中的难点和问题
实践资料太少,不太容易系统化
其实,我们开发人员离 JVM 很近,它也没有那么神秘。许多问题,你可能在平常的工作中就已经遇到了。
正在运行的 Java 进程,可能突然就 OOM 内存溢出了。
线上系统产生卡顿CPU 疯狂运转GC 时间飙升,严重影响了服务响应时间。
面对一堆 JVM 的参数无从下手,错失了性能提升的可能,或者因为某个参数的错误配置,产生了尴尬的负面效果。
想要了解线上应用的垃圾回收状况,却不知从何开始,服务监控状况无法掌控。
一段代码有问题,执行效率低,但就是无法找到深层次原因。
这些都是经常发生的事情,我就不止一次在半夜被报警铃声叫起,并苦于问题的追踪。别担心,我也是从这个阶段过来的,通过大量的线上实操,积累了非常丰富的经验。还记得当时花了整整一周时间,才定位到一个棘手的堆外内存泄漏问题。现在再回头看这些问题,就显得比较风轻云淡了。
相关问题太多,概念太杂了
同时JVM 的版本更新很快,造成了很多同学会对 JVM 有一些疑问。网络上的一些博主,可能会从自己的角度去分析问题,读者无法产生代入感。甚至,一些错误的知识会产生比较严重的后果,你会经常看到一些有冲突的概念。
Java 源代码是怎么变成字节码的,字节码又是怎么进入 JVM 的?
JVM 是怎么执行字节码的?哪些数据放在栈?哪些数据放在堆?
Java 的一些特性是如何与字节码产生关联的?
如何监控 JVM 的运行,才能够做到问题自动发现?
如果你有这方面的疑问,那再正常不过了。我们在专栏中将从实际的应用场景出发,来探讨一些比较深入的问题。
那为什么要学习 JVM不学习 JVM 会影响我写 Java 代码么?严格意义上来说,并不会。但是,如果不学习 JVM 你可能可以写出功能完善的代码,但是一定无法写出更加高效的代码。更别说常见的性能优化和故障排查了。
学习 JVM 有什么用?
由于 JVM 是一个虚拟的体系,它拥有目前最前沿的垃圾回收算法实现,虽然 JVM 也有一些局限性,但学习它之后,在遇到其他基于“虚拟机”的语言时,便能够触类旁通。
面试必考
学习 JVM 最重要的一点就是体系化,仅靠零零散散的知识是无法形成有效的知识系统的。这样,在回答面试官的问题时,便会陷入模棱两可的境地。如果你能够触类旁通,既有深度又有广度地做进一步升华,会让面试官眼前一亮。
职业提升
JVM 是 Java 体系中非常重要的内容不仅仅因为它是面试必考更因为它与我们的工作息息相关。同时我们也认识到JVM 是一块难啃的骨头。市面上有很多大牛分享的书籍,但大部分都是侧重于理论,不会教你什么时候用什么参数,也不会教你怎么去优化代码。理论与实践是有很大出入的,你可能非常了解 JVM 的内存模型,但等到真正发生问题时,还是会一头雾水。
如果能够理论联系实际,在面临一些棘手问题时,就能够快速定位到它的根本问题,为你的职业发展助力。
业务场景强相关
不同的业务JVM 的配置肯定也是不同的。比如高并发的互联网业务,与传统的报表导出业务,就是完全不同的两个应用场景:它们有的对服务响应时间 RT 要求比较高,不允许有长尾请求;有的对功能完整度要求比较高,不能运行到一半就宕机了。所以大家在以后的 JVM 优化前,一定要先确立场景,如果随便从网络上搬下几个配置参数进行设置,那是非常危险的。
鉴于以上这些问题,我会在课程中分享一些对线上 JVM 的实践和思考。课程中还会有很多代码示例来配合讲解,辅之以实战案例,让你对理论部分的知识有更深的理解。本门课程,我就以自己对 JVM 的理解,用尽量简单、活泼的语言,来解答这些问题。
JVM 怎么学?
为了准备这个课程,我同时研读了大量的中英文资料。我发现这方面的内容,有一个非常显著的特点,就是比较晦涩。很多大牛讲得比较深入,但你可能读着读着就进行不下去了。很容易产生当时感觉非常有道理,过几天就忘了的结果。
我在公众号xjjdog上分享了大量高价值的文章但有些需要系统性讲解的知识点我决定做成精品课程JVM 就是其中优先级比较高的。问题探讨会产生更多思想碰撞,也能加深记忆,大家可以多多交流。
我将整个课程分为四个部分,一个问题可能会从不同的角度去解析,每个课时都会做一个简单的总结。
基础原理:主要讲解 JVM 基础概念,以及内存区域划分和类加载机制等。最后,会根据需求实现一个自定义类加载器。
垃圾回收Java 中有非常丰富的垃圾回收器,此部分以理论为主,是通往高级工程师之路无法绕过的知识点。我会横向比较工作中常用的垃圾回收器并以主题深入的方式讲解 G1、GMS、ZGC 等主流垃圾回收器。
实战部分:我会模拟工作中涉及的 OOM 溢出全场景,用 23 个大型工作实例分析线上问题,并针对这些问题提供排查的具体工具的使用介绍,还会提供一个高阶的对堆外内存问题的排查思路。
进阶部分:介绍 JMM以及从字节码层面来剖析 Java 的基础特性以及并发方面的问题。还会重点分析应用较多的 Java Agent 技术。这部分内容比较底层,可以加深我们对 Java 底层实现的理解。
彩蛋:带你回顾 JVM 的历史并展望未来,即使 JVM 版本不断革新也能够洞悉未来掌握先机,最后会给你提供一份全面的 JVM 面试题,助力高级 Java 岗位面试。
你将获得什么?
建立完整的 JVM 知识体系
通过这门课程,你可以系统地学习 JVM 相关知识,而不是碎片化获取。我会以大量的实例来增加你的理解和记忆,理论结合实践,进而加深对 Java 语言的理解。
能够对线上应用进行优化和故障排查
课程中包含大量的实战排查工具,掌握它们,你能够非常容易地定位到应用中有问题的点,并提供优化思路,尤其是 MAT 等工具的使用,这通常是普通开发人员非常缺乏的一项技能。
我还会分享一些在线的 JVM 监控系统建设方案,让你实时掌控整个 JVM 的健康状况,辅助故障的排查。
面试中获取 Offer 的利器
本课程的每小节,都是 Java 面试题的重灾区。在课程中以实际工作场景为出发点来解答面试中的问题,既能在面试中回答问题的理论知识,又能以实际工作场景为例与面试官深入探讨问题,可以说通过本课程学习 JVM 是成为 Java 高级、资深工程师的必经之路。

View File

@ -0,0 +1,215 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
01 一探究竟:为什么需要 JVM它处在什么位置
从本课时开始我们就正式进入 JVM 的学习,如果你是一名软件开发工程师,在日常工作中除了 Java 这个关键词外,还有一个名词也一定经常被提及,那就是 JVM。提到 JVM 我们经常会在面试中遇到这样的问题:
为什么 Java 研发系统需要 JVM
对你 JVM 的运行原理了解多少?
我们写的 Java 代码到底是如何运行起来的?
想要在面试中完美地回答这三个问题,就需要首先了解 JVM 是什么?它和 Java 有什么关系?又与 JDK 有什么渊源?接下来,我就带你拨开这些问题的层层迷雾,想要弄清楚这些问题,我们首先需要从这三个维度去思考:
JVM 和操作系统的关系?
JVM、JRE、JDK 的关系?
Java 虚拟机规范和 Java 语言规范的关系?
弄清楚这几者的关系后,我们再以一个简单代码示例来看一下一个 Java 程序到底是如何执行的。
JVM 和操作系统的关系
在武侠小说中想要炼制一把睥睨天下的宝剑是需要下一番功夫的。除了要有上等的铸剑技术还需要一鼎经百炼的剑炉而工程师就相当于铸剑的剑师JVM 便是剑炉。
JVM 全称 Java Virtual Machine也就是我们耳熟能详的 Java 虚拟机。它能识别 .class后缀的文件并且能够解析它的指令最终调用操作系统上的函数完成我们想要的操作。
一般情况下,使用 C++ 开发的程序,编译成二进制文件后,就可以直接执行了,操作系统能够识别它;但是 Java 程序不一样,使用 javac 编译成 .class 文件之后,还需要使用 Java 命令去主动执行它,操作系统并不认识这些 .class 文件。
你可能会想,我们为什么不能像 C++ 一样,直接在操作系统上运行编译后的二进制文件呢?而非要搞一个处于程序与操作系统中间层的虚拟机呢?
这就是 JVM 的过人之处了。大家都知道Java 是一门抽象程度特别高的语言,提供了自动内存管理等一系列的特性。这些特性直接在操作系统上实现是不太可能的,所以就需要 JVM 进行一番转换。
有了上面的介绍,我们就可以做如下的类比。
JVM等同于操作系统
Java 字节码:等同于汇编语言。
Java 字节码一般都比较容易读懂,这从侧面上证明 Java 语言的抽象程度比较高。你可以把 JVM 认为是一个翻译器,会持续不断的翻译执行 Java 字节码,然后调用真正的操作系统函数,这些操作系统函数是与平台息息相关的。
如果你还是对上面的介绍有点模糊,可以参考下图:
从图中可以看到,有了 JVM 这个抽象层之后Java 就可以实现跨平台了。JVM 只需要保证能够正确执行 .class 文件,就可以运行在诸如 Linux、Windows、MacOS 等平台上了。
而 Java 跨平台的意义在于一次编译,处处运行,能够做到这一点 JVM 功不可没。比如我们在 Maven 仓库下载同一版本的 jar 包就可以到处运行,不需要在每个平台上再编译一次。
现在的一些 JVM 的扩展语言,比如 Clojure、JRuby、Groovy 等,编译到最后都是 .class 文件Java 语言的维护者,只需要控制好 JVM 这个解析器,就可以将这些扩展语言无缝的运行在 JVM 之上了。
我们用一句话概括 JVM 与操作系统之间的关系JVM 上承开发语言,下接操作系统,它的中间接口就是字节码。
而 Java 程序和我们通常使用的 C++ 程序有什么不同呢?这里用两张图进行说明。
对比这两张图可以看到 C++ 程序是编译成操作系统能够识别的 .exe 文件,而 Java 程序是编译成 JVM 能够识别的 .class 文件,然后由 JVM 负责调用系统函数执行程序。
JVM、JRE、JDK的关系
通过上面的学习我们了解到 JVM 是 Java 程序能够运行的核心。但是需要注意JVM 自己什么也干不了,你需要给它提供生产原料(.class 文件)。俗语说的好,巧妇难为无米之炊。它虽然功能强大,但仍需要为它提供 .class 文件。
仅仅是 JVM是无法完成一次编译处处运行的。它需要一个基本的类库比如怎么操作文件、怎么连接网络等。而 Java 体系很慷慨,会一次性将 JVM 运行所需的类库都传递给它。JVM 标准加上实现的一大堆基础类库,就组成了 Java 的运行时环境,也就是我们常说的 JREJava Runtime Environment
有了 JRE 之后,我们的 Java 程序便可以在浏览器中运行了。大家可以看一下自己安装的 Java 目录,如果是只需要执行一些 Java 程序,只需要一个 JRE 就足够了。
对于 JDK 来说,就更庞大了一些。除了 JREJDK 还提供了一些非常好用的小工具,比如 javac、java、jar 等。它是 Java 开发的核心,让外行也可以炼剑!
我们也可以看下 JDK 的全拼Java Development Kit。我非常怕 kit装备这个单词它就像一个无底洞预示着你永无休止的对它进行研究。JVM、JRE、JDK 它们三者之间的关系,可以用一个包含关系表示。
JDK>JRE>JVM
Java 虚拟机规范和 Java 语言规范的关系
我们通常谈到 JVM首先会想到它的垃圾回收器其实它还有很多部分比如对字节码进行解析的执行引擎等。广义上来讲JVM 是一种规范,它是最为官方、最为准确的文档;狭义上来讲,由于我们使用 Hotspot 更多一些,我们一般在谈到这个概念时,会将它们等同起来。
如果再加上我们平常使用的 Java 语言的话,可以得出下面这样一张图。这是 Java 开发人员必须要搞懂的两个规范。
左半部分是 Java 虚拟机规范,其实就是为输入和执行字节码提供一个运行环境。右半部分是我们常说的 Java 语法规范,比如 switch、for、泛型、lambda 等相关的程序,最终都会编译成字节码。而连接左右两部分的桥梁依然是 Java 的字节码。
如果 .class 文件的规格是不变的,这两部分是可以独立进行优化的。但 Java 也会偶尔扩充一下 .class 文件的格式,增加一些字节码指令,以便支持更多的特性。
我们可以把 Java 虚拟机可以看作是一台抽象的计算机,它有自己的指令集以及各种运行时内存区域,学过《计算机组成结构》的同学会在课程的后面看到非常多的相似性。
你可能会有疑问,如果我不学习 JVM会影响我写 Java 代码么?理论上,这两者没有什么必然的联系。它们之间通过 .class 文件进行交互,即使你不了解 JVM也能够写大多数的 Java 代码。就像是你写 C++ 代码一样,并不需要特别深入的了解操作系统的底层是如何实现的。
但是,如果你想要写一些比较精巧、效率比较高的代码,就需要了解一些执行层面的知识了。了解 JVM主要用在调优以及故障排查上面你会对运行中的各种资源分配有一个比较全面的掌控。
我们写的 Java 代码到底是如何运行起来的
最后,我们简单看一下一个 Java 程序的执行过程,它到底是如何运行起来的。
这里的 Java 程序是文本格式的。比如下面这段 HelloWorld.java它遵循的就是 Java 语言规范。其中,我们调用了 System.out 等模块,也就是 JRE 里提供的类库。
public class HelloWorld {
public static void main(String[] args) {
System.out.println("Hello World");
}
}
使用 JDK 的工具 javac 进行编译后,会产生 HelloWorld 的字节码。
我们一直在说 Java 字节码是沟通 JVM 与 Java 程序的桥梁,下面使用 javap 来稍微看一下字节码到底长什么样子。
0 getstatic #2 <java/lang/System.out>
3 ldc #3 <Hello World>
5 invokevirtual #4 <java/io/PrintStream.println>
8 return
Java 虚拟机采用基于栈的架构,其指令由操作码和操作数组成。这些字节码指令,就叫作 opcode。其中getstatic、ldc、invokevirtual、return 等,就是 opcode可以看到是比较容易理解的。
我们继续使用 hexdump 看一下字节码的二进制内容。与以上字节码对应的二进制,就是下面这几个数字(可以搜索一下)。
b2 00 02 12 03 b6 00 04 b1
我们可以看一下它们的对应关系。
0xb2 getstatic 获取静态字段的值
0x12 ldc 常量池中的常量值入栈
0xb6 invokevirtual 运行时方法绑定调用方法
0xb1 return void 函数返回
opcode 有一个字节的长度(0~255),意味着指令集的操作码个数不能操作 256 条。而紧跟在 opcode 后面的是被操作数。比如 b2 00 02就代表了 getstatic #2
JVM 就是靠解析这些 opcode 和操作数来完成程序的执行的。当我们使用 Java 命令运行 .class 文件的时候,实际上就相当于启动了一个 JVM 进程。
然后 JVM 会翻译这些字节码,它有两种执行方式。常见的就是解释执行,将 opcode + 操作数翻译成机器代码;另外一种执行方式就是 JIT也就是我们常说的即时编译它会在一定条件下将字节码编译成机器码之后再执行。
这些 .class 文件会被加载、存放到 metaspace 中,等待被调用,这里会有一个类加载器的概念。
而 JVM 的程序运行,都是在栈上完成的,这和其他普通程序的执行是类似的,同样分为堆和栈。比如我们现在运行到了 main 方法,就会给它分配一个栈帧。当退出方法体时,会弹出相应的栈帧。你会发现,大多数字节码指令,就是不断的对栈帧进行操作。
而其他大块数据是存放在堆上的。Java 在内存划分上会更为细致,关于这些概念,我们会在接下来的课时里进行详细介绍。
最后大家看下面的图,其中 JVM 部分,就是我们课程的要点。
选用的版本
既然 JVM 只是一个虚拟机规范,那肯定有非常多的实现。其中,最流行的要数 Oracle 的 HotSpot。
目前,最新的版本是 Java13注意最新的LTS版本是11。学技术当然要学最新的我们以后的课时就以 13 版本的 Java 为基准,来讲解发生在 JVM 上的那些事儿。
为了完成这个过程你可以打开浏览器输入下载网址https://www.oracle.com/technetwork/java/ javase/downloads/jdk13-downloads-5672538.html并安装软件。当然你也可以用稍低点的版本但是有些知识点会有些许差异。相信对于聪明的你来说这写都不算问题因为整个 JVM包括我们的调优就是在不断试错中完成的。
小结
我们再回头看看上面的三个问题。
为什么 Java 研发系统需要 JVM
JVM 解释的是类似于汇编语言的字节码需要一个抽象的运行时环境。同时这个虚拟环境也需要解决字节码加载、自动垃圾回收、并发等一系列问题。JVM 其实是一个规范,定义了 .class 文件的结构、加载机制、数据存储、运行时栈等诸多内容,最常用的 JVM 实现就是 Hotspot。
对你 JVM 的运行原理了解多少?
JVM 的生命周期是和 Java 程序的运行一样的当程序运行结束JVM 实例也跟着消失了。JVM 处于整个体系中的核心位置,关于其具体运行原理,我们在下面的课时中详细介绍。
我们写的 Java 代码到底是如何运行起来的?
一个 Java 程序,首先经过 javac 编译成 .class 文件,然后 JVM 将其加载到元数据区执行引擎将会通过混合模式执行这些字节码。执行时会翻译成操作系统相关的函数。JVM 作为 .class 文件的黑盒存在,输入字节码,调用操作系统函数。
过程如下Java 文件->编译器>字节码->JVM->机器码。
总结
到这里本课时的内容就全部讲完了,今天我们分别从三个角度,了解了 JVM 在 Java 研发体系中的位置,并以一个简单的程序,看了下一个 Java 程序基本的执行过程。
我们所说的 JVM狭义上指的就 HotSpot。如非特殊说明我们都以 HotSpot 为准。我们了解到Java 之所以成为跨平台,就是由于 JVM 的存在。Java 的字节码,是沟通 Java 语言与 JVM 的桥梁,同时也是沟通 JVM 与操作系统的桥梁。
JVM 是一个非常小的集合,我们常说的 Java 运行时环境,就包含 JVM 和一部分基础类库。如果加上我们常用的一些开发工具,就构成了整个 JDK。我们讲解 JVM 就聚焦在字节码的执行上面。
Java 虚拟机采用基于栈的架构,有比较丰富的 opcode。这些字节码可以解释执行也可以编译成机器码运行在底层硬件上可以说 JVM 是一种混合执行的策略。

View File

@ -0,0 +1,155 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
02 大厂面试题:你不得不掌握的 JVM 内存管理
本课时我们主要讲解 JVM 的内存划分以及栈上的执行过程。这块内容在面试中主要涉及以下这 3 个面试题:
JVM 是如何进行内存区域划分的?
JVM 如何高效进行内存管理?
为什么需要有元空间,它又涉及什么问题?
带着这 3 个问题,我们开始今天的学习,关于内存划分的知识我希望在本课时你能够理解就可以,不需要死记硬背,因为在后面的课时我们会经常使用到本课时学习的内容,也会结合工作中的场景具体问题具体分析,这样你可以对 JVM 的内存获得更深刻的认识。
首先第一个问题JVM的内存区域是怎么高效划分的这也是一个高频的面试题。很多同学可能通过死记硬背的方式来应对这个问题这样不仅对知识没有融会贯通在面试中还很容易忘记答案。
为什么要问到 JVM 的内存区域划分呢?因为 Java 引以为豪的就是它的自动内存管理机制。相比于 C++的手动内存管理、复杂难以理解的指针等Java 程序写起来就方便的多。
然而这种呼之即来挥之即去的内存申请和释放方式,自然也有它的代价。为了管理这些快速的内存申请释放操作,就必须引入一个池子来延迟这些内存区域的回收操作。
我们常说的内存回收,就是针对这个池子的操作。我们把上面说的这个池子,叫作堆,可以暂时把它看成一个整体。
JVM 内存布局
程序想要运行,就需要数据。有了数据,就需要在内存上存储。那你可以回想一下,我们的 C++ 程序是怎么运行的?是不是也是这样?
Java 程序的数据结构是非常丰富的。其中的内容,举一些例子:
静态成员变量
动态成员变量
区域变量
短小紧凑的对象声明
庞大复杂的内存申请
这么多不同的数据结构,到底是在什么地方存储的,它们之间又是怎么进行交互的呢?是不是经常在面试的时候被问到这些问题?
我们先看一下 JVM 的内存布局。随着 Java 的发展内存布局一直在调整之中。比如Java 8 及之后的版本,彻底移除了持久代,而使用 Metaspace 来进行替代。这也表示着 -XX:PermSize 和 -XX:MaxPermSize 等参数调优,已经没有了意义。但大体上,比较重要的内存区域是固定的。
JVM 内存区域划分如图所示,从图中我们可以看出:
JVM 堆中的数据是共享的,是占用内存最大的一块区域。
可以执行字节码的模块叫作执行引擎。
执行引擎在线程切换时怎么恢复?依靠的就是程序计数器。
JVM 的内存划分与多线程是息息相关的。像我们程序中运行时用到的栈,以及本地方法栈,它们的维度都是线程。
本地内存包含元数据区和一些直接内存。
一般情况下,只要你能答出上面这些主要的区域,面试官都会满意的点头。但如果深挖下去,可能就有同学就比较头疼了。下面我们就详细看下这个过程。
虚拟机栈
栈是什么样的数据结构?你可以想象一下子弹上膛的这个过程,后进的子弹最先射出,最上面的子弹就相当于栈顶。
我们在上面提到Java 虚拟机栈是基于线程的。哪怕你只有一个 main() 方法,也是以线程的方式运行的。在线程的生命周期中,参与计算的数据会频繁地入栈和出栈,栈的生命周期是和线程一样的。
栈里的每条数据,就是栈帧。在每个 Java 方法被调用的时候,都会创建一个栈帧,并入栈。一旦完成相应的调用,则出栈。所有的栈帧都出栈后,线程也就结束了。每个栈帧,都包含四个区域:
局部变量表
操作数栈
动态连接
返回地址
我们的应用程序,就是在不断操作这些内存空间中完成的。
本地方法栈是和虚拟机栈非常相似的一个区域,它服务的对象是 native 方法。你甚至可以认为虚拟机栈和本地方法栈是同一个区域,这并不影响我们对 JVM 的了解。
这里有一个比较特殊的数据类型叫作 returnAdress。因为这种类型只存在于字节码层面所以我们平常打交道的比较少。对于 JVM 来说,程序就是存储在方法区的字节码指令,而 returnAddress 类型的值就是指向特定指令内存地址的指针。
这部分有两个比较有意思的内容,面试中说出来会让面试官眼前一亮。
这里有一个两层的栈。第一层是栈帧,对应着方法;第二层是方法的执行,对应着操作数。注意千万不要搞混了。
你可以看到,所有的字节码指令,其实都会抽象成对栈的入栈出栈操作。执行引擎只需要傻瓜式的按顺序执行,就可以保证它的正确性。
这一点很神奇,也是基础。我们接下来从线程角度看一下里面的内容。
程序计数器
那么你设想一下,如果我们的程序在线程之间进行切换,凭什么能够知道这个线程已经执行到什么地方呢?
既然是线程,就代表它在获取 CPU 时间片上,是不可预知的,需要有一个地方,对线程正在运行的点位进行缓冲记录,以便在获取 CPU 时间片时能够快速恢复。
就好比你停下手中的工作,倒了杯茶,然后如何继续之前的工作?
程序计数器是一块较小的内存空间,它的作用可以看作是当前线程所执行的字节码的行号指示器。这里面存的,就是当前线程执行的进度。下面这张图,能够加深大家对这个过程的理解。
可以看到,程序计数器也是因为线程而产生的,与虚拟机栈配合完成计算操作。程序计数器还存储了当前正在运行的流程,包括正在执行的指令、跳转、分支、循环、异常处理等。
我们可以看一下程序计数器里面的具体内容。下面这张图,就是使用 javap 命令输出的字节码。大家可以看到在每个 opcode 前面,都有一个序号。就是图中红框中的偏移地址,你可以认为它们是程序计数器的内容。
堆是 JVM 上最大的内存区域,我们申请的几乎所有的对象,都是在这里存储的。我们常说的垃圾回收,操作的对象就是堆。
堆空间一般是程序启动时,就申请了,但是并不一定会全部使用。
随着对象的频繁创建,堆空间占用的越来越多,就需要不定期的对不再使用的对象进行回收。这个在 Java 中,就叫作 GCGarbage Collection
由于对象的大小不一,在长时间运行后,堆空间会被许多细小的碎片占满,造成空间浪费。所以,仅仅销毁对象是不够的,还需要堆空间整理。这个过程非常的复杂,我们会在后面有专门的课时进行介绍。
那一个对象创建的时候,到底是在堆上分配,还是在栈上分配呢?这和两个方面有关:对象的类型和在 Java 类中存在的位置。
Java 的对象可以分为基本数据类型和普通对象。
对于普通对象来说JVM 会首先在堆上创建对象,然后在其他地方使用的其实是它的引用。比如,把这个引用保存在虚拟机栈的局部变量表中。
对于基本数据类型来说byte、short、int、long、float、double、char),有两种情况。
我们上面提到,每个线程拥有一个虚拟机栈。当你在方法体内声明了基本数据类型的对象,它就会在栈上直接分配。其他情况,都是在堆上分配。
注意,像 int[] 数组这样的内容,是在堆上分配的。数组并不是基本数据类型。
这就是 JVM 的基本的内存分配策略。而堆是所有线程共享的,如果是多个线程访问,会涉及数据同步问题。这同样是个大话题,我们在这里先留下一个悬念。
元空间
关于元空间,我们还是以一个非常高频的面试题开始:“为什么有 Metaspace 区域?它有什么问题?”
说到这里,你应该回想一下类与对象的区别。对象是一个活生生的个体,可以参与到程序的运行中;类更像是一个模版,定义了一系列属性和操作。那么你可以设想一下。我们前面生成的 A.class是放在 JVM 的哪个区域的?
想要问答这个问题,就不得不提下 Java 的历史。在 Java 8 之前,这些类的信息是放在一个叫 Perm 区的内存里面的。更早版本,甚至 String.intern 相关的运行时常量池也放在这里。这个区域有大小限制,很容易造成 JVM 内存溢出,从而造成 JVM 崩溃。
Perm 区在 Java 8 中已经被彻底废除,取而代之的是 Metaspace。原来的 Perm 区是在堆上的,现在的元空间是在非堆上的,这是背景。关于它们的对比,可以看下这张图。
然后元空间的好处也是它的坏处。使用非堆可以使用操作系统的内存JVM 不会再出现方法区的内存溢出;但是,无限制的使用会造成操作系统的死亡。所以,一般也会使用参数 -XX:MaxMetaspaceSize 来控制大小。
方法区,作为一个概念,依然存在。它的物理存储的容器,就是 Metaspace。我们将在后面的课时中再次遇到它。现在你只需要了解到这个区域存储的内容包括类的信息、常量池、方法数据、方法代码就可以了。
小结
好了,到这里本课时的基本内容就讲完了,针对这块的内容在面试中还经常会遇到下面这两个问题。
我们常说的字符串常量,存放在哪呢?
由于常量池,在 Java 7 之后,放到了堆中,我们创建的字符串,将会在堆上分配。
堆、非堆、本地内存,有什么关系?
关于它们的关系,我们可以看一张图。在我的感觉里,堆是软绵绵的,松散而有弹性;而非堆是冰冷生硬的,内存非常紧凑。
大家都知道JVM 在运行时,会从操作系统申请大块的堆内内存,进行数据的存储。但是,堆外内存也就是申请后操作系统剩余的内存,也会有部分受到 JVM 的控制。比较典型的就是一些 native 关键词修饰的方法,以及对内存的申请和处理。
在 Linux 机器上,使用 top 或者 ps 命令,在大多数情况下,能够看到 RSS 段(实际的内存占用),是大于给 JVM 分配的堆内存的。
如果你申请了一台系统内存为 2GB 的主机,可能 JVM 能用的就只有 1GB这便是一个限制。
总结
JVM 的运行时区域是栈,而存储区域是堆。很多变量,其实在编译期就已经固定了。.class 文件的字节码,由于助记符的作用,理解起来并不是那么吃力,我们将在课程最后几个课时,从字节码层面看一下多线程的特性。
JVM 的运行时特性,以及字节码,是比较偏底层的知识。本课时属于初步介绍,有些部分并未深入讲解。希望你应该能够在脑海里建立一个 Java 程序怎么运行的概念,以便我们在后面的课时中,提到相应的内存区域时,有个整体的印象。

View File

@ -0,0 +1,429 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
03 大厂面试题:从覆盖 JDK 的类开始掌握类的加载机制
本课时我们主要从覆盖 JDK 的类开始讲解 JVM 的类加载机制。其实JVM 的类加载机制和 Java 的类加载机制类似,但 JVM 的类加载过程稍有些复杂。
前面课时我们讲到JVM 通过加载 .class 文件,能够将其中的字节码解析成操作系统机器码。那这些文件是怎么加载进来的呢?又有哪些约定?接下来我们就详细介绍 JVM 的类加载机制,同时介绍三个实际的应用场景。
我们首先看几个面试题。
我们能够通过一定的手段,覆盖 HashMap 类的实现么?
有哪些地方打破了 Java 的类加载机制?
如何加载一个远程的 .class 文件?怎样加密 .class 文件?
关于类加载,很多同学都知道双亲委派机制,但这明显不够。面试官可能要你讲出几个能打破这个机制的例子,这个时候不要慌。上面几个问题,是我在接触的一些比较高级的面试场景中,遇到的一些问法。在平常的工作中,也有大量的相关应用,我们会理论联系实践综合分析这些问题。
类加载过程
现实中并不是说,我把一个文件修改成 .class 后缀,就能够被 JVM 识别。类的加载过程非常复杂,主要有这几个过程:加载、验证、准备、解析、初始化。这些术语很多地方都出现过,我们不需要死记硬背,而应该要了解它背后的原理和要做的事情。
如图所示。大多数情况下,类会按照图中给出的顺序进行加载。下面我们就来分别介绍下这个过程。
加载
加载的主要作用是将外部的 .class 文件,加载到 Java 的方法区内,你可以回顾一下我们在上一课时讲的内存区域图。加载阶段主要是找到并加载类的二进制数据,比如从 jar 包里或者 war 包里找到它们。
验证
肯定不能任何 .class 文件都能加载,那样太不安全了,容易受到恶意代码的攻击。验证阶段在虚拟机整个类加载过程中占了很大一部分,不符合规范的将抛出 java.lang.VerifyError 错误。像一些低版本的 JVM是无法加载一些高版本的类库的就是在这个阶段完成的。
准备
从这部分开始,将为一些类变量分配内存,并将其初始化为默认值。此时,实例对象还没有分配内存,所以这些动作是在方法区上进行的。
我们顺便看一道面试题。下面两段代码code-snippet 1 将会输出 0而 code-snippet 2 将无法通过编译。
code-snippet 1
public class A {
static int a ;
public static void main(String[] args) {
System.out.println(a);
}
}
code-snippet 2
public class A {
public static void main(String[] args) {
int a ;
System.out.println(a);
}
}
为什么会有这种区别呢?
这是因为局部变量不像类变量那样存在准备阶段。类变量有两次赋初始值的过程,一次在准备阶段,赋予初始值(也可以是指定值);另外一次在初始化阶段,赋予程序员定义的值。
因此,即使程序员没有为类变量赋值也没有关系,它仍然有一个默认的初始值。但局部变量就不一样了,如果没有给它赋初始值,是不能使用的。
解析
解析在类加载中是非常非常重要的一环,是将符号引用替换为直接引用的过程。这句话非常的拗口,其实理解起来也非常的简单。
符号引用是一种定义,可以是任何字面上的含义,而直接引用就是直接指向目标的指针、相对偏移量。
直接引用的对象都存在于内存中,你可以把通讯录里的女友手机号码,类比为符号引用,把面对面和你吃饭的人,类比为直接引用。
解析阶段负责把整个类激活,串成一个可以找到彼此的网,过程不可谓不重要。那这个阶段都做了哪些工作呢?大体可以分为:
类或接口的解析
类方法解析
接口方法解析
字段解析
我们来看几个经常发生的异常,就与这个阶段有关。
java.lang.NoSuchFieldError 根据继承关系从下往上,找不到相关字段时的报错。
java.lang.IllegalAccessError 字段或者方法,访问权限不具备时的错误。
java.lang.NoSuchMethodError 找不到相关方法时的错误。
解析过程保证了相互引用的完整性,把继承与组合推进到运行时。
初始化
如果前面的流程一切顺利的话,接下来该初始化成员变量了,到了这一步,才真正开始执行一些字节码。
接下来是另一道面试题,你可以猜想一下,下面的代码,会输出什么?
public class A {
static int a = 0 ;
static {
a = 1;
b = 1;
}
static int b = 0;
public static void main(String[] args) {
System.out.println(a);
System.out.println(b);
}
}
结果是 1 0。a 和 b 唯一的区别就是它们的 static 代码块的位置。
这就引出一个规则static 语句块,只能访问到定义在 static 语句块之前的变量。所以下面的代码是无法通过编译的。
static {
b = b + 1;
}
static int b = 0;
我们再来看第二个规则JVM 会保证在子类的初始化方法执行之前,父类的初始化方法已经执行完毕。
所以JVM 第一个被执行的类初始化方法一定是 java.lang.Object。另外也意味着父类中定义的 static 语句块要优先于子类的。
说到这里,不得不再说一个面试题: 方法和 方法有什么区别?
主要是为了让你弄明白类的初始化和对象的初始化之间的差别。
public class A {
static {
System.out.println("1");
}
public A(){
System.out.println("2");
}
}
public class B extends A {
static{
System.out.println("a");
}
public B(){
System.out.println("b");
}
public static void main(String[] args){
A ab = new B();
ab = new B();
}
}
先公布下答案:
1
a
2
b
2
b
你可以看下这张图。其中 static 字段和 static 代码块,是属于类的,在类的加载的初始化阶段就已经被执行。类信息会被存放在方法区,在同一个类加载器下,这些信息有一份就够了,所以上面的 static 代码块只会执行一次,它对应的是 方法。
而对象初始化就不一样了。通常,我们在 new 一个新对象的时候,都会调用它的构造方法,就是 ,用来初始化对象的属性。每次新建对象的时候,都会执行。
所以,上面代码的 static 代码块只会执行一次,对象的构造方法执行两次。再加上继承关系的先后原则,不难分析出正确结果。
类加载器
整个类加载过程任务非常繁重,虽然这活儿很累,但总得有人干。类加载器做的就是上面 5 个步骤的事。
如果你在项目代码里,写一个 java.lang 的包,然后改写 String 类的一些行为编译后发现并不能生效。JRE 的类当然不能轻易被覆盖,否则会被别有用心的人利用,这就太危险了。
那类加载器是如何保证这个过程的安全性呢?其实,它是有着严格的等级制度的。
几个类加载器
首先,我们介绍几个不同等级的类加载器。
Bootstrap ClassLoader
这是加载器中的大 Boss任何类的加载行为都要经它过问。它的作用是加载核心类库也就是 rt.jar、resources.jar、charsets.jar 等。当然这些 jar 包的路径是可以指定的,-Xbootclasspath 参数可以完成指定操作。
这个加载器是 C++ 编写的,随着 JVM 启动。
Extention ClassLoader
扩展类加载器,主要用于加载 lib/ext 目录下的 jar 包和 .class 文件。同样的,通过系统变量 java.ext.dirs 可以指定这个目录。
这个加载器是个 Java 类,继承自 URLClassLoader。
App ClassLoader
这是我们写的 Java 类的默认加载器,有时候也叫作 System ClassLoader。一般用来加载 classpath 下的其他所有 jar 包和 .class 文件,我们写的代码,会首先尝试使用这个类加载器进行加载。
Custom ClassLoader
自定义加载器,支持一些个性化的扩展功能。
双亲委派机制
关于双亲委派机制的问题面试中经常会被问到,你可能已经倒背如流了。
双亲委派机制的意思是除了顶层的启动类加载器以外,其余的类加载器,在加载之前,都会委派给它的父加载器进行加载。这样一层层向上传递,直到祖先们都无法胜任,它才会真正的加载。
打个比方。有一个家族,都是一些听话的孩子。孙子想要买一块棒棒糖,最终都要经过爷爷过问,如果力所能及,爷爷就直接帮孙子买了。
但你有没有想过,“类加载的双亲委派机制,双亲在哪里?明明都是单亲?”
我们还是用一张图来讲解。可以看到除了启动类加载器每一个加载器都有一个parent并没有所谓的双亲。但是由于翻译的问题这个叫法已经非常普遍了一定要注意背后的差别。
我们可以翻阅 JDK 代码的 ClassLoader#loadClass 方法,来看一下具体的加载过程。和我们描述的一样,它首先使用 parent 尝试进行类加载parent 失败后才轮到自己。同时,我们也注意到,这个方法是可以被覆盖的,也就是双亲委派机制并不一定生效。
这个模型的好处在于 Java 类有了一种优先级的层次划分关系。比如 Object 类,这个毫无疑问应该交给最上层的加载器进行加载,即使是你覆盖了它,最终也是由系统默认的加载器进行加载的。
如果没有双亲委派模型,就会出现很多个不同的 Object 类,应用程序会一片混乱。
一些自定义加载器
下面我们就来聊一聊可以打破双亲委派机制的一些案例。为了支持一些自定义加载类多功能的需求Java 设计者其实已经作出了一些妥协。
案例一tomcat
tomcat 通过 war 包进行应用的发布,它其实是违反了双亲委派机制原则的。简单看一下 tomcat 类加载器的层次结构。
对于一些需要加载的非基础类,会由一个叫作 WebAppClassLoader 的类加载器优先加载。等它加载不到的时候,再交给上层的 ClassLoader 进行加载。这个加载器用来隔绝不同应用的 .class 文件,比如你的两个应用,可能会依赖同一个第三方的不同版本,它们是相互没有影响的。
如何在同一个 JVM 里,运行着不兼容的两个版本,当然是需要自定义加载器才能完成的事。
那么 tomcat 是怎么打破双亲委派机制的呢?可以看图中的 WebAppClassLoader它加载自己目录下的 .class 文件,并不会传递给父类的加载器。但是,它却可以使用 SharedClassLoader 所加载的类,实现了共享和分离的功能。
但是你自己写一个 ArrayList放在应用目录里tomcat 依然不会加载。它只是自定义的加载器顺序不同,但对于顶层来说,还是一样的。
案例二SPI
Java 中有一个 SPI 机制,全称是 Service Provider Interface是 Java 提供的一套用来被第三方实现或者扩展的 API它可以用来启用框架扩展和替换组件。
这个说法可能比较晦涩,但是拿我们常用的数据库驱动加载来说,就比较好理解了。在使用 JDBC 写程序之前,通常会调用下面这行代码,用于加载所需要的驱动类。
Class.forName("com.mysql.jdbc.Driver")
这只是一种初始化模式,通过 static 代码块显式地声明了驱动对象,然后把这些信息,保存到底层的一个 List 中。这种方式我们不做过多的介绍,因为这明显就是一个接口编程的思路,没什么好奇怪的。
但是你会发现,即使删除了 Class.forName 这一行代码,也能加载到正确的驱动类,什么都不需要做,非常的神奇,它是怎么做到的呢?
我们翻开 MySQL 的驱动代码,发现了一个奇怪的文件。之所以能够发生这样神奇的事情,就是在这里实现的。
路径:
mysql-connector-java-8.0.15.jar!/META-INF/services/java.sql.Driver
里面的内容是:
com.mysql.cj.jdbc.Driver
通过在 META-INF/services 目录下,创建一个以接口全限定名为命名的文件(内容为实现类的全限定名),即可自动加载这一种实现,这就是 SPI。
SPI 实际上是“基于接口的编程+策略模式+配置文件”组合实现的动态加载机制,主要使用 java.util.ServiceLoader 类进行动态装载。
这种方式,同样打破了双亲委派的机制。
DriverManager 类和 ServiceLoader 类都是属于 rt.jar 的。它们的类加载器是 Bootstrap ClassLoader也就是最上层的那个。而具体的数据库驱动却属于业务代码这个启动类加载器是无法加载的。这就比较尴尬了虽然凡事都要祖先过问但祖先没有能力去做这件事情怎么办
我们可以一步步跟踪代码,来看一下这个过程。
//part1:DriverManager::loadInitialDrivers
//jdk1.8 之后变成了lazy的ensureDriversInitialized
...
ServiceLoader <Driver> loadedDrivers = ServiceLoader.load(Driver.class);
Iterator<Driver> driversIterator = loadedDrivers.iterator();
...
//part2:ServiceLoader::load
public static <T> ServiceLoader<T> load(Class<T> service) {
ClassLoader cl = Thread.currentThread().getContextClassLoader();
return ServiceLoader.load(service, cl);
}
通过代码你可以发现 Java 玩了个魔术,它把当前的类加载器,设置成了线程的上下文类加载器。那么,对于一个刚刚启动的应用程序来说,它当前的加载器是谁呢?也就是说,启动 main 方法的那个加载器,到底是哪一个?
所以我们继续跟踪代码。找到 Launcher 类,就是 jre 中用于启动入口函数 main 的类。我们在 Launcher 中找到以下代码。
public Launcher() {
Launcher.ExtClassLoader var1;
try {
var1 = Launcher.ExtClassLoader.getExtClassLoader();
} catch (IOException var10) {
throw new InternalError("Could not create extension class loader", var10);
}
try {
this.loader = Launcher.AppClassLoader.getAppClassLoader(var1);
} catch (IOException var9) {
throw new InternalError("Could not create application class loader", var9);
}
Thread.currentThread().setContextClassLoader(this.loader);
...
}
到此为止,事情就比较明朗了,当前线程上下文的类加载器,是应用程序类加载器。使用它来加载第三方驱动,是没有什么问题的。
我们之所以花大量的篇幅来介绍这个过程,第一,可以让你更好的看到一个打破规则的案例。第二,这个问题面试时出现的几率也是比较高的,你需要好好理解。
案例三OSGi
OSGi 曾经非常流行Eclipse 就使用 OSGi 作为插件系统的基础。OSGi 是服务平台的规范,旨在用于需要长运行时间、动态更新和对运行环境破坏最小的系统。
OSGi 规范定义了很多关于包生命周期,以及基础架构和绑定包的交互方式。这些规则,通过使用特殊 Java 类加载器来强制执行,比较霸道。
比如,在一般 Java 应用程序中classpath 中的所有类都对所有其他类可见这是毋庸置疑的。但是OSGi 类加载器基于 OSGi 规范和每个绑定包的 manifest.mf 文件中指定的选项,来限制这些类的交互,这就让编程风格变得非常的怪异。但我们不难想象,这种与直觉相违背的加载方式,肯定是由专用的类加载器来实现的。
随着 jigsaw 的发展(旨在为 Java SE 平台设计、实现一个标准的模块系统),我个人认为,现在的 OSGi意义已经不是很大了。OSGi 是一个庞大的话题,你只需要知道,有这么一个复杂的东西,实现了模块化,每个模块可以独立安装、启动、停止、卸载,就可以了。
不过,如果你有机会接触相关方面的工作,也许会不由的发出感叹:原来 Java 的类加载器,可以玩出这么多花样。
如何替换 JDK 的类
让我们回到本课时开始的问题,如何替换 JDK 中的类?比如,我们现在就拿 HashMap为例。
当 Java 的原生 API 不能满足需求时,比如我们要修改 HashMap 类,就必须要使用到 Java 的 endorsed 技术。我们需要将自己的 HashMap 类,打包成一个 jar 包,然后放到 -Djava.endorsed.dirs 指定的目录中。注意类名和包名,应该和 JDK 自带的是一样的。但是java.lang 包下面的类除外,因为这些都是特殊保护的。
因为我们上面提到的双亲委派机制,是无法直接在应用中替换 JDK 的原生类的。但是,有时候又不得不进行一下增强、替换,比如你想要调试一段代码,或者比 Java 团队早发现了一个 Bug。所以Java 提供了 endorsed 技术,用于替换这些类。这个目录下的 jar 包,会比 rt.jar 中的文件,优先级更高,可以被最先加载到。
小结
通过本课时的学习我们可以了解到,一个 Java 类的加载,经过了加载、验证、准备、解析、初始化几个过程,每一个过程都划清了各自负责的事情。
接下来,我们了解到 Java 自带的三个类加载器。同时了解到main 方法的线程上下文加载器,其实是 Application ClassLoader。
一般情况下,类加载是遵循双亲委派机制的。我们也认识到,这个双亲,很有问题。通过 3 个案例的学习和介绍,可以看到有很多打破这个规则的情况。类加载器通过开放的 API让加载过程更加灵活。
Java 的类加载器是非常重要的知识点,也是面试常考的知识点,本课时提供了多个面试题,你可以实际操作体验一下。

View File

@ -0,0 +1,411 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
04 动手实践:从栈帧看字节码是如何在 JVM 中进行流转的
在上一课时我们掌握了 JVM 的内存区域划分,以及 .class 文件的加载机制。也了解到很多初始化动作是在不同的阶段发生的。
但你可能仍对以下这些问题有疑问:
怎么查看字节码文件?
字节码文件长什么样子?
对象初始化之后,具体的字节码又是怎么执行的?
带着这些疑问,我们进入本课时的学习,本课时将带你动手实践,详细分析一个 Java 文件产生的字节码,并从栈帧层面看一下字节码的具体执行过程。
工具介绍
工欲善其事,必先利其器。在开始本课时的内容之前,先给你介绍两个分析字节码的小工具。
javap
第一个小工具是 javapjavap 是 JDK 自带的反解析工具。它的作用是将 .class 字节码文件解析成可读的文件格式。我们在第一课时,就是用的它输出了 HelloWorld 的内容。
在使用 javap 时我一般会添加 -v 参数,尽量多打印一些信息。同时,我也会使用 -p 参数,打印一些私有的字段和方法。使用起来大概是这样:
javap -p -v HelloWorld
在 Stack Overflow 上有一个非常有意思的问题:我在某个类中增加一行注释之后,为什么两次生成的 .class 文件,它们的 MD5 是不一样的?
这是因为在 javac 中可以指定一些额外的内容输出到字节码。经常用的有
javac -g:lines 强制生成 LineNumberTable。
javac -g:vars 强制生成 LocalVariableTable。
javac -g 生成所有的 debug 信息。
为了观察字节码的流转,我们本课时就会使用到这些参数。
jclasslib
如果你不太习惯使用命令行的操作,还可以使用 jclasslibjclasslib 是一个图形化的工具,能够更加直观的查看字节码中的内容。它还分门别类的对类中的各个部分进行了整理,非常的人性化。同时,它还提供了 Idea 的插件,你可以从 plugins 中搜索到它。
如果你在其中看不到一些诸如 LocalVariableTable 的信息,记得在编译代码的时候加上我们上面提到的这些参数。
jclasslib 的下载地址https://github.com/ingokegel/jclasslib
类加载和对象创建的时机
接下来,我们来看一个稍微复杂的例子,来具体看一下类加载和对象创建的过程。
首先,我们写一个最简单的 Java 程序 A.java。它有一个公共方法 test还有一个静态成员变量和动态成员变量。
class B {
private int a = 1234;
static long C = 1111;
public long test(long num) {
long ret = this.a + num + C;
return ret;
}
}
public class A {
private B b = new B();
public static void main(String[] args) {
A a = new A();
long num = 4321 ;
long ret = a.b.test(num);
System.out.println(ret);
}
}
前面我们提到,类的初始化发生在类加载阶段,那对象都有哪些创建方式呢?除了我们常用的 new还有下面这些方式
使用 Class 的 newInstance 方法。
使用 Constructor 类的 newInstance 方法。
反序列化。
使用 Object 的 clone 方法。
其中,后面两种方式没有调用到构造函数。
当虚拟机遇到一条 new 指令时,首先会检查这个指令的参数能否在常量池中定位一个符号引用。然后检查这个符号引用的类字节码是否加载、解析和初始化。如果没有,将执行对应的类加载过程。
拿我们上面的代码来说,执行 A 代码,在调用 private B b = new B() 时,就会触发 B 类的加载。
让我们结合上图回顾一下前面章节的内容。A 和 B 会被加载到元空间的方法区,进入 main 方法后,将会交给执行引擎执行。这个执行过程是在栈上完成的,其中有几个重要的区域,包括虚拟机栈、程序计数器等。接下来我们详细看一下虚拟机栈上的执行过程。
查看字节码
命令行查看字节码
使用下面的命令编译源代码 A.java。如果你用的是 Idea可以直接将参数追加在 VM options 里面。
javac -g:lines -g:vars A.java
这将强制生成 LineNumberTable 和 LocalVariableTable。
然后使用 javap 命令查看 A 和 B 的字节码。
javap -p -v A
javap -p -v B
这个命令,不仅会输出行号、本地变量表信息、反编译汇编代码,还会输出当前类用到的常量池等信息。由于内容很长,这里就不具体展示了,你可以使用上面的命令实际操作一下就可以了。
注意 javap 中的如下字样。
1: invokespecial #1 // Method java/lang/Object."<init>":()V
可以看到对象的初始化,首先是调用了 Object 类的初始化方法。注意这里是 而不是 。
#2 = Fieldref #6.#27 // B.a:I
它其实直接拼接了 #13#14 的内容。
#6 = Class #29 // B
#27 = NameAndType #8:#9 // a:I
...
#8 = Utf8 a
#9 = Utf8 I
你会注意到 :I 这样特殊的字符。它们也是有意义的,如果你经常使用 jmap 这种命令,应该不会陌生。大体包括:
B 基本类型 byte
C 基本类型 char
D 基本类型 double
F 基本类型 float
I 基本类型 int
J 基本类型 long
S 基本类型 short
Z 基本类型 boolean
V 特殊类型 void
L 对象类型,以分号结尾,如 Ljava/lang/Object;
[Ljava/lang/String; 数组类型,每一位使用一个前置的”[“字符来描述
我们注意到 code 区域,有非常多的二进制指令。如果你接触过汇编语言,会发现它们之间其实有一定的相似性。但这些二进制指令,并不是操作系统能够认识的,它们是提供给 JVM 运行的源材料。
可视化查看字节码
接下来,我们就可以使用更加直观的工具 jclasslib来查看字节码中的具体内容了。
我们以 B.class 文件为例,来查看它的内容。
首先,我们能够看到 Constant Pool常量池这些内容就存放于我们的 Metaspace 区域,属于非堆。
常量池包含 .class 文件常量池、运行时常量池、String 常量池等部分,大多是一些静态内容。
接下来,可以看到两个默认的 和 方法。以下截图是 test 方法的 code 区域,比命令行版的更加直观。
继续往下看,我们看到了 LocalVariableTable 的三个变量。其中slot 0 指向的是 this 关键字。该属性的作用是描述帧栈中局部变量与源码中定义的变量之间的关系。如果没有这些信息,那么在 IDE 中引用这个方法时,将无法获取到方法名,取而代之的则是 arg0 这样的变量名。
本地变量表的 slot 是可以复用的。注意一个有意思的地方index 的最大值为 3证明了本地变量表同时最多能够存放 4 个变量。
另外,我们观察到还有 LineNumberTable 等选项。该属性的作用是描述源码行号与字节码行号(字节码偏移量)之间的对应关系,有了这些信息,在 debug 时,就能够获取到发生异常的源代码行号。
test 函数执行过程
Code 区域介绍
test 函数同时使用了成员变量 a、静态变量 C以及输入参数 num。我们此时说的函数执行内存其实就是在虚拟机栈上分配的。下面这些内容就是 test 方法的字节码。
public long test(long);
descriptor: (J)J
flags: ACC_PUBLIC
Code:
stack=4, locals=5, args_size=2
0: aload_0
1: getfield #2 // Field a:I
4: i2l
5: lload_1
6: ladd
7: getstatic #3 // Field C:J
10: ladd
11: lstore_3
12: lload_3
13: lreturn
LineNumberTable:
line 13: 0
line 14: 12
LocalVariableTable:
Start Length Slot Name Signature
0 14 0 this LB;
0 14 1 num J
12 2 3 ret J
我们介绍一下比较重要的 3 三个数值。
首先,注意 stack 字样,它此时的数值为 4表明了 test 方法的最大操作数栈深度为 4。JVM 运行时,会根据这个数值,来分配栈帧中操作栈的深度。
相对应的locals 变量存储了局部变量的存储空间。它的单位是 Slot可以被重用。其中存放的内容包括
this
方法参数
异常处理器的参数
方法体中定义的局部变量
args_size 就比较好理解。它指的是方法的参数个数,因为每个方法都有一个隐藏参数 this所以这里的数字是 2。
字节码执行过程
我们稍微回顾一下 JVM 运行时的相关内容。main 线程会拥有两个主要的运行时区域Java 虚拟机栈和程序计数器。其中,虚拟机栈中的每一项内容叫作栈帧,栈帧中包含四项内容:局部变量报表、操作数栈、动态链接和完成出口。
我们的字节码指令,就是靠操作这些数据结构运行的。下面我们看一下具体的字节码指令。
10: aload_0
把第 1 个引用型局部变量推到操作数栈,这里的意思是把 this 装载到了操作数栈中。
对于 static 方法aload_0 表示对方法的第一个参数的操作。
21: getfield #2
将栈顶的指定的对象的第 2 个实例域Field的值压入栈顶。#2 就是指的我们的成员变量 a。
#2 = Fieldref #6.#27 // B.a:I
...
#6 = Class #29 // B
#27 = NameAndType #8:#9 // a:I
3i2l
将栈顶 int 类型的数据转化为 long 类型,这里就涉及我们的隐式类型转换了。图中的信息没有变动,不再详解介绍。
4lload_1
将第一个局部变量入栈。也就是我们的参数 num。这里的 l 表示 long同样用于局部变量装载。你会看到这个位置的局部变量一开始就已经有值了。
5ladd
把栈顶两个 long 型数值出栈后相加,并将结果入栈。
6getstatic #3
根据偏移获取静态属性的值,并把这个值 push 到操作数栈上。
7ladd
再次执行 ladd。
8lstore_3
把栈顶 long 型数值存入第 4 个局部变量。
还记得我们上面的图么slot 为 4索引为 3 的就是 ret 变量。
9lload_3
正好与上面相反。上面是变量存入,我们现在要做的,就是把这个变量 ret压入虚拟机栈中。
10lreturn
从当前方法返回 long。
到此为止我们的函数就完成了相加动作执行成功了。JVM 为我们提供了非常丰富的字节码指令。详细的字节码指令列表,可以参考以下网址:
https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-6.html
注意点
注意上面的第 8 步,我们首先把变量存放到了变量报表,然后又拿出这个值,把它入栈。为什么会有这种多此一举的操作?原因就在于我们定义了 ret 变量。JVM 不知道后面还会不会用到这个变量,所以只好傻瓜式的顺序执行。
为了看到这些差异。大家可以把我们的程序稍微改动一下,直接返回这个值。
public long test(long num) {
return this.a + num + C;
}
再次看下,对应的字节码指令是不是简单了很多?
0: aload_0
1: getfield #2 // Field a:I
4: i2l
5: lload_1
6: ladd
7: getstatic #3 // Field C:J
10: ladd
11: lreturn
那我们以后编写程序时,是不是要尽量少的定义成员变量?
这是没有必要的。栈的操作复杂度是 O(1),对我们的程序性能几乎没有影响。平常的代码编写,还是以可读性作为首要任务。
小结
本课时,我们学会了使用 javap 和 jclasslib 两个工具。平常工作中,掌握第一个就够了,后者主要为我们提供更加直观的展示。
我们从实际分析一段代码开始,详细介绍了几个字节码指令对程序计数器、局部变量表、操作数栈等内容的影响,初步接触了 Java 的字节码文件格式。
希望你能够建立起一个运行时的脉络,在看到相关的 opcode 时,能够举一反三的思考背后对这些数据结构的操作。这样理解的字节码指令,根本不会忘。
你还可以尝试着对 A 类的代码进行分析,我们这里先留下一个悬念。课程后面会详细介绍 JVM 在方法调用上的一些特点。

View File

@ -0,0 +1,232 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
05 大厂面试题:得心应手应对 OOM 的疑难杂症
在前面几个课时中我们不止一次提到了堆heap堆是一个巨大的对象池。在这个对象池中管理着数量巨大的对象实例。
而池中对象的引用层次,有的是很深的。一个被频繁调用的接口,每秒生成对象的速度,也是非常可观的。对象之间的关系,形成了一张巨大的网。虽然 Java 一直在营造一种无限内存的氛围,但对象不能只增不减,所以需要垃圾回收。
那 JVM 是如何判断哪些对象应该被回收?哪些应该被保持呢?
在古代,刑罚中有诛九族一说。指的是有些人犯大事时,皇上杀一人不足以平复内心的愤怒时,会对亲朋好友产生连带责任。诛九族时首先需要追溯到一个共同的祖先,再往下细数连坐。堆上的垃圾回收也有同样的思路。我们接下来就具体分析 JVM 中是如何进行垃圾回收的。
JVM 的 GC 动作,是不受程序控制的,它会在满足条件的时候,自动触发。
在发生 GC 的时候一个对象JVM 总能够找到引用它的祖先。找到最后,如果发现这个祖先已经名存实亡了,它们都会被清理掉。而能够躲过垃圾回收的那些祖先,比较特殊,它们的名字就叫作 GC Roots。
从 GC Roots 向下追溯、搜索,会产生一个叫作 Reference Chain 的链条。当一个对象不能和任何一个 GC Root 产生关系时,就会被无情的诛杀掉。
如图所示Obj5、Obj6、Obj7由于不能和 GC Root 产生关联,发生 GC 时,就会被摧毁。
垃圾回收就是围绕着 GC Roots 去做的。同时,它也是很多内存泄露的根源,因为其他引用根本没有这样的权利。
那么,什么样的对象,才会是 GC Root 呢?这不在于它是什么样的对象,而在于它所处的位置。
GC Roots 有哪些
GC Roots 是一组必须活跃的引用。用通俗的话来说,就是程序接下来通过直接引用或者间接引用,能够访问到的潜在被使用的对象。
GC Roots 包括:
Java 线程中,当前所有正在被调用的方法的引用类型参数、局部变量、临时值等。也就是与我们栈帧相关的各种引用。
所有当前被加载的 Java 类。
Java 类的引用类型静态变量。
运行时常量池里的引用类型常量String 或 Class 类型)。
JVM 内部数据结构的一些引用,比如 sun.jvm.hotspot.memory.Universe 类。
用于同步的监控对象,比如调用了对象的 wait() 方法。
JNI handles包括 global handles 和 local handles。
这些 GC Roots 大体可以分为三大类,下面这种说法更加好记一些:
活动线程相关的各种引用。
类的静态变量的引用。
JNI 引用。
有两个注意点:
我们这里说的是活跃的引用,而不是对象,对象是不能作为 GC Roots 的。
GC 过程是找出所有活对象,并把其余空间认定为“无用”;而不是找出所有死掉的对象,并回收它们占用的空间。所以,哪怕 JVM 的堆非常的大,基于 tracing 的 GC 方式,回收速度也会非常快。
引用级别
接下来的一道面试题就有意思多了:能够找到 Reference Chain 的对象,就一定会存活么?
我在面试的时候,经常会问这些问题,比如“弱引用有什么用处”?令我感到奇怪的是,即使是一些工作多年的 Java 工程师,对待这个问题也是一知半解,错失了很多机会。
对象对于另外一个对象的引用,要看关系牢靠不牢靠,可能在链条的其中一环,就断掉了。
根据发生 GC 时,这条链条的表现,可以对这个引用关系进行更加细致的划分。
它们的关系,可以分为强引用、软引用、弱引用、虚引用等。
强引用 Strong references
当内存空间不足系统撑不住了JVM 就会抛出 OutOfMemoryError 错误。即使程序会异常终止,这种对象也不会被回收。这种引用属于最普通最强硬的一种存在,只有在和 GC Roots 断绝关系时,才会被消灭掉。
这种引用你每天的编码都在用。例如new 一个普通的对象。
Object obj = new Object()
这种方式可能是有问题的。假如你的系统被大量用户User访问你需要记录这个 User 访问的时间。可惜的是User 对象里并没有这个字段,所以我们决定将这些信息额外开辟一个空间进行存放。
static Map<User,Long> userVisitMap = new HashMap<>();
...
userVisitMap.put(user, time);
当你用完了 User 对象,其实你是期望它被回收掉的。但是,由于它被 userVisitMap 引用,我们没有其他手段 remove 掉它。这个时候就发生了内存泄漏memory leak
这种情况还通常发生在一个没有设定上限的 Cache 系统,由于设置了不正确的引用方式,加上不正确的容量,很容易造成 OOM。
软引用 Soft references
软引用用于维护一些可有可无的对象。在内存足够的时候,软引用对象不会被回收,只有在内存不足时,系统则会回收软引用对象,如果回收了软引用对象之后仍然没有足够的内存,才会抛出内存溢出异常。
可以看到,这种特性非常适合用在缓存技术上。比如网页缓存、图片缓存等。
Guava 的 CacheBuilder就提供了软引用和弱引用的设置方式。在这种场景中软引用比强引用安全的多。
软引用可以和一个引用队列ReferenceQueue联合使用如果软引用所引用的对象被垃圾回收Java 虚拟机就会把这个软引用加入到与之关联的引用队列中。
我们可以看一下它的代码。软引用需要显式的声明,使用泛型来实现。
// 伪代码
Object object = new Object();
SoftReference<Object> softRef = new SoftReference(object);
这里有一个相关的 JVM 参数。它的意思是:每 MB 堆空闲空间中 SoftReference 的存活时间。这个值的默认时间是1秒1000
-XX:SoftRefLRUPolicyMSPerMB=<N>
这里要特别说明的是,网络上一些流传的优化方法,即把这个值设置成 0其实是错误的这样容易引发故障感兴趣的话你可以自行搜索一下。
这种比较偏门的优化手段,除非在你对其原理相当了解的情况下,才能设置一些比较特殊的值。比如 0 值,无限大等,这种值在 JVM 的设置中,最好不要发生。
弱引用 Weak references
弱引用对象相比较软引用,要更加无用一些,它拥有更短的生命周期。
当 JVM 进行垃圾回收时,无论内存是否充足,都会回收被弱引用关联的对象。弱引用拥有更短的生命周期,在 Java 中,用 java.lang.ref.WeakReference 类来表示。
它的应用场景和软引用类似,可以在一些对内存更加敏感的系统里采用。它的使用方式类似于这段的代码:
// 伪代码
Object object = new Object();
WeakReference<Object> softRef = new WeakReference(object);
虚引用 Phantom References
这是一种形同虚设的引用在现实场景中用的不是很多。虚引用必须和引用队列ReferenceQueue联合使用。如果一个对象仅持有虚引用那么它就和没有任何引用一样在任何时候都可能被垃圾回收。
实际上,虚引用的 get总是返回 null。
Object object = new Object();
ReferenceQueue queue = new ReferenceQueue();
// 虚引用,必须与一个引用队列关联
PhantomReference pr = new PhantomReference(object, queue);
虚引用主要用来跟踪对象被垃圾回收的活动。
当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象之前,把这个虚引用加入到与之关联的引用队列中。
程序如果发现某个虚引用已经被加入到引用队列,那么就可以在所引用的对象的内存被回收之前采取必要的行动。
下面的方法,就是一个用于监控 GC 发生的例子。
private static void startMonitoring(ReferenceQueue<MyObject> referenceQueue, Reference<MyObject> ref) {
ExecutorService ex = Executors.newSingleThreadExecutor();
ex.execute(() -> {
while (referenceQueue.poll()!=ref) {
//don't hang forever
if(finishFlag){
break;
}
}
System.out.println("-- ref gc'ed --");
});
ex.shutdown();
}
基于虚引用,有一个更加优雅的实现方式,那就是 Java 9 以后新加入的 Cleaner用来替代 Object 类的 finalizer 方法。
典型 OOM 场景
OOM 的全称是 Out Of Memory那我们的内存区域有哪些会发生 OOM 呢?我们可以从内存区域划分图上,看一下彩色部分。
可以看到除了程序计数器其他区域都有OOM溢出的可能。但是最常见的还是发生在堆上。
所以 OOM 到底是什么引起的呢?有几个原因:
内存的容量太小了,需要扩容,或者需要调整堆的空间。
错误的引用方式,发生了内存泄漏。没有及时的切断与 GC Roots 的关系。比如线程池里的线程,在复用的情况下忘记清理 ThreadLocal 的内容。
接口没有进行范围校验,外部传参超出范围。比如数据库查询时的每页条数等。
对堆外内存无限制的使用。这种情况一旦发生更加严重,会造成操作系统内存耗尽。
典型的内存泄漏场景,原因在于对象没有及时的释放自己的引用。比如一个局部变量,被外部的静态集合引用。
你在平常写代码时,一定要注意这种情况,千万不要为了方便把对象到处引用。即使引用了,也要在合适时机进行手动清理。关于这部分的问题根源排查,我们将在实践课程中详细介绍。
小结
你可以注意到 GC Roots 的专业叫法,就是可达性分析法。另外,还有一种叫作引用计数法的方式,在判断对象的存活问题上,经常被提及。
因为有循环依赖的硬伤,现在主流的 JVM没有一个是采用引用计数法来实现 GC 的,所以我们大体了解一下就可以。引用计数法是在对象头里维护一个 counter 计数器,被引用一次数量 +1引用失效记数 -1。计数器为 0 时,就被认为无效。你现在可以忘掉引用计数的方式了。
本课时,我们详细介绍了 GC Roots 都包含哪些内容。HostSpot 采用 tracing 的方式进行 GC内存回收的速度与处于 living 状态的对象数量有关。
这部分涉及的内容较多,如果面试被问到,你可以采用白话版的方式进行介绍,然后举例深入。
接下来,我们了解到四种不同强度的引用类型,尤其是软引用和虚引用,在平常工作中使用还是比较多的。这里面最不常用的就是虚引用,但是它引申出来的 Cleaner 类,是用来替代 finalizer 方法的,这是一个比较重要的知识点。
本课时最后讨论了几种典型的 OOM 场景,你可能现在对其概念比较模糊。接下来的课时,我们将详细介绍几个常见的垃圾回收算法,然后对这些 OOM 的场景逐个击破。

View File

@ -0,0 +1,411 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
06 深入剖析:垃圾回收你真的了解吗?(上)
本课时我们重点剖析 JVM 的垃圾回收机制。关于 JVM 垃圾回收机制面试中主要涉及这三个考题:
JVM 中有哪些垃圾回收算法?它们各自有什么优劣?
CMS 垃圾回收器是怎么工作的?有哪些阶段?
服务卡顿的元凶到底是谁?
虽然 Java 不用“手动管理”内存回收,代码写起来很顺畅。但是你有没有想过,这些内存是怎么被回收的?
其实JVM 是有专门的线程在做这件事情。当我们的内存空间达到一定条件时,会自动触发。这个过程就叫作 GC负责 GC 的组件,就叫作垃圾回收器。
JVM 规范并没有规定垃圾回收器怎么实现,它只需要保证不要把正在使用的对象给回收掉就可以。在现在的服务器环境中,经常被使用的垃圾回收器有 CMS 和 G1但 JVM 还有其他几个常见的垃圾回收器。
按照语义上的意思,垃圾回收,首先就需要找到这些垃圾,然后回收掉。但是 GC 过程正好相反,它是先找到活跃的对象,然后把其他不活跃的对象判定为垃圾,然后删除。所以垃圾回收只与活跃的对象有关,和堆的大小无关。这个概念是我们一直在强调的,你一定要牢记。
本课时将首先介绍几种非常重要的回收算法,然后着重介绍分代垃圾回收的内存划分和 GC 过程,最后介绍当前 JVM 中的几种常见垃圾回收器。
这部分内容比较多,也比较细。为了知识的连贯性,这里我直接将它们放在一个课时。篇幅有点长,你一定要有耐心学完,也希望你可以对 JVM 的了解上一个档次。
为什么这部分这么重要呢?是因为几乎所有的垃圾回收器,都是在这些基本思想上演化出来的,如果你对此不熟悉,那么我们后面讲解 CMS、G1、ZGC 的时候,就会有诸多障碍。这将直接影响到我们对实践课的理解。
标记Mark
垃圾回收的第一步,就是找出活跃的对象。我们反复强调 GC 过程是逆向的。
我们在前面的课时谈到 GC Roots。根据 GC Roots 遍历所有的可达对象,这个过程,就叫作标记。
如图所示,圆圈代表的是对象。绿色的代表 GC Roots红色的代表可以追溯到的对象。可以看到标记之后仍然有多个灰色的圆圈它们都是被回收的对象。
清除Sweep
清除阶段就是把未被标记的对象回收掉。
但是这种简单的清除方式,有一个明显的弊端,那就是碎片问题。
比如我申请了 1k、2k、3k、4k、5k 的内存。
由于某种原因 2k 和 4k 的内存,我不再使用,就需要交给垃圾回收器回收。
这个时候,我应该有足足 6k 的空闲空间。接下来,我打算申请另外一个 5k 的空间,结果系统告诉我内存不足了。系统运行时间越长,这种碎片就越多。
在很久之前使用 Windows 系统时,有一个非常有用的功能,就是内存整理和磁盘整理,运行之后有可能会显著提高系统性能。这个出发点是一样的。
复制Copy
解决碎片问题没有银弹,只有老老实实的进行内存整理。
有一个比较好的思路可以完成这个整理过程,就是提供一个对等的内存空间,将存活的对象复制过去,然后清除原内存空间。
在程序设计中一般遇到扩缩容或者碎片整理问题时复制算法都是非常有效的。比如HashMap 的扩容也是使用同样的思路Redis 的 rehash 也是类似的。
整个过程如图所示:
这种方式看似非常完美的,解决了碎片问题。但是,它的弊端也非常明显。它浪费了几乎一半的内存空间来做这个事情,如果资源本来就很有限,这就是一种无法容忍的浪费。
整理Compact
其实,不用分配一个对等的额外空间,也是可以完成内存的整理工作。
你可以把内存想象成一个非常大的数组,根据随机的 index 删除了一些数据。那么对整个数组的清理,其实是不需要另外一个数组来进行支持的,使用程序就可以实现。
它的主要思路,就是移动所有存活的对象,且按照内存地址顺序依次排列,然后将末端内存地址以后的内存全部回收。
我们可以用一个理想的算法来看一下这个过程。
last = 0
for(i=0;i<mems.length;i++){
if(mems[i] != null){
mems[last++] = mems[i]
changeReference(mems[last])
}
}
clear(mems,last,mems.length)
但是需要注意,这只是一个理想状态。对象的引用关系一般都是非常复杂的,我们这里不对具体的算法进行描述。你只需要了解,从效率上来说,一般整理算法是要低于复制算法的。
分代
我们简要介绍了一些常见的内存回收算法目前JVM 的垃圾回收器,都是对几种朴素算法的发扬光大。简单看一下它们的特点:
复制算法Copy
复制算法是所有算法里面效率最高的,缺点是会造成一定的空间浪费。
标记-清除Mark-Sweep
效率一般,缺点是会造成内存碎片问题。
标记-整理Mark-Compact
效率比前两者要差,但没有空间浪费,也消除了内存碎片问题。
所以,没有最优的算法,只有最合适的算法。
JVM 是计算节点,而不是存储节点。最理想的情况,就是对象在用完之后,它的生命周期立马就结束了。而那些被频繁访问的资源,我们希望它能够常驻在内存里。
研究表明,大部分对象,可以分为两类:
大部分对象的生命周期都很短;
其他对象则很可能会存活很长时间。
大部分死的快其他的活的长。这个假设我们称之为弱代假设weak generational hypothesis
接下来划重点。
从图中可以看到,大部分对象是朝生夕灭的,其他的则活的很久。
现在的垃圾回收器都会在物理上或者逻辑上把这两类对象进行区分。我们把死的快的对象所占的区域叫作年轻代Young generation。把其他活的长的对象所占的区域叫作老年代Old generation
老年代在有些地方也会叫作 Tenured Generation你在看到时明白它的意思就可以了。
年轻代
年轻代使用的垃圾回收算法是复制算法。因为年轻代发生 GC 后,只会有非常少的对象存活,复制这部分对象是非常高效的。
我们前面也了解到复制算法会造成一定的空间浪费,所以年轻代中间也会分很多区域。
如图所示年轻代分为一个伊甸园空间Eden 两个幸存者空间Survivor )。
当年轻代中的 Eden 区分配满的时候,就会触发年轻代的 GCMinor GC。具体过程如下
在 Eden 区执行了第一次 GC 之后,存活的对象会被移动到其中一个 Survivor 分区以下简称from
Eden 区再次 GC这时会采用复制算法将 Eden 和 from 区一起清理。存活的对象会被复制到 to 区;接下来,只需要清空 from 区就可以了。
所以在这个过程中,总会有一个 Survivor 分区是空置的。Eden、from、to 的默认比例是 8:1:1所以只会造成 10% 的空间浪费。
这个比例,是由参数 -XX:SurvivorRatio 进行配置的(默认为 8
一般情况下,我们只需要了解到这一层面就 OK 了。但是在平常的面试中,还有一个点会经常提到,虽然频率不太高,它就是 TLAB我们在这里也简单介绍一下。
TLAB 的全称是 Thread Local Allocation BufferJVM 默认给每个线程开辟一个 buffer 区域,用来加速对象分配。这个 buffer 就放在 Eden 区中。
这个道理和 Java 语言中的 ThreadLocal 类似,避免了对公共区的操作,以及一些锁竞争。
对象的分配优先在 TLAB上 分配,但 TLAB 通常都很小,所以对象相对比较大的时候,会在 Eden 区的共享区域进行分配。
TLAB 是一种优化技术,类似的优化还有对象的栈上分配(这可以引出逃逸分析的话题,默认开启)。这属于非常细节的优化,不做过多介绍,但偶尔面试也会被问到。
老年代
老年代一般使用“标记-清除”、“标记-整理”算法,因为老年代的对象存活率一般是比较高的,空间又比较大,拷贝起来并不划算,还不如采取就地收集的方式。
那么,对象是怎么进入老年代的呢?有多种途径。
1提升Promotion
如果对象够老,会通过“提升”进入老年代。
关于对象老不老是通过它的年龄age来判断的。每当发生一次 Minor GC存活下来的对象年龄都会加 1。直到达到一定的阈值就会把这些“老顽固”给提升到老年代。
这些对象如果变的不可达,直到老年代发生 GC 的时候,才会被清理掉。
这个阈值,可以通过参数 XX:+MaxTenuringThreshold 进行配置,最大值是 15因为它是用 4bit 存储的(所以网络上那些要把这个值调的很大的文章,是没有什么根据的)。
2分配担保
看一下年轻代的图,每次存活的对象,都会放入其中一个幸存区,这个区域默认的比例是 10%。但是我们无法保证每次存活的对象都小于 10%,当 Survivor 空间不够,就需要依赖其他内存(指老年代)进行分配担保。这个时候,对象也会直接在老年代上分配。
3大对象直接在老年代分配
超出某个大小的对象将直接在老年代分配。这个值是通过参数 -XX:PretenureSizeThreshold 进行配置的。默认为 0意思是全部首选 Eden 区进行分配。
4动态对象年龄判定
有的垃圾回收算法,并不要求 age 必须达到 15 才能晋升到老年代,它会使用一些动态的计算方法。比如,如果幸存区中相同年龄对象大小的和,大于幸存区的一半,大于或等于 age 的对象将会直接进入老年代。
这些动态判定一般不受外部控制,我们知道有这么回事就可以了。通过下图可以看一下一个对象的分配逻辑。
卡片标记card marking
你可以看到,对象的引用关系是一个巨大的网状。有的对象可能在 Eden 区,有的可能在老年代,那么这种跨代的引用是如何处理的呢?由于 Minor GC 是单独发生的,如果一个老年代的对象引用了它,如何确保能够让年轻代的对象存活呢?
对于是、否的判断,我们通常都会用 Bitmap位图和布隆过滤器来加快搜索的速度。如果你不知道这个概念就需要课后补补课了。
JVM 也是用了类似的方法。其实老年代是被分成众多的卡页card page一般数量是 2 的次幂)。
卡表Card Table就是用于标记卡页状态的一个集合每个卡表项对应一个卡页。
如果年轻代有对象分配,而且老年代有对象指向这个新对象, 那么这个老年代对象所对应内存的卡页,就会标识为 dirty卡表只需要非常小的存储空间就可以保留这些状态。
垃圾回收时,就可以先读这个卡表,进行快速判断。
HotSpot 垃圾回收器
接下来介绍 HotSpot 的几个垃圾回收器,每种回收器都有各自的特点。我们在平常的 GC 优化时,一定要搞清楚现在用的是哪种垃圾回收器。
在此之前,我们把上面的分代垃圾回收整理成一张大图,在介绍下面的收集器时,你可以对应一下它们的位置。
年轻代垃圾回收器
1Serial 垃圾收集器
处理 GC 的只有一条线程,并且在垃圾回收的过程中暂停一切用户线程。
这可以说是最简单的垃圾回收器,但千万别以为它没有用武之地。因为简单,所以高效,它通常用在客户端应用上。因为客户端应用不会频繁创建很多对象,用户也不会感觉出明显的卡顿。相反,它使用的资源更少,也更轻量级。
2ParNew 垃圾收集器
ParNew 是 Serial 的多线程版本。由多条 GC 线程并行地进行垃圾清理。清理过程依然要停止用户线程。
ParNew 追求“低停顿时间”,与 Serial 唯一区别就是使用了多线程进行垃圾收集,在多 CPU 环境下性能比 Serial 会有一定程度的提升;但线程切换需要额外的开销,因此在单 CPU 环境中表现不如 Serial。
3Parallel Scavenge 垃圾收集器
另一个多线程版本的垃圾回收器。它与 ParNew 的主要区别是:
Parallel Scavenge追求 CPU 吞吐量,能够在较短时间内完成指定任务,适合没有交互的后台计算。弱交互强计算。
ParNew追求降低用户停顿时间适合交互式应用。强交互弱计算。
老年代垃圾收集器
1Serial Old 垃圾收集器
与年轻代的 Serial 垃圾收集器对应,都是单线程版本,同样适合客户端使用。
年轻代的 Serial使用复制算法。
老年代的 Old Serial使用标记-整理算法。
2Parallel Old
Parallel Old 收集器是 Parallel Scavenge 的老年代版本,追求 CPU 吞吐量。
3CMS 垃圾收集器
CMSConcurrent Mark Sweep收集器是以获取最短 GC 停顿时间为目标的收集器,它在垃圾收集时使得用户线程和 GC 线程能够并发执行,因此在垃圾收集过程中用户也不会感到明显的卡顿。我们会在后面的课时详细介绍它。
长期来看CMS 垃圾回收器,是要被 G1 等垃圾回收器替换掉的。在 Java8 之后,使用它将会抛出一个警告。
Java HotSpot™ 64-Bit Server VM warning: Option UseConcMarkSweepGC was deprecated in version 9.0 and will likely be removed in a future release.
配置参数
除了上面几个垃圾回收器,我们还有 G1、ZGC 等更加高级的垃圾回收器,它们都有专门的配置参数来使其生效。
通过 -XX:+PrintCommandLineFlags 参数,可以查看当前 Java 版本默认使用的垃圾回收器。你可以看下我的系统中 Java13 默认的收集器就是 G1。
java -XX:+PrintCommandLineFlags -version
-XX:G1ConcRefinementThreads=4 -XX:GCDrainStackTargetSize=64 -XX:InitialHeapSize=134217728 -XX:MaxHeapSize=2147483648 -XX:MinHeapSize=6815736 -XX:+PrintCommandLineFlags -XX:ReservedCodeCacheSize=251658240 -XX:+SegmentedCodeCache -XX:+UseCompressedClassPointers -XX:+UseCompressedOops -XX:+UseG1GC
java version “13.0.1” 2019-10-15
Java™ SE Runtime Environment (build 13.0.1+9)
Java HotSpot™ 64-Bit Server VM (build 13.0.1+9, mixed mode, sharing)
以下是一些配置参数:
-XX:+UseSerialGC 年轻代和老年代都用串行收集器
-XX:+UseParNewGC 年轻代使用 ParNew老年代使用 Serial Old
-XX:+UseParallelGC 年轻代使用 ParallerGC老年代使用 Serial Old
-XX:+UseParallelOldGC 新生代和老年代都使用并行收集器
-XX:+UseConcMarkSweepGC表示年轻代使用 ParNew老年代的用 CMS
-XX:+UseG1GC 使用 G1垃圾回收器
-XX:+UseZGC 使用 ZGC 垃圾回收器
为了让你有个更好的印象,请看下图。它们的关系还是比较复杂的。尤其注意 -XX:+UseParNewGC 这个参数,已经在 Java9 中就被抛弃了。很多程序(比如 ES会报这个错误不要感到奇怪。
有这么多垃圾回收器和参数,那我们到底用什么?在什么地方优化呢?
目前,虽然 Java 的版本比较高,但是使用最多的还是 Java8。从 Java8 升级到高版本的 Java 体系,是有一定成本的,所以 CMS 垃圾回收器还会持续一段时间。
线上使用最多的垃圾回收器,就有 CMS 和 G1以及 Java8 默认的 Parallel Scavenge。
CMS 的设置参数:-XX:+UseConcMarkSweepGC。
Java8 的默认参数:-XX:+UseParallelGC。
Java13 的默认参数:-XX:+UseG1GC。
我们的实战练习的课时中,就集中会使用这几个参数。
STW
你有没有想过,如果在垃圾回收的时候(不管是标记还是整理复制),又有新的对象进入怎么办?
为了保证程序不会乱套,最好的办法就是暂停用户的一切线程。也就是在这段时间,你是不能 new 对象的,只能等待。表现在 JVM 上就是短暂的卡顿,什么都干不了。这个头疼的现象,就叫作 Stop the world。简称 STW。
标记阶段,大多数是要 STW 的。如果不暂停用户进程,在标记对象的时候,有可能有其他用户线程会产生一些新的对象和引用,造成混乱。
现在的垃圾回收器,都会尽量去减少这个过程。但即使是最先进的 ZGC也会有短暂的 STW 过程。我们要做的就是在现有基础设施上,尽量减少 GC 停顿。
你可能对 STW 的影响没有什么概念,我举个例子来说明下。
某个高并发服务的峰值流量是 10 万次/秒,后面有 10 台负载均衡的机器,那么每台机器平均下来需要 1w/s。假如某台机器在这段时间内发生了 STW持续了 1 秒,那么本来需要 10ms 就可以返回的 1 万个请求,需要至少等待 1 秒钟。
在用户那里的表现,就是系统发生了卡顿。如果我们的 GC 非常的频繁,这种卡顿就会特别的明显,严重影响用户体验。
虽然说 Java 为我们提供了非常棒的自动内存管理机制,但也不能滥用,因为它是有 STW 硬伤的。
小结
本课时的内容很多。由于篇幅有限,我们仅介绍了最重要的点,要是深挖下去,估计一本书都写不完。
归根结底,各色的垃圾回收器就是为了解决头疼的 STW 问题,让 GC 时间更短,停顿更小,吞吐量更大。
现在的回收器,基于弱代假设,大多是分代回收的理念。针对年轻代和老年代,有多种不同的垃圾回收算法,有些可以组合使用。
我们尤其讲解了年轻代的垃圾回收。
年轻代是 GC 的重灾区,大部分对象活不到老年代;
面试经常问,都是些非常朴素的原理;
为我们后面对 G1 和 ZGC 的介绍打下基础。
我们也接触了大量的名词。让我们来总结一下:
算法
Mark
Sweep
Copy
Compact
分代
Young generation
Survivor
Eden
Old generation | Tenured Generation
GC
Minor GC
Major GC
名词
weak generational hypothesis
分配担保
提升
卡片标记
STW
文中图片关于 Eden、from、to 区的划分以及堆的划分,是很多面试官非常喜欢问的。但是有些面试官的问题非常陈旧,因为 JVM 的更新迭代有点快,你不要去反驳。有些痛点是需要实践才能体验到,心平气和的讲解这些变化,会让你在面试中掌握主动地位。

View File

@ -0,0 +1,160 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
07 深入剖析:垃圾回收你真的了解吗?(下)
由于上一课时篇幅比较多,我们在这一课时重点讲解上一课时中提到的 CMS 垃圾回收器,让你可以更好的理解垃圾回收的过程。
在这里首先给你介绍几个概念:
Minor GC发生在年轻代的 GC。
Major GC发生在老年代的 GC。
Full GC全堆垃圾回收。比如 Metaspace 区引起年轻代和老年代的回收。
理解了这三个概念,我们再往下看。
CMS 的全称是 Mostly Concurrent Mark and Sweep Garbage Collector主要并发­标记­清除­垃圾收集器它在年轻代使用复制算法而对老年代使用标记-清除算法。你可以看到,在老年代阶段,比起 Mark-Sweep它多了一个并发字样。
CMS 的设计目标,是避免在老年代 GC 时出现长时间的卡顿(但它并不是一个老年代回收器)。如果你不希望有长时间的停顿,同时你的 CPU 资源也比较丰富,使用 CMS 是比较合适的。
CMS 使用的是 Sweep 而不是 Compact所以它的主要问题是碎片化。随着 JVM 的长时间运行,碎片化会越来越严重,只有通过 Full GC 才能完成整理。
为什么 CMS 能够获得更小的停顿时间呢?主要是因为它把最耗时的一些操作,做成了和应用线程并行。接下来我们简要看一下这个过程。
CMS 回收过程
初始标记Initial Mark
初始标记阶段,只标记直接关联 GC root 的对象,不用向下追溯。因为最耗时的就在 tracing 阶段,这样就极大地缩短了初始标记时间。
这个过程是 STW 的,但由于只是标记第一层,所以速度是很快的。
注意,这里除了要标记相关的 GC Roots 之外,还要标记年轻代中对象的引用,这也是 CMS 老年代回收,依然要扫描新生代的原因。
并发标记Concurrent Mark
在初始标记的基础上,进行并发标记。这一步骤主要是 tracinng 的过程,用于标记所有可达的对象。
这个过程会持续比较长的时间,但却可以和用户线程并行。在这个阶段的执行过程中,可能会产生很多变化:
有些对象,从新生代晋升到了老年代;
有些对象,直接分配到了老年代;
老年代或者新生代的对象引用发生了变化。
还记得我们在上一课时提到的卡片标记么?在这个阶段受到影响的老年代对象所对应的卡页,会被标记为 dirty用于后续重新标记阶段的扫描。
并发预清理Concurrent Preclean
并发预清理也是不需要 STW 的,目的是为了让重新标记阶段的 STW 尽可能短。这个时候,老年代中被标记为 dirty 的卡页中的对象,就会被重新标记,然后清除掉 dirty 的状态。
由于这个阶段也是可以并发的,在执行过程中引用关系依然会发生一些变化。我们可以假定这个清理动作是第一次清理。
所以重新标记阶段,有可能还会有处于 dirty 状态的卡页。
并发可取消的预清理Concurrent Abortable Preclean
因为重新标记是需要 STW 的,所以会有很多次预清理动作。并发可取消的预清理,顾名思义,在满足某些条件的时候,可以终止,比如迭代次数、有用工作量、消耗的系统时间等。
这个阶段是可选的。换句话说,这个阶段是“并发预清理”阶段的一种优化。
这个阶段的第一个意图,是避免回扫年轻代的大量对象;另外一个意图,就是当满足最终标记的条件时,自动退出。
我们在前面说过,标记动作是需要扫描年轻代的。如果年轻代的对象太多,肯定会严重影响标记的时间。如果在此之前能够进行一次 Minor GC情况会不会变得好了许多
CMS 提供了参数 CMSScavengeBeforeRemark可以在进入重新标记之前强制进行一次 Minor GC。
但请你记住一件事情GC 的停顿是不分什么年轻代老年代的。设置了上面的参数,可能会在一个比较长的 Minor GC 之后,紧跟着一个 CMS 的 Remark它们都是 STW 的。
这部分有非常多的配置参数。但是一般都不会去改动。
最终标记Final Remark
通常 CMS 会尝试在年轻代尽可能空的情况下运行 Final Remark 阶段,以免接连多次发生 STW 事件。
这是 CMS 垃圾回收阶段的第二次 STW 阶段,目标是完成老年代中所有存活对象的标记。我们前面多轮的 preclean 阶段,一直在和应用线程玩追赶游戏,有可能跟不上引用的变化速度。本轮的标记动作就需要 STW 来处理这些情况。
如果预处理阶段做的不够好,会显著增加本阶段的 STW 时间。你可以看到CMS 垃圾回收器把回收过程分了多个部分,而影响最大的不是 STW 阶段本身,而是它之前的预处理动作。
并发清除Concurrent Sweep
此阶段用户线程被重新激活,目标是删掉不可达的对象,并回收它们的空间。
由于 CMS 并发清理阶段用户线程还在运行中伴随程序运行自然就还会有新的垃圾不断产生这一部分垃圾出现在标记过程之后CMS 无法在当次 GC 中处理掉它们,只好留待下一次 GC 时再清理掉。这一部分垃圾就称为“浮动垃圾”。
并发重置Concurrent Reset
此阶段与应用程序并发执行,重置 CMS 算法相关的内部数据,为下一次 GC 循环做准备。
内存碎片
由于 CMS 在执行过程中用户线程还需要运行那就需要保证有充足的内存空间供用户使用。如果等到老年代空间快满了再开启这个回收过程用户线程可能会产生“Concurrent Mode Failure”的错误这时会临时启用 Serial Old 收集器来重新进行老年代的垃圾收集这样停顿时间就很长了STW
这部分空间预留,一般在 30% 左右即可,那么能用的大概只有 70%。参数 -XX:CMSInitiatingOccupancyFraction 用来配置这个比例记得要首先开启参数UseCMSInitiatingOccupancyOnly。也就是说当老年代的使用率达到 70%,就会触发 GC 了。如果你的系统老年代增长不是太快,可以调高这个参数,降低内存回收的次数。
其实,这个比率非常不好设置。一般在堆大小小于 2GB 的时候,都不会考虑 CMS 垃圾回收器。
另外CMS 对老年代回收的时候,并没有内存的整理阶段。这就造成程序在长时间运行之后,碎片太多。如果你申请一个稍大的对象,就会引起分配失败。
CMS 提供了两个参数来解决这个问题:
1 UseCMSCompactAtFullCollection默认开启表示在要进行 Full GC 的时候,进行内存碎片整理。内存整理的过程是无法并发的,所以停顿时间会变长。
2CMSFullGCsBeforeCompaction每隔多少次不压缩的 Full GC 后,执行一次带压缩的 Full GC。默认值为 0表示每次进入 Full GC 时都进行碎片整理。
所以,预留空间加上内存的碎片,使用 CMS 垃圾回收器的老年代,留给我们的空间就不是太多,这也是 CMS 的一个弱点。
小结
一般的,我们将 CMS 垃圾回收器分为四个阶段:
初始标记
并发标记
重新标记
并发清理
我们总结一下 CMS 中都会有哪些停顿STW
初始标记,这部分的停顿时间较短;
Minor GC可选在预处理阶段对年轻代的回收停顿由年轻代决定
重新标记,由于 preclaen 阶段的介入,这部分停顿也较短;
Serial-Old 收集老年代的停顿,主要发生在预留空间不足的情况下,时间会持续很长;
Full GC永久代空间耗尽时的操作由于会有整理阶段持续时间较长。
在发生 GC 问题时你一定要明确发生在哪个阶段然后对症下药。gclog 通常能够非常详细的表现这个过程。
我们再来看一下 CMS 的 trade-off。
优势:
低延迟,尤其对于大堆来说。大部分垃圾回收过程并发执行。
劣势:
内存碎片问题。Full GC 的整理阶段,会造成较长时间的停顿。
需要预留空间,用来分配收集阶段产生的“浮动垃圾”。
使用更多的 CPU 资源,在应用运行的同时进行堆扫描。
CMS 是一种高度可配置的复杂算法,因此给 JDK 中的 GC 代码库带来了很多复杂性。由于 G1 和 ZGC 的产生CMS 已经在被废弃的路上。但是,目前仍然有大部分应用是运行在 Java8 及以下的版本之上,针对它的优化,还是要持续很长一段时间。

View File

@ -0,0 +1,238 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
08 大厂面试题:有了 G1 还需要其他垃圾回收器吗?
本课时我们主要来看下这两个高频的面试考题:
G1 的回收原理是什么?为什么 G1 比传统 GC 回收性能好?
为什么 G1 如此完美仍然会有 ZGC
我们在上一课时,简要的介绍了 CMS 垃圾回收器,下面我们简单回忆一下它的一个极端场景(而且是经常发生的场景)。
在发生 Minor GC 时,由于 Survivor 区已经放不下了多出的对象只能提升promotion到老年代。但是此时老年代因为空间碎片的缘故会发生 concurrent mode failure 的错误。这个时候,就需要降级为 Serail Old 垃圾回收器进行收集。这就是比 concurrent mode failure 更加严重的 promotion failed 问题。
一次简单的 Major GC竟然能演化成耗时最长的 Full GC。最要命的是这个停顿时间是不可预知的。
有没有一种办法,能够首先定义一个停顿时间,然后反向推算收集内容呢?就像是领导在年初制定 KPI 一样,分配的任务多就多干些,分配的任务少就少干点。
很久之前就有领导教导过我,如果你列的目标太大,看起来无法完成,不要怕。有一个叫作里程碑的名词,可以让我们以小跑的姿态,完成一次马拉松。
G1 的思路说起来也类似,它不要求每次都把垃圾清理的干干净净,它只是努力做它认为对的事情。
我们要求 G1在任意 1 秒的时间内,停顿不得超过 10ms这就是在给它制定 KPI。G1 会尽量达成这个目标,它能够推算出本次要收集的大体区域,以增量的方式完成收集。
这也是使用 G1 垃圾回收器不得不设置的一个参数:
-XX:MaxGCPauseMillis=10
为什么叫 G1
G1 的目标是用来干掉 CMS 的,它同样是一款软实时垃圾回收器。相比 CMSG1 的使用更加人性化。比如CMS 垃圾回收器的相关参数有 72 个,而 G1 的参数只有 26 个。
G1 的全称是 Garbage­First GC为了达成上面制定的 KPI它和前面介绍的垃圾回收器在对堆的划分上有一些不同。
其他的回收器都是对某个年代的整体收集收集时间上自然不好控制。G1 把堆切成了很多份,把每一份当作一个小目标,部分上目标很容易达成。
那又有一个面试题来啦G1 有年轻代和老年代的区分吗?
如图所示G1 也是有 Eden 区和 Survivor 区的概念的,只不过它们在内存上不是连续的,而是由一小份一小份组成的。
这一小份区域的大小是固定的名字叫作小堆区Region。小堆区可以是 Eden 区,也可以是 Survivor 区,还可以是 Old 区。所以 G1 的年轻代和老年代的概念都是逻辑上的。
每一块 Region大小都是一致的它的数值是在 1M 到 32M 字节之间的一个 2 的幂值数。
但假如我的对象太大,一个 Region 放不下了怎么办?注意图中有一块面积很大的黄色区域,它的名字叫作 Humongous Region大小超过 Region 50% 的对象,将会在这里分配。
Region 的大小,可以通过参数进行设置:
-XX:G1HeapRegionSize=M
那么,回收的时候,到底回收哪些小堆区呢?是随机的么?
这当然不是。事实上,垃圾最多的小堆区,会被优先收集。这就是 G1 名字的由来。
G1 的垃圾回收过程
在逻辑上G1 分为年轻代和老年代,但它的年轻代和老年代比例,并不是那么“固定”,为了达到 MaxGCPauseMillis 所规定的效果G1 会自动调整两者之间的比例。
如果你强行使用 -Xmn 或者 -XX:NewRatio 去设定它们的比例的话,我们给 G1 设定的这个目标将会失效。
G1 的回收过程主要分为 3 类:
1G1“年轻代”的垃圾回收同样叫 Minor GC这个过程和我们前面描述的类似发生时机就是 Eden 区满的时候。
2老年代的垃圾收集严格上来说其实不算是收集它是一个“并发标记”的过程顺便清理了一点点对象。
3真正的清理发生在“混合模式”它不止清理年轻代还会将老年代的一部分区域进行清理。
在 GC 日志里这个过程描述特别有意思1的过程叫作 [GC pause (G1 Evacuation Pause) (young)2的过程叫作 [GC pause (G1 Evacuation Pause) (mixed)。Evacuation 是转移的意思,和 Copy 的意思有点类似。
这三种模式之间的间隔也是不固定的。比如1 次 Minor GC 后,发生了一次并发标记,接着发生了 9 次 Mixed GC。
RSet
RSet 是一个空间换时间的数据结构。
在第 6 课时中我们提到过一个叫作卡表Card Table的数据结构用来解决跨代引用的问题。RSet 的功能与此类似,它的全称是 Remembered Set用于记录和维护 Region 之间的对象引用关系。
但 RSet 与 Card Table 有些不同的地方。Card Table 是一种 points-out我引用了谁的对象的结构。而 RSet 记录了其他 Region 中的对象引用本 Region 中对象的关系,属于 points-into 结构(谁引用了我的对象),有点倒排索引的味道。
你可以把 RSet 理解成一个 Hashkey 是引用的 Region 地址value 是引用它的对象的卡页集合。
有了这个数据结构,在回收某个 Region 的时候,就不必对整个堆内存的对象进行扫描了。它使得部分收集成为了可能。
对于年轻代的 Region它的 RSet 只保存了来自老年代的引用,这是因为年轻代的回收是针对所有年轻代 Region 的,没必要画蛇添足。所以说年轻代 Region 的 RSet 有可能是空的。
而对于老年代的 Region 来说,它的 RSet 也只会保存老年代对它的引用。这是因为老年代回收之前会先对年轻代进行回收。这时Eden 区变空了,而在回收过程中会扫描 Survivor 分区,所以也没必要保存来自年轻代的引用。
RSet 通常会占用很大的空间,大约 5% 或者更高。不仅仅是空间方面,很多计算开销也是比较大的。
事实上,为了维护 RSet程序运行的过程中写入某个字段就会产生一个 post-write barrier 。为了减少这个开销,将内容放入 RSet 的过程是异步的而且经过了很多的优化Write Barrier 把脏卡信息存放到本地缓冲区local buffer有专门的 GC 线程负责收集,并将相关信息传给被引用 Region 的 RSet。
参数 -XX:G1ConcRefinementThreads 或者 -XX:ParallelGCThreads 可以控制这个异步的过程。如果并发优化线程跟不上缓冲区的速度,就会在用户进程上完成。
具体回收过程
G1 还有一个 CSet 的概念。这个就比较好理解了,它的全称是 Collection Set即收集集合保存一次 GC 中将执行垃圾回收的区间Region。GC 是在 CSet 中的所有存活数据Live Data都会被转移。
了解了上面的数据结构,我们再来简要看一下回收过程。
年轻代回收
年轻代回收是一个 STW 的过程,它的跨代引用使用 RSet 数据结构来追溯,会一次性回收掉年轻代的所有 Region。
JVM 启动时G1 会先准备好 Eden 区,程序在运行过程中不断创建对象到 Eden 区,当所有的 Eden 区都满了G1 会启动一次年轻代垃圾回收过程。
年轻代的收集包括下面的回收阶段:
1扫描根
根,可以看作是我们前面介绍的 GC Roots加上 RSet 记录的其他 Region 的外部引用。
2更新 RS
处理 dirty card queue 中的卡页,更新 RSet。此阶段完成后RSet 可以准确的反映老年代对所在的内存分段中对象的引用。可以看作是第一步的补充。
3处理 RS
识别被老年代对象指向的 Eden 中的对象,这些被指向的 Eden 中的对象被认为是存活的对象。
4复制对象
没错,收集算法依然使用的是 Copy 算法。
在这个阶段对象树被遍历Eden 区内存段中存活的对象会被复制到 Survivor 区中空的 Region。这个过程和其他垃圾回收算法一样包括对象的年龄和晋升无需做过多介绍。
5处理引用
处理 Soft、Weak、Phantom、Final、JNI Weak 等引用。结束收集。
它的大体示意图如下所示。
并发标记Concurrent Marking
当整个堆内存使用达到一定比例(默认是 45%),并发标记阶段就会被启动。这个比例也是可以调整的,通过参数 -XX:InitiatingHeapOccupancyPercent 进行配置。
Concurrent Marking 是为 Mixed GC 提供标记服务的,并不是一次 GC 过程的一个必须环节。这个过程和 CMS 垃圾回收器的回收过程非常类似,你可以类比 CMS 的回收过程看一下。具体标记过程如下:
1初始标记Initial Mark
这个过程共用了 Minor GC 的暂停,这是因为它们可以复用 root scan 操作。虽然是 STW 的,但是时间通常非常短。
2Root 区扫描Root Region Scan
3并发标记 Concurrent Mark
这个阶段从 GC Roots 开始对 heap 中的对象标记,标记线程与应用程序线程并行执行,并且收集各个 Region 的存活对象信息。
4重新标记Remaking
和 CMS 类似,也是 STW 的。标记那些在并发标记阶段发生变化的对象。
5清理阶段Cleanup
这个过程不需要 STW。如果发现 Region 里全是垃圾,在这个阶段会立马被清除掉。不全是垃圾的 Region并不会被立马处理它会在 Mixed GC 阶段,进行收集。
了解 CMS 垃圾回收器后,上面这个过程就比较好理解。但是还有一个疑问需要稍微提一下。
如果在并发标记阶段,又有新的对象变化,该怎么办?
这是由算法 SATB 保证的。SATB 的全称是 Snapshot At The Beginning它作用是保证在并发标记阶段的正确性。
这个快照是逻辑上的,主要是有几个指针,将 Region 分成个多个区段。如图所示,并发标记期间分配的对象,都会在 next TAMS 和 top 之间。
混合回收Mixed GC
能并发清理老年代中的整个整个的小堆区是一种最优情形。混合收集过程,不只清理年轻代,还会将一部分老年代区域也加入到 CSet 中。
通过 Concurrent Marking 阶段,我们已经统计了老年代的垃圾占比。在 Minor GC 之后,如果判断这个占比达到了某个阈值,下次就会触发 Mixed GC。这个阈值由 -XX:G1HeapWastePercent 参数进行设置(默认是堆大小的 5%)。因为这种情况下, GC 会花费很多的时间但是回收到的内存却很少。所以这个参数也是可以调整 Mixed GC 的频率的。
还有参数 G1MixedGCCountTarget用于控制一次并发标记之后最多执行 Mixed GC 的次数。
ZGC
你有没有感觉,在系统切换到 G1 垃圾回收器之后,线上发生的严重 GC 问题已经非常少了?
这归功于 G1 的预测模型和它创新的分区模式。但预测模型也会有失效的时候,它并不是总如我们期望的那样运行,尤其是你给它定下一个苛刻的目标之后。
另外,如果应用的内存非常吃紧,对内存进行部分回收根本不够,始终要进行整个 Heap 的回收,那么 G1 要做的工作量就一点也不会比其他垃圾回收器少,而且因为本身算法复杂了,还可能比其他回收器要差。
所以垃圾回收器本身的优化和升级,从来都没有停止过。最新的 ZGC 垃圾回收器,就有 3 个令人振奋的 Flag
停顿时间不会超过 10ms
停顿时间不会随着堆的增大而增大(不管多大的堆都能保持在 10ms 以下);
可支持几百 M甚至几 T 的堆大小(最大支持 4T
在 ZGC 中,连逻辑上的年轻代和老年代也去掉了,只分为一块块的 page每次进行 GC 时,都会对 page 进行压缩操作所以没有碎片问题。ZGC 还能感知 NUMA 架构提高内存的访问速度。与传统的收集算法相比ZGC 直接在对象的引用指针上做文章,用来标识对象的状态,所以它只能用在 64 位的机器上。
现在在线上使用 ZGC 的还非常少。即使是用,也只能在 Linux 平台上使用。等待它的普及,还需要一段时间。
小结
本课时,我们简要看了下 G1 垃圾回收器的回收过程,并着重看了一下底层的数据结构 RSet。基本思想很简单但实现细节却特别多。这不是我们的重点对 G1 详细过程感兴趣的,可以参考纸质书籍。我也会通过其他途径分享一些细节,你也可以关注拉勾教育公众号后进学习群与大家一起多多交流。
相对于 CMSG1 有了更可靠的驾驭度。而且有 RSet 和 SATB 等算法的支撑Remark 阶段更加高效。
G1 最重要的概念,其实就是 Region。它采用分而治之部分收集的思想尽力达到我们给它设定的停顿目标。
G1 的垃圾回收过程分为三种,其中,并发标记阶段,为更加复杂的 Mixed GC 阶段做足了准备。
以下是一个线上运行系统的 JVM 参数样例。这些参数,现在你都能看懂么?如果有问题可以在评论区讨论。
JAVA_OPTS="$JAVA_OPTS -XX:NewRatio=2 -XX:G1HeapRegionSize=8m -XX:MetaspaceSize
=256m -XX:MaxMetaspaceSize=256m -XX:MaxTenuringThreshold=10 -XX:+UseG1GC
-XX:InitiatingHeapOccupancyPercent=45 -XX:MaxGCPauseMillis=200 -verbose:gc
-XX:+PrintGCDetails -XX:+PrintGCTimeStamps -XX:+PrintReferenceGC
-XX:+PrintAdaptiveSizePolicy -XX:+UseGCLogFileRotation -XX:NumberOfGCLogFiles=6
-XX:GCLogFileSize=32m -Xloggc:./var/run/gc.log.$(date +%Y%m%d%H%M)
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=./var/run/heap-dump.hprof
-Dfile.encoding=UTF-8 -Dcom.sun.management.jmxremote -Dcom.sun.management.
jmxremote.port=${JMX_PORT:-0} -Dcom.sun.management.jmxremote.ssl=false
-Dcom.sun.management.jmxremote.authenticate=false"

View File

@ -0,0 +1,233 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
09 案例实战:亿级流量高并发下如何进行估算和调优
本课时主要讲解如何在大流量高并发场景下进行估算和调优。
我们知道,垃圾回收器一般使用默认参数,就可以比较好的运行。但如果用错了某些参数,那么后果可能会比较严重,我不只一次看到有同学想要验证某个刚刚学到的优化参数,结果引起了线上 GC 的严重问题。
所以你的应用程序如果目前已经满足了需求,那就不要再随便动这些参数了。另外,优化代码获得的性能提升,远远大于参数调整所获得的性能提升,你不要纯粹为了调参数而走了弯路。
那么GC 优化有没有可遵循的一些规则呢?这些“需求”又是指的什么?我们可以将目标归结为三点:
系统容量Capacity
延迟Latency
吞吐量Throughput
考量指标
系统容量
系统容量其实非常好理解。比如,领导要求你每个月的运维费用不能超过 x 万,那就决定了你的机器最多是 2C4G 的。
举个比较极端的例子。假如你的内存是无限大的,那么无论是存活对象,还是垃圾对象,都不需要额外的计算和回收,你只需要往里放就可以了。这样,就没有什么吞吐量和延迟的概念了。
但这毕竟是我们的一厢情愿。越是资源限制比较严格的系统,对它的优化就会越明显。通常在一个资源相对宽松的环境下优化的参数,平移到另外一个限制资源的环境下,并不是最优解。
吞吐量-延迟
接下来我们看一下吞吐量和延迟方面的概念。
假如你开了一个面包店,你的首要目标是卖出更多的面包,因为赚钱来说是最要紧的。
为了让客人更快买到面包,你引进了很多先进的设备,使得制作面包的间隔减少到 30 分钟,一批面包可以有 100 个。
工人师傅是拿工资的,并不想和你一样加班。按照一天 8 小时工作制,每天就可以制作 8x2x100=1600 个面包。
但是你很不满意,因为每天的客人都很多,需求大约是 2000 个面包。
你只好再引进更加先进的设备,这种设备可以一次做出 200 个面包,一天可以做 2000~3000 个面包,但是每运行一段时间就需要冷却一会儿。
原来每个客人最多等 30 分钟就可以拿到面包,现在有的客人需要等待 40 分钟。客人通常受不了这么长的等待时间,第二天就不来了。
考虑到我们的营业目标,就可以抽象出两个概念。
吞吐量,也就是每天制作的面包数量。
延迟,也就是等待的时间,涉及影响顾客的满意度。
吞吐量大不代表响应能力高,吞吐量一般这么描述:在一个时间段内完成了多少个事务操作;在一个小时之内完成了多少批量操作。
响应能力是以最大的延迟时间来判断的,比如:一个桌面按钮对一个触发事件响应有多快;需要多长时间返回一个网页;查询一行 SQL 需要多长时间,等等。
这两个目标,在有限的资源下,通常不能够同时达到,我们需要做一些权衡。
选择垃圾回收器
接下来,再回顾一下前面介绍的垃圾回收器,简单看一下它们的应用场景。
如果你的堆大小不是很大(比如 100MB选择串行收集器一般是效率最高的。参数-XX:+UseSerialGC。
如果你的应用运行在单核的机器上,或者你的虚拟机核数只有 1C选择串行收集器依然是合适的这时候启用一些并行收集器没有任何收益。参数-XX:+UseSerialGC。
如果你的应用是“吞吐量”优先的,并且对较长时间的停顿没有什么特别的要求。选择并行收集器是比较好的。参数:-XX:+UseParallelGC。
如果你的应用对响应时间要求较高,想要较少的停顿。甚至 1 秒的停顿都会引起大量的请求失败,那么选择 G1、ZGC、CMS 都是合理的。虽然这些收集器的 GC 停顿通常都比较短,但它需要一些额外的资源去处理这些工作,通常吞吐量会低一些。参数:-XX:+UseConcMarkSweepGC、-XX:+UseG1GC、-XX:+UseZGC 等。
从上面这些出发点来看,我们平常的 Web 服务器,都是对响应性要求非常高的。选择性其实就集中在 CMS、G1、ZGC 上。
而对于某些定时任务,使用并行收集器,是一个比较好的选择。
大流量应用特点
这是一类对延迟非常敏感的系统。吞吐量一般可以通过堆机器解决。
如果一项业务有价值,客户很喜欢,那亿级流量很容易就能达到了。假如某个接口一天有 10 亿次请求,每秒的峰值大概也就 5~6 w/秒,虽然不算是很大,但也不算小。最直接的影响就是:可能你发个版,几万用户的请求就抖一抖。
一般达到这种量级的系统,承接请求的都不是一台服务器,接口都会要求快速响应,一般不会超过 100ms。
这种系统,一般都是社交、电商、游戏、支付场景等,要求的是短、平、快。长时间停顿会堆积海量的请求,所以在停顿发生的时候,表现会特别明显。我们要考量这些系统,有很多指标。
每秒处理的事务数量TPS
平均响应时间AVG
TP 值,比如 TP90 代表有 90% 的请求响应时间小于 x 毫秒。
可以看出来,它和 JVM 的某些指标很像。
尤其是 TP 值最能代表系统中到底有多少长尾请求这部分请求才是影响系统稳定性的元凶。大多数情况下GC 增加,长尾请求的数量也会增加。
我们的目标,就是减少这些停顿。本课时假定使用的是 CMS 垃圾回收器。
估算
在《编程珠玑》第七章里,将估算看作程序员的一项非常重要的技能。这是一种化繁为简的能力,不要求极度精确,但对问题的分析有着巨大的帮助。
拿一个简单的 Feed 业务来说。查询用户在社交网站上发送的帖子,还需要查询第一页的留言(大概是 15 条),它们共同组成了每次查询后的实体。
class Feed{
private User user;
private List<Comment> commentList;
private String content;
}
这种类型的数据结构,一般返回体都比较大,大概会有几 KB 到几十 KB 不等。我们就可以对这些数据进行以大体估算。具体的数据来源可以看日志,也可以分析线上的请求。
这个接口每天有 10 亿次请求,假如每次请求的大小有 20KB很容易达到那么一天的流量就有 18TB 之巨。假如高峰请求 6w/s我们部署了 10 台机器,那么每个 JVM 的流量就可以达到 120MB/s这个速度算是比较快的了。
如果你实在不知道怎么去算这个数字,那就按照峰值的 2 倍进行准备,一般都是 OK 的。
调优
问题是这样的,我们的机器是 4C8GB 的,分配给了 JVM 1024*8GB/3*2= 5460MB 的空间。那么年轻代大小就有 5460MB/3=1820MB。进而可以推断出Eden 区的大小约 1456MB那么大约只需要 12 秒,就会发生一次 Minor GC。不仅如此每隔半个小时会发生一次 Major GC。
不管是年轻代还是老年代,这个 GC 频率都有点频繁了。
提醒一下,你可以算一下我们的 Survivor 区大小,大约是 182MB 左右,如果稍微有点流量偏移,或者流量突增,再或者和其他接口共用了 JVM那么这个 Survivor 区就已经装不下 Minor GC 后的内容了。总有一部分超出的容量,需要老年代来补齐。这些垃圾信息就要保存更长时间,直到老年代空间不足。
我们发现,用户请求完这些信息之后,很快它们就会变成垃圾。所以每次 MinorGC 之后,剩下的对象都很少。
也就是说,我们的流量虽然很多,但大多数都在年轻代就销毁了。如果我们加大年轻代的大小,由于 GC 的时间受到活跃对象数的影响,回收时间并不会增加太多。
如果我们把一半空间给年轻代。也就是下面的配置:
-XX:+UseConcMarkSweepGC -Xmx5460M -Xms5460M -Xmn2730M
重新估算一下,发现 Minor GC 的间隔,由 12 秒提高到了 18 秒。
线上观察:
[ParNew: 2292326K>243160K(2795520K), 0.1021743 secs]
3264966K>10880154K(1215800K), 0.1021417 secs]
[Times: user=0.52 sys=0.02, real=0.2 secs]
Minor GC 有所改善但是并没有显著的提升。相比较而言Major GC 的间隔却增加到了 3 小时,是一个非常大的性能优化。这就是在容量限制下的初步调优方案。
此种场景,我们可以更加激进一些,调大年轻代(顺便调大了幸存区),让对象在年轻代停留的时间更长一些,有更多的 buffer 空间。这样 Minor GC 间隔又可以提高到 23 秒。参数配置:
-XX:+UseConcMarkSweepGC -Xmx5460M -Xms5460M -Xmn3460M
一切看起来很美好,但还是有一个瑕疵。
问题如下由于每秒的请求都非常大如果应用重启或者更新流量瞬间打过来JVM 还没预热完毕,这时候就会有大量的用户请求超时、失败。
为了解决这种问题,通常会逐步的把新发布的机器进行放量预热。比如第一秒 100 请求,第二秒 200 请求,第三秒 5000 请求。大型的应用都会有这个预热过程。
如图所示负载均衡器负责服务的放量server4 将在 6 秒之后流量正常流通。但是奇怪的是,每次重启大约 20 多秒以后,就会发生一次诡异的 Full GC。
注意是 Full GC而不是老年代的 Major GC也不是年轻代的 Minor GC。
事实上,经过观察,此时年轻代和老年代的空间还有很大一部分,那 Full GC 是怎么产生的呢?
一般Full GC 都是在老年代空间不足的时候执行。但不要忘了,我们还有一个区域叫作 Metaspace它的容量是没有上限的但是每当它扩容时就会发生 Full GC。
使用下面的命令可以看到它的默认值:
java -XX:+PrintFlagsFinal 2>&1 | grep Meta
默认值如下:
size_t MetaspaceSize = 21807104 {pd product} {default}
size_t MaxMetaspaceSize = 18446744073709547520 {product} {default}
可以看到 MetaspaceSize 的大小大约是 20MB。这个初始值太小了。
现在很多类库,包括 Spring都会大量生成一些动态类20MB 很容易就超了,我们可以试着调大这个数值。
按照经验,一般调整成 256MB 就足够了。同时,为了避免无限制使用造成操作系统内存溢出,我们同时设置它的上限。配置参数如下:
-XX:+UseConcMarkSweepGC -Xmx5460M -Xms5460M -Xmn3460M -XX:MetaspaceSize=256M -XX:MaxMetaspaceSize=256M
经观察,启动后停顿消失。
这种方式通常是行之有效的,但也可以通过扩容机器内存或者扩容机器数量的办法,显著地降低 GC 频率。这些都是在估算容量后的优化手段。
我们把部分机器升级到 8C16GB 的机器,使用如下的参数:
-XX:+UseConcMarkSweepGC -Xmx10920M -Xms10920M -Xmn5460M -XX:MetaspaceSize=256M -XX:MaxMetaspaceSize=256M
相比较其他实例,系统运行的特别棒,系统平均 1 分钟左右发生一次 MinorGC老年代观察了一天才发生 GC响应水平明显提高。
这是一种非常简单粗暴的手段,但是有效。我们看到,对 JVM 的优化,不仅仅是优化参数本身。我们的目的是解决问题,寻求多种有用手段。
总结
其实,如果没有明显的内存泄漏问题和严重的性能问题,专门调优一些 JVM 参数是非常没有必要的,优化空间也比较小。
所以,我们一般优化的思路有一个重要的顺序:
程序优化,效果通常非常大;
扩容,如果金钱的成本比较小,不要和自己过不去;
参数调优,在成本、吞吐量、延迟之间找一个平衡点。
本课时主要是在第三点的基础上,一步一步地增加 GC 的间隔,达到更好的效果。
我们可以再加一些原则用以辅助完成优化。
一个长时间的压测是必要的,通常我们使用 JMeter 工具。
如果线上有多个节点,可以把我们的优化在其中几个节点上生效。等优化真正有效果之后再全面推进。
优化过程和目标之间可能是循环的,结果和目标不匹配,要推翻重来。
我们的业务场景是高并发的。对象诞生的快,死亡的也快,对年轻代的利用直接影响了整个堆的垃圾收集。
足够大的年轻代,会增加系统的吞吐,但不会增加 GC 的负担。
容量足够的 Survivor 区,能够让对象尽可能的留在年轻代,减少对象的晋升,进而减少 Major GC。
我们还看到了一个元空间引起的 Full GC 的过程,这在高并发的场景下影响会格外突出,尤其是对于使用了大量动态类的应用来说。通过调大它的初始值,可以解决这个问题。

View File

@ -0,0 +1,413 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
10 第09讲案例实战面对突如其来的 GC 问题如何下手解决
本课时我们主要从一个实战案例入手分析面对突如其来的 GC 问题该如何下手解决。
想要下手解决 GC 问题,我们首先需要掌握下面这三种问题。
如何使用 jstat 命令查看 JVM 的 GC 情况?
面对海量 GC 日志参数,如何快速抓住问题根源?
你不得不掌握的日志分析工具。
工欲善其事,必先利其器。我们前面课时讲到的优化手段,包括代码优化、扩容、参数优化,甚至我们的估算,都需要一些支撑信息加以判断。
对于 JVM 来说,一种情况是 GC 时间过长,会影响用户的体验,这个时候就需要调整某些 JVM 参数、观察日志。
另外一种情况就比较严重了,发生了 OOM或者操作系统的内存溢出。服务直接宕机我们要寻找背后的原因。
这时GC 日志能够帮我们找到问题的根源。本课时,我们就简要介绍一下如何输出这些日志,以及如何使用这些日志的支撑工具解决问题。
GC 日志输出
你可能感受到,最近几年 Java 的版本更新速度是很快的JVM 的参数配置其实变化也很大。就拿 GC 日志这一块来说Java 9 几乎是推翻重来。网络上的一些文章,把这些参数写的乱七八糟,根本不能投入生产。如果你碰到不能被识别的参数,先确认一下自己的 Java 版本。
在事故出现的时候,通常并不是那么温柔。你可能在半夜里就能接到报警电话,这是因为很多定时任务都设定在夜深人静的时候执行。
这个时候,再去看 jstat 已经来不及了,我们需要保留现场。这个便是看门狗的工作,看门狗可以通过设置一些 JVM 参数进行配置。
那在实践中,要怎么用呢?请看下面命令行。
Java 8
我们先看一下 JDK8 中的使用。
#!/bin/sh
LOG_DIR="/tmp/logs"
JAVA_OPT_LOG=" -verbose:gc"
JAVA_OPT_LOG="${JAVA_OPT_LOG} -XX:+PrintGCDetails"
JAVA_OPT_LOG="${JAVA_OPT_LOG} -XX:+PrintGCDateStamps"
JAVA_OPT_LOG="${JAVA_OPT_LOG} -XX:+PrintGCApplicationStoppedTime"
JAVA_OPT_LOG="${JAVA_OPT_LOG} -XX:+PrintTenuringDistribution"
JAVA_OPT_LOG="${JAVA_OPT_LOG} -Xloggc:${LOG_DIR}/gc_%p.log"
JAVA_OPT_OOM=" -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=${LOG_DIR} -XX:ErrorFile=${LOG_DIR}/hs_error_pid%p.log "
JAVA_OPT="${JAVA_OPT_LOG} ${JAVA_OPT_OOM}"
JAVA_OPT="${JAVA_OPT} -XX:-OmitStackTraceInFastThrow"
合成一行。
-verbose:gc -XX:+PrintGCDetails -XX:+PrintGCDateStamps
-XX:+PrintGCApplicationStoppedTime -XX:+PrintTenuringDistribution
-Xloggc:/tmp/logs/gc_%p.log -XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/tmp/logs -XX:ErrorFile=/tmp/logs/hs_error_pid%p.log
-XX:-OmitStackTraceInFastThrow
然后我们来解释一下这些参数:
参数
意义
-verbose:gc
打印 GC 日志
PrintGCDetails
打印详细 GC 日志
PrintGCDateStamps
系统时间更加可读PrintGCTimeStamps 是 JVM 启动时间
PrintGCApplicationStoppedTime
打印 STW 时间
PrintTenuringDistribution
打印对象年龄分布,对调优 MaxTenuringThreshold 参数帮助很大
loggc
将以上 GC 内容输出到文件中
再来看下 OOM 时的参数:
参数
意义
HeapDumpOnOutOfMemoryError
OOM 时 Dump 信息,非常有用
HeapDumpPath
Dump 文件保存路径
ErrorFile
错误日志存放路径
注意到我们还设置了一个参数 OmitStackTraceInFastThrow这是 JVM 用来缩简日志输出的。
开启这个参数之后,如果你多次发生了空指针异常,将会打印以下信息。
java.lang.NullPointerException
java.lang.NullPointerException
java.lang.NullPointerException
java.lang.NullPointerException
在实际生产中,这个参数是默认开启的,这样就导致有时候排查问题非常不方便(很多研发对此无能为力),我们这里把它关闭,但这样它会输出所有的异常堆栈,日志会多很多。
Java 13
再看下 JDK 13 中的使用。
从 Java 9 开始,移除了 40 多个 GC 日志相关的参数。具体参见 JEP 158。所以这部分的日志配置有很大的变化。
我们同样看一下它的生成脚本。
#!/bin/sh
LOG_DIR="/tmp/logs"
JAVA_OPT_LOG=" -verbose:gc"
JAVA_OPT_LOG="${JAVA_OPT_LOG} -Xlog:gc,gc+ref=debug,gc+heap=debug,gc+age=trace:file=${LOG_DIR}/gc_%p.log:tags,uptime,time,level"
JAVA_OPT_LOG="${JAVA_OPT_LOG} -Xlog:safepoint:file=${LOG_DIR}/safepoint_%p.log:tags,uptime,time,level"
JAVA_OPT_OOM=" -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=${LOG_DIR} -XX:ErrorFile=${LOG_DIR}/hs_error_pid%p.log "
JAVA_OPT="${JAVA_OPT_LOG} ${JAVA_OPT_OOM}"
JAVA_OPT="${JAVA_OPT} -XX:-OmitStackTraceInFastThrow"
echo $JAVA_OPT
合成一行展示。
-verbose:gc -Xlog:gc,gc+ref=debug,gc+heap=debug,gc+age=trace:file
=/tmp/logs/gc_%p.log:tags,uptime,time,level -Xlog:safepoint:file=/tmp
/logs/safepoint_%p.log:tags,uptime,time,level -XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/tmp/logs -XX:ErrorFile=/tmp/logs/hs_error_pid%p.log
-XX:-OmitStackTraceInFastThrow
可以看到 GC 日志的打印方式,已经完全不一样,但是比以前的日志参数规整了许多。
我们除了输出 GC 日志,还输出了 safepoint 的日志。这个日志对我们分析问题也很重要,那什么叫 safepoint 呢?
safepoint 是 JVM 中非常重要的一个概念,指的是可以安全地暂停线程的点。
当发生 GC 时,用户线程必须全部停下来,才可以进行垃圾回收,这个状态我们可以认为 JVM 是安全的safe整个堆的状态是稳定的。
如果在 GC 前,有线程迟迟进入不了 safepoint那么整个 JVM 都在等待这个阻塞的线程,会造成了整体 GC 的时间变长。
所以呢,并不是只有 GC 会挂起 JVM进入 safepoint 的过程也会。这个概念,如果你有兴趣可以自行深挖一下,一般是不会出问题的。
如果面试官问起你在项目中都使用了哪些打印 GC 日志的参数,上面这些信息肯定是不很好记忆。你需要进行以下总结。比如:
“我一般在项目中输出详细的 GC 日志,并加上可读性强的 GC 日志的时间戳。特别情况下我还会追加一些反映对象晋升情况和堆详细信息的日志用来排查问题。另外OOM 时自动 Dump 堆栈,我一般也会进行配置”。
GC 日志的意义
我们首先看一段日志,然后简要看一下各个阶段的意义。
1 表示 GC 发生的时间,一般使用可读的方式打印;
2 表示日志表明是 G1 的“转移暂停: 混合模式”,停顿了约 223ms
3 表明由 8 个 Worker 线程并行执行,消耗了 214ms
4 表示 Diff 越小越好,说明每个工作线程的速度都很均匀;
5 表示外部根区扫描外部根是堆外区。JNI 引用JVM 系统目录Classloaders 等;
6 表示更新 RSet 的时间信息;
7 表示该任务主要是对 CSet 中存活对象进行转移(复制);
8 表示花在 GC 之外的工作线程的时间;
9 表示并行阶段的 GC 总时间;
10 表示其他清理活动;
11表示收集结果统计
12 表示时间花费统计。
可以看到 GC 日志描述了垃圾回收器过程中的几乎每一个阶段。但即使你了解了这些数值的意义,在分析问题时,也会感到吃力,我们一般使用图形化的分析工具进行分析。
尤其注意的是最后一行日志,需要详细描述。可以看到 G C花费的时间竟然有 3 个数值。这个数值你可能在多个地方见过。如果你手头有 Linux 机器,可以执行以下命令:
time ls /
可以看到一段命令的执行,同样有三种纬度的时间统计。接下来解释一下这三个字段的意思。
real 实际花费的时间,指的是从开始到结束所花费的时间。比如进程在等待 I/O 完成,这个阻塞时间也会被计算在内;
user 指的是进程在用户态User Mode所花费的时间只统计本进程所使用的时间注意是指多核
sys 指的是进程在核心态Kernel Mode花费的 CPU 时间量,指的是内核中的系统调用所花费的时间,只统计本进程所使用的时间。
在上面的 GC 日志中real < user + sys因为我们使用了多核进行垃圾收集所以实际发生的时间比 (user + sys) 少很多在多核机器上这很常见
[Times: user=1.64 sys=0.00, real=0.23 secs]
下面是一个串行垃圾收集器收集的 GC 时间的示例。由于串行垃圾收集器始终仅使用一个线程,因此实际使用的时间等于用户和系统时间的总和:
[Times: user=0.29 sys=0.00, real=0.29 secs]
那我们统计 GC 以哪个时间为准呢?一般来说,用户只关心系统停顿了多少秒,对实际的影响时间非常感兴趣。至于背后是怎么实现的,是多核还是单核,是用户态还是内核态,它们都不关心。所以我们直接使用 real 字段。
GC日志可视化
肉眼可见的这些日志信息,让人非常头晕,尤其是日志文件特别大的时候。所幸现在有一些在线分析平台,可以帮助我们分析这个过程。下面我们拿常用的 gceasy 来看一下。
以下是一个使用了 G1 垃圾回收器,堆内存为 6GB 的服务,运行 5 天的 GC 日志。
1堆信息
我们可以从图中看到堆的使用情况。
2关键信息
从图中我们可以看到一些性能的关键信息。
吞吐量98.6%(一般超过 95% 就 ok 了);
最大延迟230ms平均延迟42.8ms
延迟要看服务的接受程度,比如 SLA 定义 50ms 返回数据,上面的最大延迟就会有一点问题。本服务接近 99% 的停顿在 100ms 以下,可以说算是非常优秀了。
你在看这些信息的时候,一定要结合宿主服务器的监控去看。比如 GC 发生期间CPU 会突然出现尖锋,就证明 GC 对 CPU 资源使用的有点多。但多数情况下,如果吞吐量和延迟在可接受的范围内,这些对 CPU 的超额使用是可以忍受的。
3交互式图表
可以对有问题的区域进行放大查看,图中表示垃圾回收后的空间释放,可以看到效果是比较好的。
4G1 的时间耗时
如图展示了 GC 的每个阶段花费的时间。可以看到平均耗时最长的阶段,就是 Concurrent Mark 阶段但由于是并发的影响并不大。随着时间的推移YoungGC 竟然达到了 136485 次。运行 5 天,光花在 GC 上的时间就有 2 个多小时,还是比较可观的。
5其他
如图所示,整个 JVM 创建了 100 多 T 的数据,其中有 2.4TB 被 promoted 到老年代。
另外,还有一些 safepoint 的信息等,你可以自行探索。
那到底什么样的数据才是有问题的呢gceasy 提供了几个案例。比如下面这个就是停顿时间明显超长的 GC 问题。
下面这个是典型的内存泄漏。
上面这些问题都是非常明显的。但大多数情况下,问题是偶发的。从基本的衡量指标,就能考量到整体的服务水准。如果这些都没有问题,就要看曲线的尖峰。
一般来说,任何不平滑的曲线,都是值得怀疑的,那就需要看一下当时的业务情况具体是什么样子的。是用户请求突增引起的,还是执行了一个批量的定时任务,再或者查询了大批量的数据,这要和一些服务的监控一起看才能定位出根本问题。
只靠 GC 来定位问题是比较困难的,我们只需要知道它有问题就可以了。后面,会介绍更多的支持工具进行问题的排解。
为了方便你调试使用,我在 GitHub 上上传了两个 GC 日志。其中 gc01.tar.gz 就是我们现在正在看的,解压后有 200 多兆;另外一个 gc02.tar.gz 是一个堆空间为 1GB 的日志文件,你也可以下载下来体验一下。
GitHub 地址:
https://gitee.com/xjjdog/jvm-lagou-res
另外GCViewer 这个工具也是常用的,可以下载到本地,以 jar 包的方式运行。
在一些极端情况下,也可以使用脚本简单过滤一下。比如下面行命令,就是筛选停顿超过 100ms 的 GC 日志和它的行数G1
# grep -n real gc.log | awk -F"=| " '{ if($8>0.1){ print }}'
1975: [Times: user=2.03 sys=0.93, real=0.75 secs]
2915: [Times: user=1.82 sys=0.65, real=0.64 secs]
16492: [Times: user=0.47 sys=0.89, real=0.35 secs]
16627: [Times: user=0.71 sys=0.76, real=0.39 secs]
16801: [Times: user=1.41 sys=0.48, real=0.49 secs]
17045: [Times: user=0.35 sys=1.25, real=0.41 secs]
jstat
上面的可视化工具,必须经历导出、上传、分析三个阶段,这种速度太慢了。有没有可以实时看堆内存的工具?
你可能会第一时间想到 jstat 命令。第一次接触这个命令,我也是很迷惑的,主要是输出的字段太多,不了解什么意义。
但其实了解我们在前几节课时所讲到内存区域划分和堆划分之后,再看这些名词就非常简单了。
我们拿 -gcutil 参数来说明一下。
jstat -gcutil $pid 1000
只需要提供一个 Java 进程的 ID然后指定间隔时间毫秒就 OK 了。
S0 S1 E O M CCS YGC YGCT FGC FGCT GCT
0.00 0.00 72.03 0.35 54.12 55.72 11122 16.019 0 0.000 16.019
0.00 0.00 95.39 0.35 54.12 55.72 11123 16.024 0 0.000 16.024
0.00 0.00 25.32 0.35 54.12 55.72 11125 16.025 0 0.000 16.025
0.00 0.00 37.00 0.35 54.12 55.72 11126 16.028 0 0.000 16.028
0.00 0.00 60.35 0.35 54.12 55.72 11127 16.028 0 0.000 16.028
可以看到E 其实是 Eden 的缩写S0 对应的是 Surivor0S1 对应的是 Surivor1O 代表的是 Old而 M 代表的是 Metaspace。
YGC 代表的是年轻代的回收次数YGC T对应的是年轻代的回收耗时。那么 FGC 肯定代表的是 Full GC 的次数。
你在看日志的时候,一定要注意其中的规律。-gcutil 位置的参数可以有很多种。我们最常用的有 gc、gcutil、gccause、gcnew 等,其他的了解一下即可。
gc: 显示和 GC 相关的 堆信息;
gcutil: 显示 垃圾回收信息;
gccause: 显示垃圾回收 的相关信息(同 -gcutil同时显示 最后一次 或 当前 正在发生的垃圾回收的 诱因;
gcnew: 显示 新生代 信息;
gccapacity: 显示 各个代 的 容量 以及 使用情况;
gcmetacapacity: 显示 元空间 metaspace 的大小;
gcnewcapacity: 显示 新生代大小 和 使用情况;
gcold: 显示 老年代 和 永久代 的信息;
gcoldcapacity: 显示 老年代 的大小;
printcompilation: 输出 JIT 编译 的方法信息;
class: 显示 类加载 ClassLoader 的相关信息;
compiler: 显示 JIT 编译 的相关信息;
如果 GC 问题特别明显,通过 jstat 可以快速发现。我们在启动命令行中加上参数 -t可以输出从程序启动到现在的时间。如果 FGC 和启动时间的比值太大就证明系统的吞吐量比较小GC 花费的时间太多了。另外,如果老年代在 Full GC 之后,没有明显的下降,那可能内存已经达到了瓶颈,或者有内存泄漏问题。
下面这行命令,就追加了 GC 时间的增量和 GC 时间比率两列。
jstat -gcutil -t 90542 1000 | awk 'BEGIN{pre=0}{if(NR>1) {print $0 "\t" ($12-pre) "\t" $12*100/$1 ; pre=$12 } else { print $0 "\tGCT_INC\tRate"} }'
Timestamp S0 S1 E O M CCS YGC YGCT FGC FGCT GCT GCT_INC Rate
18.7 0.00 100.00 6.02 1.45 84.81 76.09 1 0.002 0 0.000 0.002 0.002 0.0106952
19.7 0.00 100.00 6.02 1.45 84.81 76.09 1 0.002 0 0.000 0.002 0 0.0101523
GC 日志也会搞鬼
顺便给你介绍一个实际发生的故障。
你知道 ElasticSearch 的速度是非常快的,我们为了压榨它的性能,对磁盘的读写几乎是全速的。它在后台做了很多 Merge 动作,将小块的索引合并成大块的索引。还有 TransLog 等预写动作,都是 I/O 大户。
使用 iostat -x 1 可以看到具体的 I/O 使用状况。
问题是,我们有一套 ES 集群,在访问高峰时,有多个 ES 节点发生了严重的 STW 问题。有的节点竟停顿了足足有 7~8 秒。
[Times: user=0.42 sys=0.03, real=7.62 secs]
从日志可以看到在 GC 时用户态只停顿了 420ms但真实的停顿时间却有 7.62 秒。
盘点一下资源,唯一超额利用的可能就是 I/O 资源了(%util 保持在 90 以上GC 可能在等待 I/O。
通过搜索,发现已经有人出现过这个问题,这里直接说原因和结果。
原因就在于,写 GC 日志的 write 动作,是统计在 STW 的时间里的。在我们的场景中,由于 ES 的索引数据,和 GC 日志放在了一个磁盘GC 时写日志的动作,就和写数据文件的动作产生了资源争用。
解决方式也是比较容易的,把 ES 的日志文件,单独放在一块普通 HDD 磁盘上就可以了。
小结
本课时,我们主要介绍了比较重要的 GC 日志,以及怎么输出它,并简要的介绍了一段 G1 日志的意义。对于这些日志的信息,能够帮助我们理解整个 GC 的过程,专门去记忆它投入和产出并不成正比,可以多看下 G1 垃圾回收器原理方面的东西。
接下来我们介绍了几个图形化分析 GC 的工具,这也是现在主流的使用方式,因为动辄几百 MB 的 GC 日志,是无法肉眼分辨的。如果机器的 I/O 问题很突出,就要考虑把 GC 日志移动到单独的磁盘。
我们尤其介绍了在线分析工具 gceasy你也可以下载 gcviewer 的 jar 包本地体验一下。
最后我们看了一个命令行的 GC 回收工具 jstat它的格式比较规整可以重定向到一个日志文件里后续使用 sed、awk 等工具进行分析。关于相关的两个命令,可以参考我以前写的两篇文章。
《Linux生产环境上最常用的一套“Sed“技巧》
《Linux生产环境上最常用的一套“AWK“技巧》

View File

@ -0,0 +1,519 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
11 第10讲动手实践自己模拟 JVM 内存溢出场景
本课时我们主要自己模拟一个 JVM 内存溢出的场景。在模拟 JVM 内存溢出之前我们先来看下这样的几个问题。
老年代溢出为什么那么可怕?
元空间也有溢出?怎么优化?
如何配置栈大小?避免栈溢出?
进程突然死掉,没有留下任何信息时如何进行排查?
年轻代由于有老年代的担保,一般在内存占满的时候,并没什么问题。但老年代满了就比较严重了,它没有其他的空间用来做担保,只能 OOM 了,也就是发生 Out Of Memery Error。JVM 会在这种情况下直接停止工作,是非常严重的后果。
OOM 一般是内存泄漏引起的,表现在 GC 日志里,一般情况下就是 GC 的时间变长了而且每次回收的效果都非常一般。GC 后,堆内存的实际占用呈上升趋势。接下来,我们将模拟三种溢出场景,同时使用我们了解的工具进行观测。
在开始之前,请你下载并安装一个叫作 VisualVM 的工具,我们使用这个图形化的工具看一下溢出过程。
虽然 VisualVM 工具非常好用,但一般生产环境都没有这样的条件,所以大概率使用不了。新版本 JDK 把这个工具单独抽离了出去,需要自行下载。
这里需要注意下载安装完成之后请在插件选项中勾选 Visual GC 下载,它将可视化内存布局。
堆溢出模拟
首先,我们模拟堆溢出的情况,在模拟之前我们需要准备一份测试代码。这份代码开放了一个 HTTP 接口,当你触发它之后,将每秒钟生成 1MB 的数据。由于它和 GC Roots 的强关联性,每次都不能被回收。
程序通过 JMX将在每一秒创建数据之后输出一些内存区域的占用情况。然后通过访问 http://localhost:8888 触发后,它将一直运行,直到堆溢出。
import com.sun.net.httpserver.HttpContext;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;
import java.io.OutputStream;
import java.lang.management.ManagementFactory;
import java.lang.management.MemoryPoolMXBean;
import java.net.InetSocketAddress;
import java.util.ArrayList;
import java.util.List;
public class OOMTest {
public static final int _1MB = 1024 * 1024;
static List<byte[]> byteList = new ArrayList<>();
private static void oom(HttpExchange exchange) {
try {
String response = "oom begin!";
exchange.sendResponseHeaders(200, response.getBytes().length);
OutputStream os = exchange.getResponseBody();
os.write(response.getBytes());
os.close();
} catch (Exception ex) {
}
for (int i = 0; ; i++) {
byte[] bytes = new byte[_1MB];
byteList.add(bytes);
System.out.println(i + "MB");
memPrint();
try {
Thread.sleep(1000);
} catch (Exception e) {
}
}
}
static void memPrint() {
for (MemoryPoolMXBean memoryPoolMXBean : ManagementFactory.getMemoryPoolMXBeans()) {
System.out.println(memoryPoolMXBean.getName() +
" committed:" + memoryPoolMXBean.getUsage().getCommitted() +
" used:" + memoryPoolMXBean.getUsage().getUsed());
}
}
private static void srv() throws Exception {
HttpServer server = HttpServer.create(new InetSocketAddress(8888), 0);
HttpContext context = server.createContext("/");
context.setHandler(OOMTest::oom);
server.start();
}
public static void main(String[] args) throws Exception{
srv();
}
}
我们使用 CMS 收集器进行垃圾回收,可以看到如下的信息。
命令:
java -Xmx20m -Xmn4m -XX:+UseConcMarkSweepGC -verbose:gc -Xlog:gc,
gc+ref=debug,gc+heap=debug,
gc+age=trace:file=/tmp/logs/gc_%p.log:tags,
uptime,
time,
level -Xlog:safepoint:file=/tmp/logs/safepoint_%p.log:tags,
uptime,
time,
level -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp/logs -XX:ErrorFile=/tmp/logs/hs_error_pid%p.log -XX:-OmitStackTraceInFastThrow OOMTest
输出:
[0.025s][info][gc] Using Concurrent Mark Sweep
0MB
CodeHeap non-nmethods committed:2555904 used:1120512
Metaspace committed:4980736 used:854432
CodeHeap profiled nmethods committed:2555904 used:265728
Compressed Class Space committed:524288 used:96184
Par Eden Space committed:3407872 used:2490984
Par Survivor Space committed:393216 used:0
CodeHeap non-profiled nmethods committed:2555904 used:78592
CMS Old Gen committed:16777216 used:0
…省略
[16.377s][info][gc] GC(9) Concurrent Mark 1.592ms
[16.377s][info][gc] GC(9) Concurrent Preclean
[16.378s][info][gc] GC(9) Concurrent Preclean 0.721ms
[16.378s][info][gc] GC(9) Concurrent Abortable Preclean
[16.378s][info][gc] GC(9) Concurrent Abortable Preclean 0.006ms
[16.378s][info][gc] GC(9) Pause Remark 17M->17M(19M) 0.344ms
[16.378s][info][gc] GC(9) Concurrent Sweep
[16.378s][info][gc] GC(9) Concurrent Sweep 0.248ms
[16.378s][info][gc] GC(9) Concurrent Reset
[16.378s][info][gc] GC(9) Concurrent Reset 0.013ms
17MB
CodeHeap non-nmethods committed:2555904 used:1120512
Metaspace committed:4980736 used:883760
CodeHeap profiled nmethods committed:2555904 used:422016
Compressed Class Space committed:524288 used:92432
Par Eden Space committed:3407872 used:3213392
Par Survivor Space committed:393216 used:0
CodeHeap non-profiled nmethods committed:2555904 used:88064
CMS Old Gen committed:16777216 used:16452312
[18.380s][info][gc] GC(10) Pause Initial Mark 18M->18M(19M) 0.187ms
[18.380s][info][gc] GC(10) Concurrent Mark
[18.384s][info][gc] GC(11) Pause Young (Allocation Failure) 18M->18M(19M) 0.186ms
[18.386s][info][gc] GC(10) Concurrent Mark 5.435ms
[18.395s][info][gc] GC(12) Pause Full (Allocation Failure) 18M->18M(19M) 10.572ms
[18.400s][info][gc] GC(13) Pause Full (Allocation Failure) 18M->18M(19M) 5.348ms
Exception in thread “main” java.lang.OutOfMemoryError: Java heap space
at OldOOM.main(OldOOM.java:20)
最后 JVM 在一阵疯狂的 GC 日志输出后进程停止了。在现实情况中JVM 在停止工作之前很多会垂死挣扎一段时间这个时候GC 线程会造成 CPU 飙升,但其实它已经不能工作了。
VisualVM 的截图展示了这个溢出结果。可以看到 Eden 区刚开始还是运行平稳的,内存泄漏之后就开始疯狂回收(其实是提升),老年代内存一直增长,直到 OOM。
很多参数会影响对象的分配行为,但不是非常必要,我们一般不去调整它们。为了观察这些参数的默认值,我们通常使用 -XX:+PrintFlagsFinal 参数,输出一些设置信息。
命令:
java -XX:+PrintFlagsFinal 2>&1 | grep SurvivorRatio
uintx SurvivorRatio = 8 {product} {default}
Java13 输出了几百个参数和默认值,我们通过修改一些参数来观测一些不同的行为。
NewRatio 默认值为 2表示年轻代是老年代的 1/2。追加参数 “-XX:NewRatio=1”可以把年轻代和老年代的空间大小调成一样大。在实践中我们一般使用 -Xmn 来设置一个固定值。注意,这两个参数不要用在 G1 垃圾回收器中。
SurvivorRatio 默认值为 8。表示伊甸区和幸存区的比例。在上面的例子中Eden 的内存大小为0.8*4MB。S 分区不到 1MB根本存不下我们的 1MB 数据。
MaxTenuringThreshold 这个值在 CMS 下默认为 6G1 下默认为 15。这是因为 G1 存在动态阈值计算。这个值和我们前面提到的对象提升有关,如果你想要对象尽量长的时间存在于年轻代,则在 CMS 中,可以把它调整到 15。
java -XX:+PrintFlagsFinal -XX:+UseConcMarkSweepGC 2>&1 | grep MaxTenuringThreshold
java -XX:+PrintFlagsFinal -XX:+UseG1GC 2>&1 | grep MaxTenuringThreshold
PretenureSizeThreshold 这个参数默认值是 0意味着所有的对象年轻代优先分配。我们把这个值调小一点再观测 JVM 的行为。追加参数 -XX:PretenureSizeThreshold=1024可以看到 VisualVm 中老年代的区域增长。
TargetSurvivorRatio 默认值为 50。在动态计算对象提升阈值的时候使用。计算时会从年龄最小的对象开始累加如果累加的对象大小大于幸存区的一半则将当前的对象 age 作为新的阈值,年龄大于此阈值的对象直接进入老年代。工作中不建议调整这个值,如果要调,请调成比 50 大的值。
你可以尝试着更改其他参数,比如垃圾回收器的种类,动态看一下效果。尤其注意每一项内存区域的内容变动,你会对垃圾回收器有更好的理解。
UseAdaptiveSizePolicy ,因为它和 CMS 不兼容,所以 CMS 下默认为 false但 G1 下默认为 true。这是一个非常智能的参数它是用来自适应调整空间大小的参数。它会在每次 GC 之后,重新计算 Eden、From、To 的大小。很多人在 Java 8 的一些配置中会见到这个参数,但其实在 CMS 和 G1 中是不需要显式设置的。
值的注意的是Java 8 默认垃圾回收器是 Parallel Scavenge它的这个参数是默认开启的有可能会发生把幸存区自动调小的可能造成一些问题显式的设置 SurvivorRatio 可以解决这个问题。
下面这张截图,是切换到 G1 之后的效果。
java -Xmx20m -XX:+UseG1GC -verbose:gc -Xlog:gc,gc+ref=debug,gc+heap=debug,gc+age=trace:file=/tmp/logs/gc%p.log:tags,uptime,time,level -Xlog:safepoint:file=/tmp/logs/safepoint%p.log:tags,uptime,time,level -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp/logs -XX:ErrorFile=/tmp/logs/hs_error_pid%p.log -XX:-OmitStackTraceInFastThrow OOMTest
可以通过下面这个命令调整小堆区的大小,来看一下这个过程。
-XX:G1HeapRegionSize=M
元空间溢出
堆一般都是指定大小的,但元空间不是。所以如果元空间发生内存溢出会更加严重,会造成操作系统的内存溢出。我们在使用的时候,也会给它设置一个上限 for safe。
元空间溢出主要是由于加载的类太多,或者动态生成的类太多。下面是一段模拟代码。通过访问 http://localhost:8888 触发后,它将会发生元空间溢出。
import com.sun.net.httpserver.HttpContext;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;
import java.io.OutputStream;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
import java.net.InetSocketAddress;
import java.net.URL;
import java.net.URLClassLoader;
import java.util.HashMap;
import java.util.Map;
public class MetaspaceOOMTest {
public interface Facade {
void m(String input);
}
public static class FacadeImpl implements Facade {
@Override
public void m(String name) {
}
}
public static class MetaspaceFacadeInvocationHandler implements InvocationHandler {
private Object impl;
public MetaspaceFacadeInvocationHandler(Object impl) {
this.impl = impl;
}
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
return method.invoke(impl, args);
}
}
private static Map<String, Facade> classLeakingMap = new HashMap<String, Facade>();
private static void oom(HttpExchange exchange) {
try {
String response = "oom begin!";
exchange.sendResponseHeaders(200, response.getBytes().length);
OutputStream os = exchange.getResponseBody();
os.write(response.getBytes());
os.close();
} catch (Exception ex) {
}
try {
for (int i = 0; ; i++) {
String jar = "file:" + i + ".jar";
URL[] urls = new URL[]{new URL(jar)};
URLClassLoader newClassLoader = new URLClassLoader(urls);
Facade t = (Facade) Proxy.newProxyInstance(newClassLoader,
new Class<?>[]{Facade.class},
new MetaspaceFacadeInvocationHandler(new FacadeImpl()));
classLeakingMap.put(jar, t);
}
} catch (Exception e) {
}
}
private static void srv() throws Exception {
HttpServer server = HttpServer.create(new InetSocketAddress(8888), 0);
HttpContext context = server.createContext("/");
context.setHandler(MetaspaceOOMTest::oom);
server.start();
}
public static void main(String[] args) throws Exception {
srv();
}
}
这段代码将使用 Java 自带的动态代理类,不断的生成新的 class。
java -Xmx20m -Xmn4m -XX:+UseG1GC -verbose:gc -Xlog:gc,gc+ref=debug,gc+heap=debug,gc+age=trace:file=/tmp/logs/gc%p.log:tags,uptime,time,level -Xlog:safepoint:file=/tmp/logs/safepoint%p.log:tags,uptime,time,level -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp/logs -XX:ErrorFile=/tmp/logs/hs_error_pid%p.log -XX:-OmitStackTraceInFastThrow -XX:MetaspaceSize=16M -XX:MaxMetaspaceSize=16M MetaspaceOOMTest
我们在启动的时候,限制 Metaspace 空间大小为 16MB。可以看到运行一小会之后Metaspace 会发生内存溢出。
[6.509s][info][gc] GC(28) Pause Young (Concurrent Start) (Metadata GC Threshold) 9M->9M(20M) 1.186ms
[6.509s][info][gc] GC(30) Concurrent Cycle
[6.534s][info][gc] GC(29) Pause Full (Metadata GC Threshold) 9M->9M(20M) 25.165ms
[6.556s][info][gc] GC(31) Pause Full (Metadata GC Clear Soft References) 9M->9M(20M) 21.136ms
[6.556s][info][gc] GC(30) Concurrent Cycle 46.668ms
java.lang.OutOfMemoryError: Metaspace
Dumping heap to /tmp/logs/java_pid36723.hprof …
Heap dump file created [17362313 bytes in 0.134 secs]
但假如你把堆 Metaspace 的限制给去掉,会更可怕。它占用的内存会一直增长。
堆外内存溢出
严格来说,上面的 Metaspace 也是属于堆外内存的。但是我们这里的堆外内存指的是 Java 应用程序通过直接方式从操作系统中申请的内存。所以严格来说,这里是指直接内存。
程序将通过 ByteBuffer 的 allocateDirect 方法每 1 秒钟申请 1MB 的直接内存。不要忘了通过链接触发这个过程。
但是,使用 VisualVM 看不到这个过程,使用 JMX 的 API 同样也看不到。关于这部分内容,我们将在堆外内存排查课时进行详细介绍。
import com.sun.net.httpserver.HttpContext;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;
import java.io.OutputStream;
import java.lang.management.ManagementFactory;
import java.lang.management.MemoryPoolMXBean;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.util.ArrayList;
import java.util.List;
public class OffHeapOOMTest {
public static final int _1MB = 1024 * 1024;
static List<ByteBuffer> byteList = new ArrayList<>();
private static void oom(HttpExchange exchange) {
try {
String response = "oom begin!";
exchange.sendResponseHeaders(200, response.getBytes().length);
OutputStream os = exchange.getResponseBody();
os.write(response.getBytes());
os.close();
} catch (Exception ex) {
}
for (int i = 0; ; i++) {
ByteBuffer buffer = ByteBuffer.allocateDirect(_1MB);
byteList.add(buffer);
System.out.println(i + "MB");
memPrint();
try {
Thread.sleep(1000);
} catch (Exception e) {
}
}
}
private static void srv() throws Exception {
HttpServer server = HttpServer.create(new InetSocketAddress(8888), 0);
HttpContext context = server.createContext("/");
context.setHandler(OffHeapOOMTest::oom);
server.start();
}
public static void main(String[] args) throws Exception {
srv();
}
static void memPrint() {
for (MemoryPoolMXBean memoryPoolMXBean : ManagementFactory.getMemoryPoolMXBeans()) {
System.out.println(memoryPoolMXBean.getName() +
" committed:" + memoryPoolMXBean.getUsage().getCommitted() +
" used:" + memoryPoolMXBean.getUsage().getUsed());
}
}
}
通过 top 或者操作系统的监控工具,能够看到内存占用的明显增长。为了限制这些危险的内存申请,如果你确定在自己的程序中用到了大量的 JNI 和 JNA 操作,要显式的设置 MaxDirectMemorySize 参数。
以下是程序运行一段时间抛出的错误。
Exception in thread “Thread-2” java.lang.OutOfMemoryError: Direct buffer memory
at java.nio.Bits.reserveMemory(Bits.java:694)
at java.nio.DirectByteBuffer.(DirectByteBuffer.java:123)
at java.nio.ByteBuffer.allocateDirect(ByteBuffer.java:311)
at OffHeapOOMTest.oom(OffHeapOOMTest.java:27)
at com.sun.net.httpserver.Filter$Chain.doFilter(Filter.java:79)
at sun.net.httpserver.AuthFilter.doFilter(AuthFilter.java:83)
at com.sun.net.httpserver.Filter$Chain.doFilter(Filter.java:82)
at sun.net.httpserver.ServerImpl\(Exchange\)LinkHandler.handle(ServerImpl.java:675)
at com.sun.net.httpserver.Filter$Chain.doFilter(Filter.java:79)
at sun.net.httpserver.ServerImpl$Exchange.run(ServerImpl.java:647)
at sun.net.httpserver.ServerImpl$DefaultExecutor.execute(ServerImpl.java:158)
at sun.net.httpserver.ServerImpl$Dispatcher.handle(ServerImpl.java:431)
at sun.net.httpserver.ServerImpl$Dispatcher.run(ServerImpl.java:396)
at java.lang.Thread.run(Thread.java:748)
启动命令。
java -XX:MaxDirectMemorySize=10M -Xmx10M OffHeapOOMTest
栈溢出
还记得我们的虚拟机栈么?栈溢出指的就是这里的数据太多造成的泄漏。通过 -Xss 参数可以设置它的大小。比如下面的命令就是设置栈大小为 128K。
-Xss128K
从这里我们也能了解到,由于每个线程都有一个虚拟机栈。线程的开销也是要占用内存的。如果系统中的线程数量过多,那么占用内存的大小也是非常可观的。
栈溢出不会造成 JVM 进程死亡,危害“相对较小”。下面是一个简单的模拟栈溢出的代码,只需要递归调用就可以了。
public class StackOverflowTest {
static int count = 0;
static void a() {
System.out.println(count);
count++;
b();
}
static void b() {
System.out.println(count);
count++;
a();
}
public static void main(String[] args) throws Exception {
a();
}
}
运行后,程序直接报错。
Exception in thread “main” java.lang.StackOverflowError
at java.io.PrintStream.write(PrintStream.java:526)
at java.io.PrintStream.print(PrintStream.java:597)
at java.io.PrintStream.println(PrintStream.java:736)
at StackOverflowTest.a(StackOverflowTest.java:5)
如果你的应用经常发生这种情况,可以试着调大这个值。但一般都是因为程序错误引起的,最好检查一下自己的代码。
进程异常退出
上面这几种溢出场景,都有明确的原因和报错,排查起来也是非常容易的。但是还有一类应用,死亡的时候,静悄悄的,什么都没留下。
以下问题已经不止一个同学问了:我的 Java 进程没了,什么都没留下,直接蒸发不见了
why是因为对象太多了么
这是趣味性和技巧性非常突出的一个问题。让我们执行 dmesg 命令,大概率会看到你的进程崩溃信息躺在那里。
为了能看到发生的时间,我们习惯性加上参数 Tdmesg -T
这个现象,其实和 Linux 的内存管理有关。由于 Linux 系统采用的是虚拟内存分配方式JVM 的代码、库、堆和栈的使用都会消耗内存,但是申请出来的内存,只要没真正 access过是不算的因为没有真正为之分配物理页面。
随着使用内存越用越多。第一层防护墙就是 SWAP当 SWAP 也用的差不多了,会尝试释放 cache当这两者资源都耗尽杀手就出现了。oom-killer 会在系统内存耗尽的情况下跳出来,选择性的干掉一些进程以求释放一点内存。
所以这时候我们的 Java 进程是操作系统“主动”终结的JVM 连发表遗言的机会都没有。这个信息,只能在操作系统日志里查找。
要解决这种问题,首先不能太贪婪。比如一共 8GB 的机器,你把整整 7.5GB 都分配给了 JVM。当操作系统内存不足时你的 JVM 就可能成为 oom-killer 的猎物。
相对于被动终结,还有一种主动求死的方式。有些同学,会在程序里面做一些判断,直接调用 System.exit() 函数。
这个函数危险得很,它将强制终止我们的应用,而且什么都不会留下。你应该扫描你的代码,确保这样的逻辑不会存在。
再聊一种最初级最常见还经常发生的,会造成应用程序意外死亡的情况,那就是对 Java 程序错误的启动方式。
很多同学对 Linux 不是很熟悉,使用 XShell 登陆之后,调用下面的命令进行启动。
java com.cn.AA &
这样调用还算有点意识,在最后使用了“&”号,以期望进程在后台运行。但可惜的是,很多情况下,随着 XShell Tab 页的关闭,或者等待超时,后面的 Java 进程就随着一块停止了,很让人困惑。
正确的启动方式,就是使用 nohup 关键字或者阻塞在其他更加长命的进程里比如docker
nohup java com.cn.AA &
进程这种静悄悄的死亡方式,通常会给我们的问题排查带来更多的困难。
在发生问题时,要确保留下了足够的证据,来支持接下来的分析。不能喊一句“出事啦”,然后就陷入无从下手的尴尬境地。
通常我们在关闭服务的时候会使用“kill -15”而不是“kill -9”以便让服务在临死之前喘口气。信号9和15的区别是面试经常问的一个问题也是一种非常有效的手段。
小结
本课时我们简单模拟了堆、元空间、栈的溢出。并使用 VisualVM 观察了这个过程。
接下来,我们了解到进程静悄悄消失的三种情况。如果你的应用也这样消失过,试着这样找找它。这三种情况也是一个故障排查流程中要考虑的环节,属于非常重要的边缘检查点。相信聪明的你,会将这些情况揉进自己的面试体系去,真正成为自己的实战经验。

View File

@ -0,0 +1,317 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
12 第11讲动手实践遇到问题不要慌轻松搞定内存泄漏
当一个系统在发生 OOM 的时候,行为可能会让你感到非常困惑。因为 JVM 是运行在操作系统之上的,操作系统的一些限制,会严重影响 JVM 的行为。故障排查是一个综合性的技术问题,在日常工作中要增加自己的知识广度。多总结、多思考、多记录,这才是正确的晋级方式。
现在的互联网服务,一般都做了负载均衡。如果一个实例发生了问题,不要着急去重启。万能的重启会暂时缓解问题,但如果不保留现场,可能就错失了解决问题的根本,担心的事情还会到来。
所以,当实例发生问题的时候,第一步是隔离,第二步才是问题排查。什么叫隔离呢?就是把你的这台机器从请求列表里摘除,比如把 nginx 相关的权重设成零。在微服务中,也有相应的隔离机制,这里默认你已经有了(面试也默认你已经有隔离功能了)。
本课时的内容将涉及非常多的 Linux 命令,对 JVM 故障排查的帮助非常大,你可以逐个击破。
1. GC 引起 CPU 飙升
我们有个线上应用单节点在运行一段时间后CPU 的使用会飙升,一旦飙升,一般怀疑某个业务逻辑的计算量太大,或者是触发了死循环(比如著名的 HashMap 高并发引起的死循环),但排查到最后其实是 GC 的问题。
在 Linux 上,分析哪个线程引起的 CPU 问题,通常有一个固定的步骤。我们下面来分解这个过程,这是面试频率极高的一个问题。
1使用 top 命令,查找到使用 CPU 最多的某个进程,记录它的 pid。使用 Shift + P 快捷键可以按 CPU 的使用率进行排序。
top
2再次使用 top 命令,加 -H 参数,查看某个进程中使用 CPU 最多的某个线程,记录线程的 ID。
top -Hp $pid
3使用 printf 函数,将十进制的 tid 转化成十六进制。
printf %x $tid
4使用 jstack 命令,查看 Java 进程的线程栈。
jstack $pid >$pid.log
5使用 less 命令查看生成的文件,并查找刚才转化的十六进制 tid找到发生问题的线程上下文。
less $pid.log
我们在 jstack 日志中找到了 CPU 使用最多的几个线程。
可以看到问题发生的根源,是我们的堆已经满了,但是又没有发生 OOM于是 GC 进程就一直在那里回收,回收的效果又非常一般,造成 CPU 升高应用假死。
接下来的具体问题排查,就需要把内存 dump 一份下来,使用 MAT 等工具分析具体原因了(将在第 12 课时讲解)。
2. 现场保留
可以看到这个过程是繁杂而冗长的,需要记忆很多内容。现场保留可以使用自动化方式将必要的信息保存下来,那一般在线上系统会保留哪些信息呢?下面我进行一下总结。
2.1. 瞬时态和历史态
为了协助我们的分析,这里创造了两个名词:瞬时态和历史态。瞬时态是指当时发生的、快照类型的元素;历史态是指按照频率抓取的,有固定监控项的资源变动图。
有很多信息,比如 CPU、系统内存等瞬时态的价值就不如历史态来的直观一些。因为瞬时状态无法体现一个趋势性问题比如斜率、求导等而这些信息的获取一般依靠监控系统的协作。
但对于 lsof、heap 等,这种没有时间序列概念的混杂信息,体积都比较大,无法进入监控系统产生有用价值,就只能通过瞬时态进行分析。在这种情况下,瞬时态的价值反而更大一些。我们常见的堆快照,就属于瞬时状态。
问题不是凭空产生的,在分析时,一般要收集系统的整体变更集合,比如代码变更、网络变更,甚至数据量的变化。
接下来对每一项资源的获取方式进行介绍。
2.2. 保留信息
1系统当前网络连接
ss -antp > $DUMP_DIR/ss.dump 2>&1
其中ss 命令将系统的所有网络连接输出到 ss.dump 文件中。使用 ss 命令而不是 netstat 的原因,是因为 netstat 在网络连接非常多的情况下,执行非常缓慢。
后续的处理,可通过查看各种网络连接状态的梳理,来排查 TIME_WAIT 或者 CLOSE_WAIT或者其他连接过高的问题非常有用。
线上有个系统更新之后,监控到 CLOSE_WAIT 的状态突增,最后整个 JVM 都无法响应。CLOSE_WAIT 状态的产生一般都是代码问题,使用 jstack 最终定位到是因为 HttpClient 的不当使用而引起的,多个连接不完全主动关闭。
2网络状态统计
netstat -s > $DUMP_DIR/netstat-s.dump 2>&1
此命令将网络统计状态输出到 netstat-s.dump 文件中。它能够按照各个协议进行统计输出,对把握当时整个网络状态,有非常大的作用。
sar -n DEV 1 2 > $DUMP_DIR/sar-traffic.dump 2>&1
上面这个命令,会使用 sar 输出当前的网络流量。在一些速度非常高的模块上,比如 Redis、Kafka就经常发生跑满网卡的情况。如果你的 Java 程序和它们在一起运行,资源则会被挤占,表现形式就是网络通信非常缓慢。
3进程资源
lsof -p $PID > $DUMP_DIR/lsof-$PID.dump
这是个非常强大的命令,通过查看进程,能看到打开了哪些文件,这是一个神器,可以以进程的维度来查看整个资源的使用情况,包括每条网络连接、每个打开的文件句柄。同时,也可以很容易的看到连接到了哪些服务器、使用了哪些资源。这个命令在资源非常多的情况下,输出稍慢,请耐心等待。
4CPU 资源
mpstat > $DUMP_DIR/mpstat.dump 2>&1
vmstat 1 3 > $DUMP_DIR/vmstat.dump 2>&1
sar -p ALL > $DUMP_DIR/sar-cpu.dump 2>&1
uptime > $DUMP_DIR/uptime.dump 2>&1
主要用于输出当前系统的 CPU 和负载,便于事后排查。这几个命令的功能,有不少重合,使用者要注意甄别。
5I/O 资源
iostat -x > $DUMP_DIR/iostat.dump 2>&1
一般以计算为主的服务节点I/O 资源会比较正常,但有时也会发生问题,比如日志输出过多,或者磁盘问题等。此命令可以输出每块磁盘的基本性能信息,用来排查 I/O 问题。在第 8 课时介绍的 GC 日志分磁盘问题,就可以使用这个命令去发现。
6内存问题
free -h > $DUMP_DIR/free.dump 2>&1
free 命令能够大体展现操作系统的内存概况,这是故障排查中一个非常重要的点,比如 SWAP 影响了 GCSLAB 区挤占了 JVM 的内存。
7其他全局
ps -ef > $DUMP_DIR/ps.dump 2>&1
dmesg > $DUMP_DIR/dmesg.dump 2>&1
sysctl -a > $DUMP_DIR/sysctl.dump 2>&1
dmesg 是许多静悄悄死掉的服务留下的最后一点线索。当然ps 作为执行频率最高的一个命令,它当时的输出信息,也必然有一些可以参考的价值。
另外,由于内核的配置参数,会对系统和 JVM 产生影响,所以我们也输出了一份。
8进程快照最后的遗言jinfo
${JDK_BIN}jinfo $PID > $DUMP_DIR/jinfo.dump 2>&1
此命令将输出 Java 的基本进程信息,包括环境变量和参数配置,可以查看是否因为一些错误的配置造成了 JVM 问题。
9dump 堆信息
${JDK_BIN}jstat -gcutil $PID > $DUMP_DIR/jstat-gcutil.dump 2>&1
${JDK_BIN}jstat -gccapacity $PID > $DUMP_DIR/jstat-gccapacity.dump 2>&1
jstat 将输出当前的 gc 信息。一般,基本能大体看出一个端倪,如果不能,可将借助 jmap 来进行分析。
10堆信息
${JDK_BIN}jmap $PID > $DUMP_DIR/jmap.dump 2>&1
${JDK_BIN}jmap -heap $PID > $DUMP_DIR/jmap-heap.dump 2>&1
${JDK_BIN}jmap -histo $PID > $DUMP_DIR/jmap-histo.dump 2>&1
${JDK_BIN}jmap -dump:format=b,file=$DUMP_DIR/heap.bin $PID > /dev/null 2>&1
jmap 将会得到当前 Java 进程的 dump 信息。如上所示,其实最有用的就是第 4 个命令,但是前面三个能够让你初步对系统概况进行大体判断。
因为,第 4 个命令产生的文件,一般都非常的大。而且,需要下载下来,导入 MAT 这样的工具进行深入分析,才能获取结果。这是分析内存泄漏一个必经的过程。
11JVM 执行栈
${JDK_BIN}jstack $PID > $DUMP_DIR/jstack.dump 2>&1
jstack 将会获取当时的执行栈。一般会多次取值,我们这里取一次即可。这些信息非常有用,能够还原 Java 进程中的线程情况。
top -Hp $PID -b -n 1 -c > $DUMP_DIR/top-$PID.dump 2>&1
为了能够得到更加精细的信息,我们使用 top 命令,来获取进程中所有线程的 CPU 信息,这样,就可以看到资源到底耗费在什么地方了。
12高级替补
kill -3 $PID
有时候jstack 并不能够运行,有很多原因,比如 Java 进程几乎不响应了等之类的情况。我们会尝试向进程发送 kill -3 信号,这个信号将会打印 jstack 的 trace 信息到日志文件中,是 jstack 的一个替补方案。
gcore -o $DUMP_DIR/core $PID
对于 jmap 无法执行的问题,也有替补,那就是 GDB 组件中的 gcore将会生成一个 core 文件。我们可以使用如下的命令去生成 dump
${JDK_BIN}jhsdb jmap --exe ${JDK}java --core $DUMP_DIR/core --binaryheap
3. 内存泄漏的现象
稍微提一下 jmap 命令,它在 9 版本里被干掉了,取而代之的是 jhsdb你可以像下面的命令一样使用。
jhsdb jmap --heap --pid 37340
jhsdb jmap --pid 37288
jhsdb jmap --histo --pid 37340
jhsdb jmap --binaryheap --pid 37340
heap 参数能够帮我们看到大体的内存布局,以及每一个年代中的内存使用情况。这和我们前面介绍的内存布局,以及在 VisualVM 中看到的 没有什么不同。但由于它是命令行,所以使用更加广泛。
histo 能够大概的看到系统中每一种类型占用的空间大小,用于初步判断问题。比如某个对象 instances 数量很小,但占用的空间很大,这就说明存在大对象。但它也只能看大概的问题,要找到具体原因,还是要 dump 出当前 live 的对象。
一般内存溢出,表现形式就是 Old 区的占用持续上升,即使经过了多轮 GC 也没有明显改善。我们在前面提到了 GC Roots内存泄漏的根本就是有些对象并没有切断和 GC Roots 的关系,可通过一些工具,能够看到它们的联系。
4. 一个卡顿实例
有一个关于服务的某个实例,经常发生服务卡顿。由于服务的并发量是比较高的,所以表现也非常明显。这个服务和我们第 8 课时的高并发服务类似,每多停顿 1 秒钟,几万用户的请求就会感到延迟。
我们统计、类比了此服务其他实例的 CPU、内存、网络、I/O 资源,区别并不是很大,所以一度怀疑是机器硬件的问题。
接下来我们对比了节点的 GC 日志,发现无论是 Minor GC还是 Major GC这个节点所花费的时间都比其他实例长得多。
通过仔细观察,我们发现在 GC 发生的时候vmstat 的 si、so 飙升的非常严重,这和其他实例有着明显的不同。
使用 free 命令再次确认,发现 SWAP 分区,使用的比例非常高,引起的具体原因是什么呢?
更详细的操作系统内存分布,从 /proc/meminfo 文件中可以看到具体的逻辑内存块大小,有多达 40 项的内存信息,这些信息都可以通过遍历 /proc 目录的一些文件获取。我们注意到 slabtop 命令显示的有一些异常dentry目录高速缓冲占用非常高。
问题最终定位到是由于某个运维工程师执行了一句命令:
find / | grep "x"
他是想找一个叫做 x 的文件,看看在哪台服务器上,结果,这些老服务器由于文件太多,扫描后这些文件信息都缓存到了 slab 区上。而服务器开了 swap操作系统发现物理内存占满后并没有立即释放 cache导致每次 GC 都要和硬盘打一次交道。
解决方式就是关闭 SWAP 分区。
swap 是很多性能场景的万恶之源建议禁用。当你的应用真正高并发了SWAP 绝对能让你体验到它魔鬼性的一面:进程倒是死不了了,但 GC 时间长的却让人无法忍受。
5. 内存泄漏
我们再来聊一下内存溢出和内存泄漏的区别。
内存溢出是一个结果,而内存泄漏是一个原因。内存溢出的原因有内存空间不足、配置错误等因素。
不再被使用的对象、没有被回收、没有及时切断与 GC Roots 的联系,这就是内存泄漏。内存泄漏是一些错误的编程方式,或者过多的无用对象创建引起的。
举个例子,有团队使用了 HashMap 做缓存,但是并没有设置超时时间或者 LRU 策略,造成了放入 Map 对象的数据越来越多,而产生了内存泄漏。
再来看一个经常发生的内存泄漏的例子,也是由于 HashMap 产生的。代码如下,由于没有重写 Key 类的 hashCode 和 equals 方法,造成了放入 HashMap 的所有对象都无法被取出来,它们和外界失联了。所以下面的代码结果是 null。
//leak example
import java.util.HashMap;
import java.util.Map;
public class HashMapLeakDemo {
public static class Key {
String title;
public Key(String title) {
this.title = title;
}
}
public static void main(String[] args) {
Map<Key, Integer> map = new HashMap<>();
map.put(new Key("1"), 1);
map.put(new Key("2"), 2);
map.put(new Key("3"), 2);
Integer integer = map.get(new Key("2"));
System.out.println(integer);
}
}
即使提供了 equals 方法和 hashCode 方法,也要非常小心,尽量避免使用自定义的对象作为 Key。仓库中 dog 目录有一个实际的、有问题的例子,你可以尝试排查一下。
再看一个例子关于文件处理器的应用在读取或者写入一些文件之后由于发生了一些异常close 方法又没有放在 finally 块里面,造成了文件句柄的泄漏。由于文件处理十分频繁,产生了严重的内存泄漏问题。
另外,对 Java API 的一些不当使用,也会造成内存泄漏。很多同学喜欢使用 String 的 intern 方法,但如果字符串本身是一个非常长的字符串,而且创建之后不再被使用,则会造成内存泄漏。
import java.util.UUID;
public class InternDemo {
static String getLongStr() {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 100000; i++) {
sb.append(UUID.randomUUID().toString());
}
return sb.toString();
}
public static void main(String[] args) {
while (true) {
getLongStr().intern();
}
}
}
6. 小结
本课时介绍了很多 Linux 命令,用于定位分析问题,所有的命令都是可以实际操作的,能够让你详细地把握整个 JVM 乃至操作系统的运行状况。其中jinfo、jstat、jstack、jhsdbjmap等是经常被使用的一些工具尤其是 jmap在分析处理内存泄漏问题的时候是必须的。
同时还介绍了保留现场的工具和辅助分析的方法论,遇到问题不要慌,记得隔离保存现场。
接下来我们看了一个实际的例子,由于 SWAP 的启用造成的服务卡顿。SWAP 会引起很多问题,在高并发服务中一般是关掉它。从这个例子中也可以看到,影响 GC甚至是整个 JVM 行为的因素,可能不仅限于 JVM 内部,故障排查也是一个综合性的技能。
最后,我们详细看了下内存泄漏的概念和几个实际的例子,从例子中能明显的看到内存泄漏的结果,但是反向去找这些问题代码就不是那么容易了。在后面的课时内容中,我们将使用 MAT 工具具体分析这个捉虫的过程。

View File

@ -0,0 +1,400 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
13 工具进阶:如何利用 MAT 找到问题发生的根本原因
我们知道,在存储用户输入的密码时,会使用一些 hash 算法对密码进行加工,比如 SHA-1。这些信息同样不允许在日志输出里出现必须做脱敏处理但是对于一个拥有系统权限的攻击者来说这些防护依然是不够的。攻击者可能会直接从内存中获取明文数据尤其是对于 Java 来说,由于提供了 jmap 这一类非常方便的工具,可以把整个堆内存的数据 dump 下来。
比如,“我的世界”这一类使用 Java 开发的游戏,会比其他语言的游戏更加容易破解一些,所以我们在 JVM 中,如果把密码存储为 char 数组,其安全性会稍微高一些。
这是一把双刃剑,在保证安全的前提下,我们也可以借助一些外部的分析工具,帮助我们方便的找到问题根本。
有两种方式来获取内存的快照。我们前面提到过,通过配置一些参数,可以在发生 OOM 的时候,被动 dump 一份堆栈信息,这是一种;另一种,就是通过 jmap 主动去获取内存的快照。
jmap 命令在 Java 9 之后,使用 jhsdb 命令替代,它们在用法上,区别不大。注意,这些命令本身会占用操作系统的资源,在某些情况下会造成服务响应缓慢,所以不要频繁执行。
jmap -dump:format=b,file=heap.bin 37340
jhsdb jmap --binaryheap --pid 37340
1. 工具介绍
有很多工具能够帮助我们来分析这份内存快照。在前面已多次提到 VisualVm 这个工具,它同样可以加载和分析这份 dump 数据,虽然比较“寒碜”。
专业的事情要有专业的工具来做,今天要介绍的是一款专业的开源分析工具,即 MAT。
MAT 工具是基于 Eclipse 平台开发的,本身是一个 Java 程序,所以如果你的堆快照比较大的话,则需要一台内存比较大的分析机器,并给 MAT 本身加大初始内存,这个可以修改安装目录中的 MemoryAnalyzer.ini 文件。
来看一下 MAT 工具的截图,主要的功能都体现在工具栏上了。其中,默认的启动界面,展示了占用内存最高的一些对象,并有一些常用的快捷方式。通常,发生内存泄漏的对象,会在快照中占用比较大的比重,分析这些比较大的对象,是我们切入问题的第一步。
点击对象,可以浏览对象的引用关系,这是一个非常有用的功能:
outgoing references 对象的引出
incoming references 对象的引入
path to GC Roots 这是快速分析的一个常用功能,显示和 GC Roots 之间的路径。
另外一个比较重要的概念就是浅堆Shallow Heap和深堆Retained Heap在 MAT 上经常看到这两个数值。
浅堆代表了对象本身的内存占用,包括对象自身的内存占用,以及“为了引用”其他对象所占用的内存。
深堆是一个统计结果会循环计算引用的具体对象所占用的内存。但是深堆和“对象大小”有一点不同深堆指的是一个对象被垃圾回收后能够释放的内存大小这些被释放的对象集合叫做保留集Retained Set
如上图所示A 对象浅堆大小 1 KBB 对象 2 KBC 对象 100 KB。A 对象同时引用了 B 对象和 C 对象,但由于 C 对象也被 D 引用,所以 A 对象的深堆大小为 3 KB1 KB + 2 KB
A 对象大小1 KB + 2 KB + 100 KB> A 对象深堆 > A 对象浅堆。
2. 代码示例
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.IntStream;
public class Objects4MAT {
static class A4MAT {
B4MAT b4MAT = new B4MAT();
}
static class B4MAT {
C4MAT c4MAT = new C4MAT();
}
static class C4MAT {
List<String> list = new ArrayList<>();
}
static class DominatorTreeDemo1 {
DominatorTreeDemo2 dominatorTreeDemo2;
public void setValue(DominatorTreeDemo2 value) {
this.dominatorTreeDemo2 = value;
}
}
static class DominatorTreeDemo2 {
DominatorTreeDemo1 dominatorTreeDemo1;
public void setValue(DominatorTreeDemo1 value) {
this.dominatorTreeDemo1 = value;
}
}
static class Holder {
DominatorTreeDemo1 demo1 = new DominatorTreeDemo1();
DominatorTreeDemo2 demo2 = new DominatorTreeDemo2();
Holder() {
demo1.setValue(demo2);
demo2.setValue(demo1);
}
private boolean aBoolean = false;
private char aChar = '\0';
private short aShort = 1;
private int anInt = 1;
private long aLong = 1L;
private float aFloat = 1.0F;
private double aDouble = 1.0D;
private Double aDouble_2 = 1.0D;
private int[] ints = new int[2];
private String string = "1234";
}
Runnable runnable = () -> {
Map<String, A4MAT> map = new HashMap<>();
IntStream.range(0, 100).forEach(i -> {
byte[] bytes = new byte[1024 * 1024];
String str = new String(bytes).replace('\0', (char) i);
A4MAT a4MAT = new A4MAT();
a4MAT.b4MAT.c4MAT.list.add(str);
map.put(i + "", a4MAT);
});
Holder holder = new Holder();
try {
//sleep forever , retain the memory
Thread.sleep(Integer.MAX_VALUE);
} catch (InterruptedException e) {
e.printStackTrace();
}
};
void startHugeThread() throws Exception {
new Thread(runnable, "huge-thread").start();
}
public static void main(String[] args) throws Exception {
Objects4MAT objects4MAT = new Objects4MAT();
objects4MAT.startHugeThread();
}
}
2.1. 代码介绍
我们以一段代码示例 Objects4MAT来具体看一下 MAT 工具的使用。代码创建了一个新的线程 “huge-thread”并建立了一个引用的层级关系总的内存大约占用 100 MB。同时demo1 和 demo2 展示了一个循环引用的关系。最后,使用 sleep 函数,让线程永久阻塞住,此时整个堆处于一个相对“静止”的状态。
如果你是在本地启动的示例代码,则可以使用 Accquire 的方式来获取堆快照。
2.2. 内存泄漏检测
如果问题特别突出,则可以通过 Find Leaks 菜单快速找出问题。
如下图所示,展示了名称叫做 huge-thread 的线程,持有了超过 96% 的对象,数据被一个 HashMap 所持有。
对于特别明显的内存泄漏,在这里能够帮助我们迅速定位,但通常内存泄漏问题会比较隐蔽,我们需要更加复杂的分析。
2.3. 支配树视图
支配树视图对数据进行了归类,体现了对象之间的依赖关系。如图,我们通常会根据“深堆”进行倒序排序,可以很容易的看到占用内存比较高的几个对象,点击前面的箭头,即可一层层展开支配关系。
图中显示的是其中的 1 MB 数据,从左侧的 inspector 视图,可以看到这 1 MB 的 byte 数组具体内容。
从支配树视图同样能够找到我们创建的两个循环依赖,但它们并没有显示这个过程。
支配树视图的概念有一点点复杂,我们只需要了解这个概念即可。
如上图,左边是引用关系,右边是支配树视图。可以看到 A、B、C 被当作是“虚拟”的根,支配关系是可传递的,因为 C 支配 EE 支配 G所以 C 也支配 G。
另外,到对象 C 的路径中,可以经过 A也可以经过 B因此对象 C 的直接支配者也是根对象。同理,对象 E 是 H 的支配者。
我们再来看看比较特殊的 D 和 F。对象 F 与对象 D 相互引用,因为到对象 F 的所有路径必然经过对象 D因此对象 D 是对象 F 的直接支配者。
可以看到支配树视图并不一定总是能看到对象的真实应用关系,但对我们分析问题的影响并不是很大。
这个视图是非常好用的,甚至可以根据 package 进行归类,对目标类的查找也是非常快捷的。
编译下面这段代码,可以展开视图,实际观测一下支配树,这和我们上面介绍的是一致的。
public class DorminatorTreeDemo {
static class A {
C c;
byte[] data = new byte[1024 * 1024 * 2];
}
static class B {
C c;
byte[] data = new byte[1024 * 1024 * 3];
}
static class C {
D d;
E e;
byte[] data = new byte[1024 * 1024 * 5];
}
static class D {
F f;
byte[] data = new byte[1024 * 1024 * 7];
}
static class E {
G g;
byte[] data = new byte[1024 * 1024 * 11];
}
static class F {
D d;
H h;
byte[] data = new byte[1024 * 1024 * 13];
}
static class G {
H h;
byte[] data = new byte[1024 * 1024 * 17];
}
static class H {
byte[] data = new byte[1024 * 1024 * 19];
}
A makeRef(A a, B b) {
C c = new C();
D d = new D();
E e = new E();
F f = new F();
G g = new G();
H h = new H();
a.c = c;
b.c = c;
c.e = e;
c.d = d;
d.f = f;
e.g = g;
f.d = d;
f.h = h;
g.h = h;
return a;
}
static A a = new A();
static B b = new B();
public static void main(String[] args) throws Exception {
new DorminatorTreeDemo().makeRef(a, b);
Thread.sleep(Integer.MAX_VALUE);
}
}
2.4. 线程视图
想要看具体的引用关系,可以通过线程视图。我们在第 5 讲,就已经了解了线程其实是可以作为 GC Roots 的。如图展示了线程内对象的引用关系,以及方法调用关系,相对比 jstack 获取的栈 dump我们能够更加清晰地看到内存中具体的数据。
如下图,我们找到了 huge-thread依次展开找到 holder 对象,可以看到循环依赖已经陷入了无限循环的状态。这在查看一些 Java 对象的时候,经常发生,不要感到奇怪。
2.5. 柱状图视图
我们返回头来再看一下柱状图视图,可以看到除了对象的大小,还有类的实例个数。结合 MAT 提供的不同显示方式,往往能够直接定位问题。也可以通过正则过滤一些信息,我们在这里输入 MAT过滤猜测的、可能出现问题的类可以看到创建的这些自定义对象不多不少正好一百个。
右键点击类,然后选择 incoming这会列出所有的引用关系。
再次选择某个引用关系然后选择菜单“Path To GC Roots”即可显示到 GC Roots 的全路径。通常在排查内存泄漏的时候,会选择排除虚弱软等引用。
使用这种方式,即可在引用之间进行跳转,方便的找到所需要的信息。
再介绍一个比较高级的功能。
我们对于堆的快照,其实是一个“瞬时态”,有时候仅仅分析这个瞬时状态,并不一定能确定问题,这就需要对两个或者多个快照进行对比,来确定一个增长趋势。
可以将代码中的 100 改成 10 或其他数字,再次 dump 一份快照进行比较。如图,通过分析某类对象的增长,即可辅助问题定位。
3. 高级功能—OQL
MAT 支持一种类似于 SQL 的查询语言 OQLObject Query Language这个查询语言 VisualVM 工具也支持。
以下是几个例子,你可以实际实践一下。
查询 A4MAT 对象:
SELECT * FROM Objects4MAT$A4MAT
正则查询 MAT 结尾的对象:
SELECT * FROM ".*MAT"
查询 String 类的 char 数组:
SELECT OBJECTS s.value FROM java.lang.String s
SELECT OBJECTS mat.b4MAT FROM Objects4MAT$A4MAT mat
根据内存地址查找对象:
select * from 0x55a034c8
使用 INSTANCEOF 关键字,查找所有子类:
SELECT * FROM INSTANCEOF java.util.AbstractCollection
查询长度大于 1000 的 byte 数组:
SELECT * FROM byte[] s WHERE s.@length>1000
查询包含 java 字样的所有字符串:
SELECT * FROM java.lang.String s WHERE toString(s) LIKE ".*java.*"
查找所有深堆大小大于 1 万的对象:
SELECT * FROM INSTANCEOF java.lang.Object o WHERE o.@retainedHeapSize>10000
如果你忘记这些属性的名称的话MAT 是可以自动补全的。
OQL 有比较多的语法和用法,若想深入了解,可参考这里。
一般,我们使用上面这些简单的查询语句就够用了。
OQL 还有一个好处,就是可以分享。如果你和同事同时在分析一个大堆,不用告诉他先点哪一步、再点哪一步,共享给他一个 OQL 语句就可以了。
如下图MAT 贴心的提供了复制 OQL 的功能,但是用在其他快照上,不会起作用,因为它复制的是如下的内容。
4. 小结
这一讲我们介绍了 MAT 工具的使用,其是用来分析内存快照的;在最后,简要介绍了 OQL 查询语言。
在 Java 9 以前的版本中,有一个工具 jhat可以以 html 的方式显示堆栈信息,但和 VisualVm 一样,都太过于简陋,推荐使用 MAT 工具。
我们把问题设定为内存泄漏,但其实 OOM 或者频繁 GC 不一定就是内存泄漏,它也可能是由于某次或者某批请求频繁而创建了大量对象,所以一些严重的、频繁的 GC 问题也能在这里找到原因。有些情况下,占用内存最多的对象,并不一定是引起内存泄漏问题的元凶,但我们也有一个比较通用的分析过程。
并不是所有的堆都值得分析的我们在做这个耗时的分析之前需要有个依据。比如经过初步调优之后GC 的停顿时间还是较长,则需要找到频繁 GC 的原因;再比如,我们发现了内存泄漏,需要找到是谁在搞鬼。
首先,我们高度关注快照载入后的初始分析,占用内存高的 topN 对象,大概率是问题产生者。
对照自己的代码,首先要分析的,就是产生这些大对象的逻辑。举几个实际发生的例子。有一个 Spring Boot 应用,由于启用了 Swagger 文档生成器,但是由于它的 API 关系非常复杂,嵌套层次又非常深(每次要产生几百 M 的文档!),结果请求几次之后产生了内存溢出,这在 MAT 上就能够一眼定位到问题;而另外一个应用,在读取数据库的时候使用了分页,但是 pageSize 并没有做一些范围检查,结果在请求一个较大分页的时候,使用 fastjson 对获取的数据进行加工,直接 OOM。
如果不能通过大对象发现问题,则需要对快照进行深入分析。使用柱状图和支配树视图,配合引入引出和各种排序,能够对内存的使用进行整体的摸底。由于我们能够看到内存中的具体数据,排查一些异常数据就容易得多。
可以在程序运行的不同时间点,获取多份内存快照,对比之后问题会更加容易发现。我们还是用一个例子来看。有一个应用,使用了 Kafka 消息队列开了一般大小的消费缓冲区Kafka 会复用这个缓冲区,按理说不应该有内存问题,但是应用却频繁发生 GC。通过对比请求高峰和低峰期间的内存快照我们发现有工程师把消费数据放入了另外一个 “内存队列”,写了一些画蛇添足的代码,结果在业务高峰期一股脑把数据加载到了内存中。
上面这些问题通过分析业务代码,也不难发现其关联性。问题如果非常隐蔽,则需要使用 OQL 等语言,对问题一一排查、确认。
可以看到,上手 MAT 工具是有一定门槛的,除了其操作模式,还需要对我们前面介绍的理论知识有深入的理解,比如 GC Roots、各种引用级别等。
在很多场景MAT 并不仅仅用于内存泄漏的排查。由于我们能够看到内存上的具体数据,在排查一些难度非常高的 bug 时MAT 也有用武之地。比如,因为某些脏数据,引起了程序的执行异常,此时,想要找到它们,不要忘了 MAT 这个老朋友。

View File

@ -0,0 +1,456 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
14 动手实践:让面试官刮目相看的堆外内存排查
本课时我们主要讲解让面试官刮目相看的堆外内存排查。
第 02 课时讲了 JVM 的内存布局,同时也在第 08 课时中看到了由于 Metaspace 设置过小而引起的问题,接着,第 10 课时讲了一下元空间和直接内存引起的内存溢出实例。
Metaspace 属于堆外内存,但由于它是单独管理的,所以排查起来没什么难度。你平常可能见到的使用堆外内存的场景还有下面这些:
JNI 或者 JNA 程序,直接操纵了本地内存,比如一些加密库;
使用了Java 的 Unsafe 类,做了一些本地内存的操作;
Netty 的直接内存Direct Memory底层会调用操作系统的 malloc 函数。
使用堆外内存可以调用一些功能完备的库函数,而且减轻了 GC 的压力。这些代码,有可能是你了解的人写的,也有可能隐藏在第三方的 jar 包里。虽然有一些好处,但是问题排查起来通常会比较的困难。
在第 10 课时,介绍了 MaxDirectMemorySize 可以控制直接内存的申请。其实,通过这个参数,仍然限制不住所有堆外内存的使用,它只是限制了使用 DirectByteBuffer 的内存申请。很多时候(比如直接使用了 sun.misc.Unsafe 类),堆外内存会一直增长,直到机器物理内存爆满,被 oom killer。
import sun.misc.Unsafe;
import java.lang.reflect.Field;
public class UnsafeDemo {
public static final int _1MB = 1024 * 1024;
public static void main(String[] args) throws Exception {
Field field = Unsafe.class.getDeclaredField("theUnsafe");
field.setAccessible(true);
Unsafe unsafe = (Unsafe) field.get(null);
for (; ; ) {
unsafe.allocateMemory(_1MB);
}
}
上面这段代码,就会持续申请堆外内存,但它返回的是 long 类型的地址句柄,所以堆内内存的使用会很少。
我们使用下面的命令去限制堆内和直接内存的使用,结果发现程序占用的操作系统内存在一直上升,这两个参数在这种场景下没有任何效果。这段程序搞死了我的机器很多次,运行的时候要小心。
java -XX:MaxDirectMemorySize=10M -Xmx10M UnsafeDemo
相信这种情况也困扰了你,因为使用一些 JDK 提供的工具,根本无法发现这部门内存的使用。我们需要一些更加底层的工具来发现这些游离的内存分配。其实,很多内存和性能问题,都逃不过下面要介绍的这些工具的联合分析。本课时将会结合一个实际的例子,来看一下一个堆外内存的溢出情况,了解常见的套路。
1. 现象
我们有一个服务,非常的奇怪,在某个版本之后,占用的内存开始增长,直到虚拟机分配的内存上限,但是并不会 OOM。如果你开启了 SWAP会发现这个应用也会毫不犹豫的将它吞掉有多少吞多少。
说它的内存增长,是通过 top 命令去观察的,看它的 RES 列的数值;反之,如果使用 jmap 命令去看内存占用,得到的只是堆的大小,只能看到一小块可怜的空间。
使用 ps 也能看到相同的效果。我们观测到,除了虚拟内存比较高,达到了 17GB 以外,实际使用的内存 RSS 也夸张的达到了 7 GB远远超过了 -Xmx 的设定。
[root]$ ps -p 75 -o rss,vsz
RSS VSZ 7152568 17485844
使用 jps 查看启动参数,发现分配了大约 3GB 的堆内存。实际内存使用超出了最大内存设定的一倍还多,这明显是不正常的,肯定是使用了堆外内存。
2. 模拟程序
为了能够使用这些工具实际观测这个内存泄漏的过程,我这里准备了一份小程序。程序将会持续的使用 Java 的 Zip 函数进行压缩和解压,这种操作在一些对传输性能较高的的场景经常会用到。
程序将会申请 1kb 的随机字符串,然后持续解压。为了避免让操作系统陷入假死状态,我们每次都会判断操作系统内存使用率,在达到 60% 的时候,我们将挂起程序;通过访问 8888 端口,将会把内存阈值提高到 85%。我们将分析这两个处于相对静态的虚拟快照。
import com.sun.management.OperatingSystemMXBean;
import com.sun.net.httpserver.HttpContext;
import com.sun.net.httpserver.HttpServer;
import java.io.*;
import java.lang.management.ManagementFactory;
import java.net.InetSocketAddress;
import java.util.Random;
import java.util.concurrent.ThreadLocalRandom;
import java.util.zip.GZIPInputStream;
import java.util.zip.GZIPOutputStream;
/**
* @author xjjdog
*/
public class LeakExample {
/**
* 构造随机的字符串
*/
public static String randomString(int strLength) {
Random rnd = ThreadLocalRandom.current();
StringBuilder ret = new StringBuilder();
for (int i = 0; i < strLength; i++) {
boolean isChar = (rnd.nextInt(2) % 2 == 0);
if (isChar) {
int choice = rnd.nextInt(2) % 2 == 0 ? 65 : 97;
ret.append((char) (choice + rnd.nextInt(26)));
} else {
ret.append(rnd.nextInt(10));
}
}
return ret.toString();
}
public static int copy(InputStream input, OutputStream output) throws IOException {
long count = copyLarge(input, output);
return count > 2147483647L ? -1 : (int) count;
}
public static long copyLarge(InputStream input, OutputStream output) throws IOException {
byte[] buffer = new byte[4096];
long count = 0L;
int n;
for (; -1 != (n = input.read(buffer)); count += (long) n) {
output.write(buffer, 0, n);
}
return count;
}
public static String decompress(byte[] input) throws Exception {
ByteArrayOutputStream out = new ByteArrayOutputStream();
copy(new GZIPInputStream(new ByteArrayInputStream(input)), out);
return new String(out.toByteArray());
}
public static byte[] compress(String str) throws Exception {
ByteArrayOutputStream bos = new ByteArrayOutputStream();
GZIPOutputStream gzip = new GZIPOutputStream(bos);
try {
gzip.write(str.getBytes());
gzip.finish();
byte[] b = bos.toByteArray();
return b;
}finally {
try { gzip.close(); }catch (Exception ex ){}
try { bos.close(); }catch (Exception ex ){}
}
}
private static OperatingSystemMXBean osmxb = (OperatingSystemMXBean) ManagementFactory.getOperatingSystemMXBean();
public static int memoryLoad() {
double totalvirtualMemory = osmxb.getTotalPhysicalMemorySize();
double freePhysicalMemorySize = osmxb.getFreePhysicalMemorySize();
double value = freePhysicalMemorySize / totalvirtualMemory;
int percentMemoryLoad = (int) ((1 - value) * 100);
return percentMemoryLoad;
}
private static volatile int RADIO = 60;
public static void main(String[] args) throws Exception {
HttpServer server = HttpServer.create(new InetSocketAddress(8888), 0);
HttpContext context = server.createContext("/");
context.setHandler(exchange -> {
try {
RADIO = 85;
String response = "OK!";
exchange.sendResponseHeaders(200, response.getBytes().length);
OutputStream os = exchange.getResponseBody();
os.write(response.getBytes());
os.close();
} catch (Exception ex) {
}
});
server.start();
//1kb
int BLOCK_SIZE = 1024;
String str = randomString(BLOCK_SIZE / Byte.SIZE);
byte[] bytes = compress(str);
for (; ; ) {
int percent = memoryLoad();
if (percent > RADIO) {
Thread.sleep(1000);
} else {
decompress(bytes);
Thread.sleep(1);
}
程序将使用下面的命令行进行启动。为了简化问题,这里省略了一些无关的配置。
java -Xmx1G -Xmn1G -XX:+AlwaysPreTouch -XX:MaxMetaspaceSize=10M -XX:MaxDirectMemorySize=10M -XX:NativeMemoryTracking=detail LeakExample
3. NMT
首先介绍一下上面的几个 JVM 参数,分别使用 Xmx、MaxMetaspaceSize、MaxDirectMemorySize 这三个参数限制了堆、元空间、直接内存的大小。
然后,使用 AlwaysPreTouch 参数。其实,通过参数指定了 JVM 大小,只有在 JVM 真正使用的时候,才会分配给它。这个参数,在 JVM 启动的时候,就把它所有的内存在操作系统分配了。在堆比较大的时候,会加大启动时间,但在这个场景中,我们为了减少内存动态分配的影响,把这个值设置为 True。
接下来的 NativeMemoryTracking是用来追踪 Native 内存的使用情况。通过在启动参数上加入 -XX:NativeMemoryTracking=detail 就可以启用。使用 jcmd 命令,就可查看内存分配。
jcmd $pid VM.native_memory summary
我们在一台 4GB 的虚拟机上使用上面的命令。启动程序之后,发现进程使用的内存迅速升到 2.4GB。
# jcmd 2154 VM.native_memory summary
2154:
Native Memory Tracking:
Total: reserved=2370381KB, committed=1071413KB
- Java Heap (reserved=1048576KB, committed=1048576KB)
(mmap: reserved=1048576KB, committed=1048576KB)
- Class (reserved=1056899KB, committed=4995KB)
(classes #432)
(malloc=131KB #328)
(mmap: reserved=1056768KB, committed=4864KB)
- Thread (reserved=10305KB, committed=10305KB)
(thread #11)
(stack: reserved=10260KB, committed=10260KB)
(malloc=34KB #52)
(arena=12KB #18)
- Code (reserved=249744KB, committed=2680KB)
(malloc=144KB #502)
(mmap: reserved=249600KB, committed=2536KB)
- GC (reserved=2063KB, committed=2063KB)
(malloc=7KB #80)
(mmap: reserved=2056KB, committed=2056KB)
- Compiler (reserved=138KB, committed=138KB)
(malloc=8KB #38)
(arena=131KB #5)
- Internal (reserved=789KB, committed=789KB)
(malloc=757KB #1272)
(mmap: reserved=32KB, committed=32KB)
- Symbol (reserved=1535KB, committed=1535KB)
(malloc=983KB #114)
(arena=552KB #1)
- Native Memory Tracking (reserved=159KB, committed=159KB)
(malloc=99KB #1399)
(tracking overhead=60KB)
- Arena Chunk (reserved=174KB, committed=174KB)
(mall
可惜的是,这个名字让人振奋的工具并不能如它描述的一样,看到我们这种泄漏的场景。下图这点小小的空间,是不能和 2GB 的内存占用相比的。
NMT 能看到堆内内存、Code 区域或者使用 unsafe.allocateMemory 和 DirectByteBuffer 申请的堆外内存,虽然是个好工具但问题并不能解决。
使用 jmap 工具dump 一份堆快照,然后使用 MAT 分析,依然不能找到这部分内存。
4. pmap
像是 EhCache 这种缓存框架,提供了多种策略,可以设定将数据存储在非堆上,我们就是要排查这些影响因素。如果能够在代码里看到这种可能性最大的代码块,是最好的。
为了进一步分析问题,我们使用 pmap 命令查看进程的内存分配,通过 RSS 升序序排列。结果发现除了地址 00000000c0000000 上分配的 1GB 堆以外(也就是我们的堆内存),还有数量非常多的 64M 一块的内存段,还有巨量小的物理内存块映射到不同的虚拟内存段上。但到现在为止,我们不知道里面的内容是什么,是通过什么产生的。
# pmap -x 2154 | sort -n -k3
Address Kbytes RSS Dirty Mode Mapping
---------------- ------- ------- -------
0000000100080000 1048064 0 0 ----- [ anon ]
00007f2d4fff1000 60 0 0 ----- [ anon ]
00007f2d537fb000 8212 0 0 ----- [ anon ]
00007f2d57ff1000 60 0 0 ----- [ anon ]
.....省略N行
00007f2e3c000000 65524 22064 22064 rw--- [ anon ]
00007f2e00000000 65476 22068 22068 rw--- [ anon ]
00007f2e18000000 65476 22072 22072 rw--- [ anon ]
00007f2e30000000 65476 22076 22076 rw--- [ anon ]
00007f2dc0000000 65520 22080 22080 rw--- [ anon ]
00007f2dd8000000 65520 22080 22080 rw--- [ anon ]
00007f2da8000000 65524 22088 22088 rw--- [ anon ]
00007f2e8c000000 65528 22088 22088 rw--- [ anon ]
00007f2e64000000 65520 22092 22092 rw--- [ anon ]
00007f2e4c000000 65520 22096 22096 rw--- [ anon ]
00007f2e7c000000 65520 22096 22096 rw--- [ anon ]
00007f2ecc000000 65520 22980 22980 rw--- [ anon ]
00007f2d84000000 65476 23368 23368 rw--- [ anon ]
00007f2d9c000000 131060 43932 43932 rw--- [ anon ]
00007f2d50000000 57324 56000 56000 rw--- [ anon ]
00007f2d4c000000 65476 64160 64160 rw--- [ anon ]
00007f2d5c000000 65476 64164 64164 rw--- [ anon ]
00007f2d64000000 65476 64164 64164 rw--- [ anon ]
00007f2d54000000 65476 64168 64168 rw--- [ anon ]
00007f2d7c000000 65476 64168 64168 rw--- [ anon ]
00007f2d60000000 65520 64172 64172 rw--- [ anon ]
00007f2d6c000000 65476 64172 64172 rw--- [ anon ]
00007f2d74000000 65476 64172 64172 rw--- [ anon ]
00007f2d78000000 65520 64176 64176 rw--- [ anon ]
00007f2d68000000 65520 64180 64180 rw--- [ anon ]
00007f2d80000000 65520 64184 64184 rw--- [ anon ]
00007f2d58000000 65520 64188 64188 rw--- [ anon ]
00007f2d70000000 65520 64192 64192 rw--- [ anon ]
00000000c0000000 1049088 1049088 1049088 rw--- [ anon ]
total kB 8492740 3511008 3498584
通过 Google找到以下资料 Linux glibc >= 2.10 (RHEL 6) malloc may show excessive virtual memory usage) 。
文章指出造成应用程序大量申请 64M 大内存块的原因是由 Glibc 的一个版本升级引起的,通过 export MALLOC_ARENA_MAX=4 可以解决 VSZ 占用过高的问题。虽然这也是一个问题,但却不是我们想要的,因为我们增长的是物理内存,而不是虚拟内存,程序在这一方面表现是正常的。
5. gdb
非常好奇 64M 或者其他小内存块中是什么内容,接下来可以通过 gdb 工具将其 dump 出来。
读取 /proc 目录下的 maps 文件,能精准地知晓目前进程的内存分布。以下脚本通过传入进程 id能够将所关联的内存全部 dump 到文件中。注意,这个命令会影响服务,要慎用。
pid=$1;grep rw-p /proc/$pid/maps | sed -n 's/^\([0-9a-f]*\)-\([0-9a-f]*\) .*$/\1 \2/p' | while read start stop; do gdb --batch --pid $pid -ex "dump memory $1-$start-$stop.dump 0x$start 0x$stop"; done
这个命令十分霸道,甚至把加载到内存中的 class 文件、堆文件一块给 dump 下来。这是机器的原始内存,大多数文件我们打不开。
更多时候,只需要 dump 一部分内存就可以。再次提醒操作会影响服务,注意 dump 的内存块大小,线上一定要慎用。
我们复制 pman 的一块 64M 内存,比如 00007f2d70000000然后去掉前面的 0使用下面代码得到内存块的开始和结束地址。
cat /proc/2154/maps | grep 7f2d70000000
7f2d6fff1000-7f2d70000000 ---p 00000000 00:00 0 7f2d70000000-7f2d73ffc000 rw-p 00000000 00:00 0
接下来就 dump 这 64MB 的内存。
gdb --batch --pid 2154 -ex "dump memory a.dump 0x7f2d70000000 0x7f2d73ffc000"
使用 du 命令查看具体的内存块大小,不多不少正好 64M。
# du -h a.dump
64M a.dump
是时候查看里面的内容了,使用 strings 命令可以看到内存块里一些可以打印的内容。
# strings -10 a.dump
0R4f1Qej1ty5GT8V1R8no6T44564wz499E6Y582q2R9h8CC175GJ3yeJ1Q3P5Vt757Mcf6378kM36hxZ5U8uhg2A26T5l7f68719WQK6vZ2BOdH9lH5C7838qf1
...
等等?这些内容不应该在堆里面么?为何还会使用额外的内存进行分配?那么还有什么地方在分配堆外内存呢?
这种情况,只可能是 native 程序对堆外内存的操作。
6. perf
下面介绍一个神器 perf除了能够进行一些性能分析它还能帮助我们找到相应的 native 调用。这么突出的堆外内存使用问题,肯定能找到相应的调用函数。
使用 perf record -g -p 2154 开启监控栈函数调用,然后访问服务器的 8888 端口,这将会把内存使用的阈值增加到 85%,我们的程序会逐渐把这部分内存占满,你可以 syi。perf 运行一段时间后 Ctrl+C 结束,会生成一个文件 perf.data。
执行 perf report -i perf.data 查看报告。
如图,一般第三方 JNI 程序,或者 JDK 内的模块,都会调用相应的本地函数,在 Linux 上,这些函数库的后缀都是 so。
我们依次浏览用的可疑资源发现了“libzip.so”还发现了不少相关的调用。搜索 zip输入 / 进入搜索模式),结果如下:
查看 JDK 代码,发现 bzip 大量使用了 native 方法。也就是说,有大量内存的申请和销毁,是在堆外发生的。
进程调用了Java_java_util_zip_Inflater_inflatBytes() 申请了内存,却没有调用 Deflater 释放内存。与 pmap 内存地址相比对,确实是 zip 在搞鬼。
7. gperftools
google 还有一个类似的、非常好用的工具,叫做 gperftools我们主要用到它的 Heap Profiler功能更加强大。
它的启动方式有点特别,安装成功之后,你只需要输出两个环境变量即可。
mkdir -p /opt/test
export LD_PRELOAD=/usr/lib64/libtcmalloc.so
export HEAPPROFILE=/opt/test/heap
在同一个终端,再次启动我们的应用程序,可以看到内存申请动作都被记录到了 opt 目录下的 test 目录。
接下来,我们就可以使用 pprof 命令分析这些文件。
cd /opt/test
pprof -text *heap | head -n 200
使用这个工具能够一眼追踪到申请内存最多的函数。Java_java_util_zip_Inflater_init 这个函数立马就被发现了。
Total: 25205.3 MB
20559.2 81.6% 81.6% 20559.2 81.6% inflateBackEnd
4487.3 17.8% 99.4% 4487.3 17.8% inflateInit2_
75.7 0.3% 99.7% 75.7 0.3% os::malloc@8bbaa0
70.3 0.3% 99.9% 4557.6 18.1% Java_java_util_zip_Inflater_init
7.1 0.0% 100.0% 7.1 0.0% readCEN
3.9 0.0% 100.0% 3.9 0.0% init
1.1 0.0% 100.0% 1.1 0.0% os::malloc@8bb8d0
0.2 0.0% 100.0% 0.2 0.0% _dl_new_object
0.1 0.0% 100.0% 0.1 0.0% __GI__dl_allocate_tls
0.1 0.0% 100.0% 0.1 0.0% _nl_intern_locale_data
0.0 0.0% 100.0% 0.0 0.0% _dl_check_map_versions
0.0 0.0% 100.0% 0.0 0.0% __GI___strdup
0.0 0.0% 100.0% 0.1 0.0% _dl_map_object_deps
0.0 0.0% 100.0% 0.0 0.0% nss_parse_service_list
0.0 0.0% 100.0% 0.0 0.0% __new_exitfn
0.0 0.0% 100.0% 0.0 0.0% getpwuid
0.0 0.0% 100.0% 0.0 0.0% expand_dynamic_string_token
8. 解决
这就是我们模拟内存泄漏的整个过程,到此问题就解决了。
GZIPInputStream 使用 Inflater 申请堆外内存、Deflater 释放内存,调用 close() 方法来主动释放。如果忘记关闭Inflater 对象的生命会延续到下一次 GC有一点类似堆内的弱引用。在此过程中堆外内存会一直增长。
把 decompress 函数改成如下代码,重新编译代码后观察,问题解决。
public static String decompress(byte[] input) throws Exception {
ByteArrayOutputStream out = new ByteArrayOutputStream();
GZIPInputStream gzip = new GZIPInputStream(new ByteArrayInputStream(input));
try {
copy(gzip, out);
return new String(out.toByteArray());
}finally {
try{ gzip.close(); }catch (Exception ex){}
try{ out.close(); }catch (Exception ex){}
}
}
9. 小结
本课时使用了非常多的工具和命令来进行堆外内存的排查,可以看到,除了使用 jmap 获取堆内内存,还对堆外内存的获取也有不少办法。
现在,我们可以把堆外内存进行更加细致地划分了。
元空间属于堆外内存主要是方法区和常量池的存储之地使用数“MaxMetaspaceSize”可以限制它的大小我们也能观测到它的使用。
直接内存主要是通过 DirectByteBuffer 申请的内存可以使用参数“MaxDirectMemorySize”来限制它的大小参考第 10 课时)。
其他堆外内存,主要是指使用了 Unsafe 或者其他 JNI 手段直接直接申请的内存。这种情况,就没有任何参数能够阻挡它们,要么靠它自己去释放一些内存,要么等待操作系统对它的审判了。
还有一种情况,和内存的使用无关,但是也会造成内存不正常使用,那就是使用了 Process 接口,直接调用了外部的应用程序,这些程序对操作系统的内存使用一般是不可预知的。
本课时介绍的一些工具,很多高级研发,包括一些面试官,也是不知道的;即使了解这个过程,不实际操作一遍,也很难有深刻的印象。通过这个例子,你可以看到一个典型的堆外内存问题的排查思路。
堆外内存的泄漏是非常严重的,它的排查难度高、影响大,甚至会造成宿主机的死亡。在排查内存问题时,不要忘了这一环。

View File

@ -0,0 +1,256 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
15 预警与解决:深入浅出 GC 监控与调优
本课时我们主要讲解深入浅出 GC 监控与调优。
在前面的课时中不止一次谈到了监控,但除了 GC Log大多数都是一些“瞬时监控”工具也就是看到的问题基本是当前发生的。
你可能见过在地铁上抱着电脑处理故障的照片,由此可见,大部分程序员都是随身携带电脑的,它体现了两个问题:第一,自动化应急处理机制并不完善;第二,缺乏能够跟踪定位问题的工具,只能靠“苦力”去解决。
我们在前面第 11 课时中提到的一系列命令,就是一个被分解的典型脚本,这个脚本能够在问题发生的时候,自动触发并保存顺时态的现场。除了这些工具,我们还需要有一个与时间序列相关的监控系统。这就是监控工具的必要性。
我们来盘点一下对于问题的排查,现在都有哪些资源:
GC 日志,能够反映每次 GC 的具体状况,可根据这些信息调整一些参数及容量;
问题发生点的堆快照,能够在线下找到具体内存泄漏的原因;
问题发生点的堆栈信息,能够定位到当前正在运行的业务,以及一些死锁问题;
操作系统监控,比如 CPU 资源、内存、网络、I/O 等,能够看到问题发生前后整个操作系统的资源状况;
服务监控,比如服务的访问量、响应时间等,可以评估故障堆服务的影响面,或者找到一些突增的流量来源;
JVM 各个区的内存变化、GC 变化、耗时等监控,能够帮我们了解到 JVM 在整个故障周期的时间跨度上,到底发生了什么。
在实践课时中,我们也不止一次提到,优化和问题排查是一个综合的过程。故障相关信息越多越好,哪怕是同事不经意间透露的一次压测信息,都能够帮助你快速找到问题的根本。
本课时将以一个实际的监控解决方案,来看一下监控数据是怎么收集和分析的。使用的工具主要集中在 Telegraf、InfluxDB 和 Grafana 上,如果你在用其他的监控工具,思路也是类似的。
监控指标
在前面的一些示例代码中,会看到如下的 JMX 代码片段:
static void memPrint() {
for (MemoryPoolMXBean memoryPoolMXBean : ManagementFactory.getMemoryPoolMXBeans()) {
System.out.println(memoryPoolMXBean.getName() +
" committed:" + memoryPoolMXBean.getUsage().getCommitted() +
" used:" + memoryPoolMXBean.getUsage().getUsed());
}
}
这就是 JMX 的作用。除了使用代码,通过 jmc 工具也可以简单地看一下它们的值(前面提到的 VisualVM 通过安装插件,也可以看到这些信息)。
新版本的 JDK 不再包含 jmc 这个工具,可点击这里自行下载。
如下图所示,可以看到一个 Java 进程的资源概览包括内存、CPU、线程等。
下图是切换到 MBean 选项卡之后的截图,可以看到图中展示的 Metaspace 详细信息。
jmc 还是一个性能分析平台,可以录制、收集正在运行的 Java 程序的诊断数据和概要分析数据,感兴趣的可以自行探索。但还是那句话,线上环境可能没有条件让我们使用一些图形化分析工具,相对比 Arthas 这样的命令行工具就比较吃香。
比如,下图就是一个典型的互联网架构图,真正的服务器可能是一群 docker 实例,如果自己的机器想要访问 JVM 的宿主机器,则需要配置一些复杂的安全策略和权限开通。图像化的工具在平常的工作中不是非常有用,而且,由于性能损耗和安全性的考虑,也不会让研发主动去通过 JMX 连接这些机器。
所以面试的时候如果你一直在提一些图形化工具,面试官只能无奈的笑笑,这个话题也无法进行下去了。
在必要的情况下JMX 还可以通过加上一些参数,进行远程访问。
-Djava.rmi.server.hostname=127.0.0.1
-Dcom.sun.management.jmxremote
-Dcom.sun.management.jmxremote.port=14000
-Dcom.sun.management.jmxremote.ssl=false
-Dcom.sun.management.jmxremote.authenticate=false
无论是哪种方式我们发现每个内存区域都有四个值init、used、committed 和 max下图展示了它们之间的大小关系。
以堆内存大小来说:
-Xmx 就是 max
-Xms 就是 init
committed 指的是当前可用的内存大小,它的大小包括已经使用的内存
used 指的是实际被使用的内存大小,它的值总是小于 committed
如果在启动的时候,指定了 -Xmx = -Xms也就是初始值和最大值是一样的可以看到这四个值只有 used 是变动的。
Jolokia
单独看这些 JMX 的瞬时监控值,是没有什么用的,需要使用程序收集起来并进行分析。
但是 JMX 的客户端 API 使用起来非常的不方便Jolokia 就是一个将 JMX 转换成 HTTP 的适配器,方便了 JMX 的使用。
Jokokia 可以通过 jar 包和 agent 的方式启动,在一些框架中,比如 Spring Boot 中,很容易进行集成。
访问 http://start.spring.io生成一个普通的 Spring Boot 项目。
直接在 pom 文件里加入 jolokia 的依赖。
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>org.jolokia</groupId>
<artifactId>jolokia-core</artifactId>
</dependency>
在 application.yml 中简单地加入一点配置,就可以通过 HTTP 接口访问 JMX 的内容了。
management:
endpoints:
web:
exposure:
include: jolokia
你也可以直接下载仓库中的 monitor-demo 项目,启动后访问 8084 端口,即可获取 JMX 的 json 数据。访问链接 /demo 之后,会使用 guava 持续产生内存缓存。
接下来,我们将收集这个项目的 JMX 数据。
http://localhost:8084/actuator/jolokia/list
附上仓库地址https://gitee.com/xjjdog/jvm-lagou-res。
JVM 监控搭建
我们先简单看一下 JVM 监控的整体架构图:
JVM 的各种内存信息,会通过 JMX 接口进行暴露Jolokia 组件负责把 JMX 信息翻译成容易读取的 HTTP 请求。
telegraf 组件作为一个通用的监控 agent和 JVM 进程部署在同一台机器上,通过访问转化后的 HTTP 接口,以固定的频率拉取监控信息;然后把这些信息存放到 influxdb 时序数据库中;最后,通过高颜值的 Grafana 展示组件,设计 JVM 监控图表。
整个监控组件是可以热拔插的,并不会影响原有服务。监控部分也是可以复用的,比如 telegraf 就可以很容易的进行操作系统监控。
influxdb
influxdb 是一个性能和压缩比非常高的时序数据库,在中小型公司非常流行,点击这里可获取 influxdb。
在 CentOS 环境中,可以使用下面的命令下载。
wget -c https://dl.influxdata.com/influxdb/releases/influxdb-1.7.9_linux_amd64.tar.gz
tar xvfz influxdb-1.7.9_linux_amd64.tar.gz
解压后,然后使用 nohup 进行启动。
nohup ./influxd &
InfluxDB 将在 8086 端口进行监听。
Telegraf
Telegraf 是一个监控数据收集工具,支持非常丰富的监控类型,其中就包含内置的 Jolokia 收集器。
接下来,下载并安装 Telegraf
wget -c https://dl.influxdata.com/telegraf/releases/telegraf-1.13.1-1.x86_64.rpm
sudo yum localinstall telegraf-1.13.1-1.x86_64.rpm
Telegraf 通过 jolokia 配置收集数据相对简单,比如下面就是收集堆内存使用状况的一段配置。
[[inputs.jolokia2_agent.metric]]
name = "jvm"
field_prefix = "Memory_"
mbean = "java.lang:type=Memory"
paths = ["HeapMemoryUsage", "NonHeapMemoryUsage", "ObjectPendingFinalizationCount"]
设计这个配置文件的主要难点在于对 JVM 各个内存分区的理解。由于配置文件比较长,可以参考仓库中的 jvm.conf 和 sys.conf你可以把这两个文件复制到 /etc/telegraf/telegraf.d/ 目录下面,然后执行 systemctl restart telegraf 重启 telegraf。
grafana
grafana 是一个颜值非常高的监控展示组件,支持非常多的数据源类型,对 influxdb 的集成度也比较高可通过以下地址进行下载https://grafana.com/grafana/download
wget -c https://dl.grafana.com/oss/release/grafana-6.5.3.linux-amd64.tar.gz
tar -zxvf grafana-6.5.3.linux-amd64.tar.gz
下面是我已经做好的一张针对于 CMS 垃圾回收器的监控图,你可以导入 grafana-jvm-influxdb.json 文件进行测试。
在导入之前,还需要创建一个数据源,选择 influxdb填入 db 的地址即可。
集成
把我们的 Spring Boot 项目打包(见仓库),然后上传到服务器上去执行。
打包方式:
mvn package -Dmaven.tesk.skip=true
执行方式(自行替换日志方面配置):
mkdir /tmp/logs
nohup java -XX:+UseConcMarkSweepGC -Xmx512M -Xms512M -Djava.rmi.server.hos
tname=192.168.99.101 -Dcom.sun.management.jmxremote -Dcom.sun.management.jmx
remote.port=14000 -Dcom.sun.management.jmxremote.ssl=false -Dcom.sun.manage
ment.jmxremote.authenticate=false -verbose:gc -XX:+PrintGCDetails -XX:+PrintG
CDateStamps -XX:+PrintGCApplicationStoppedTime -XX:+PrintTenuringDistributio
n -Xloggc:/tmp/logs/gc_%p.log -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPat
h=/tmp/logs -XX:ErrorFile=/tmp/logs/hs_error_pid%p.log -XX:-OmitStackTraceInF
astThrow -jar monitor-demo-0.0.1-SNAPSHOT.jar 2>&1 &
请将 IP 地址改成自己服务器的实际 IP 地址,这样就可以使用 jmc 或者 VisualVM 等工具进行连接了。
确保 Telegraf、InfluxDB、Grafana 已经启动这样Java 进程的 JVM 相关数据,将会以 10 秒一次的频率进行收集,我们可以选择 Grafana 的时间轴,来查看实时的或者历史的监控曲线。
这类监控信息,可以保存长达 1 ~ 2 年,也就是说非常久远的问题,也依然能够被追溯到。如果你想要对 JVM 尽可能地进行调优,就要时刻关注这些监控图。
举一个例子我们发现有一个线上服务运行一段时间以后CPU 升高、程序执行变慢,登录相应的服务器进行分析,发现 C2 编译线程一直处在高耗 CPU 的情况。
但是我们无法解决这个问题,一度以为是 JVM 的 Bug。
通过分析 CPU 的监控图和 JVM 每个内存分区的曲线,发现 CodeCache 相应的曲线,在增加到 32MB 之后,就变成了一条直线,同时 CPU 的使用也开始增加。
通过检查启动参数和其他配置,最终发现一个开发环境的 JVM 参数被一位想要练手的同学给修改了,他本意是想要通过参数 “-XX:ReservedCodeCacheSize” 来限制 CodeCache 的大小,这个参数被误推送到了线上环境。
JVM 通过 JIT 编译器来增加程序的执行效率JIT 编译后的代码,都会放在 CodeCache 里。如果这个空间不足JIT 则无法继续编译编译执行会变成解释执行性能会降低一个数量级。同时JIT 编译器会一直尝试去优化代码,造成了 CPU 的占用上升。
由于我们收集了这些分区的监控信息,所以很容易就发现了问题的相关性,这些判断也会反向支持我们的分析,而不仅仅是靠猜测。
小结
本课时简要介绍了基于 JMX 的 JVM 监控,并了解了一系列观测这些数据的工具。但通常,使用 JMX 的 API 还是稍显复杂一些Jolokia 可以把这些信息转化成 HTTP 的 json 信息。
还介绍了一个可用的监控体系,来收集这些暴露的数据,这也是有点规模的公司采用的正统思路。收集的一些 GC 数据,和前面介绍的 GC 日志是有一些重合的,但我们的监控更突出的是实时性,以及追踪一些可能比较久远的问题数据。
附录:代码清单
sys.conf 操作系统监控数据收集配置文件Telegraf 使用。
jvm.conf JVM 监控配置文件Telegraf 使用。
grafana-jvm-influxdb.json JVM 监控面板Grafana 使用。
monitor-demo 被收集的 Spring Boot 项目。

View File

@ -0,0 +1,567 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
16 案例分析:一个高死亡率的报表系统的优化之路
本课时我们主要分析一个案例,那就是一个“高死亡率”报表系统的优化之路。
传统观念上的报表系统,可能访问量不是特别多,点击一个查询按钮,后台 SQL 语句的执行需要等数秒。如果使用 jstack 来查看执行线程,会发现大多数线程都阻塞在数据库的 I/O 上。
上面这种是非常传统的报表。还有一种类似于大屏监控一类的实时报表,这种报表的并发量也是比较可观的,但由于它的结果集都比较小,所以我们可以像对待一个高并发系统一样对待它,问题不是很大。
本课时要讲的,就是传统观念上的报表。除了处理时间比较长以外,报表系统每次处理的结果集,普遍都比较大,这给 JVM 造成了非常大的压力。
下面我们以一个综合性的实例,来看一下一个“病入膏肓”的报表系统的优化操作。
有一个报表系统,频繁发生内存溢出,在高峰期间使用时,还会频繁的发生拒绝服务,这是不可忍受的。
服务背景
本次要优化的服务是一个 SaaS 服务,使用 Spring Boot 编写,采用的是 CMS 垃圾回收器。如下图所示,有些接口会从 MySQL 中获取数据,有些则从 MongoDB 中获取数据,涉及的结果集合都比较大。
由于有些结果集的字段不是太全,因此需要对结果集合进行循环,可通过 HttpClient 调用其他服务的接口进行数据填充。也许你会认为某些数据可能会被复用,于是使用 Guava 做了 JVM 内缓存。
大体的服务依赖可以抽象成下面的图。
初步排查JVM 的资源太少。当接口 A 每次进行报表计算时,都要涉及几百兆的内存,而且在内存里驻留很长时间,同时有些计算非常耗 CPU特别的“吃”资源。而我们分配给 JVM 的内存只有 3 GB在多人访问这些接口的时候内存就不够用了进而发生了 OOM。在这种情况下即使连最简单的报表都不能用了。
没办法,只有升级机器。把机器配置升级到 4core8g给 JVM 分配 6GB 的内存,这样 OOM 问题就消失了。但随之而来的是频繁的 GC 问题和超长的 GC 时间,平均 GC 时间竟然有 5 秒多。
初步优化
我们前面算过6GB 大小的内存,年轻代大约是 2GB在高峰期每几秒钟则需要进行一次 MinorGC。报表系统和高并发系统不太一样它的对象存活时长大得多并不能仅仅通过增加年轻代来解决而且如果增加了年轻代那么必然减少了老年代的大小由于 CMS 的碎片和浮动垃圾问题,我们可用的空间就更少了。虽然服务能够满足目前的需求,但还有一些不太确定的风险。
第一,了解到程序中有很多缓存数据和静态统计数据,为了减少 MinorGC 的次数,通过分析 GC 日志打印的对象年龄分布,把 MaxTenuringThreshold 参数调整到了 3请根据你自己的应用情况设置。这个参数是让年轻代的这些对象赶紧回到老年代去不要老呆在年轻代里。
第二,我们的 GC 时间比较长,就一块开了参数 CMSScavengeBeforeRemark使得在 CMS remark 前,先执行一次 Minor GC 将新生代清掉。同时配合上个参数,其效果还是比较好的,一方面,对象很快晋升到了老年代,另一方面,年轻代的对象在这种情况下是有限的,在整个 MajorGC 中占的时间也有限。
第三,由于缓存的使用,有大量的弱引用,拿一次长达 10 秒的 GC 来说。我们发现在 GC 日志里,处理 weak refs 的时间较长,达到了 4.5 秒。
2020-01-28T12:13:32.876+0800: 526569.947: [weak refs processing, 4.5240649 secs]
所以加入了参数 ParallelRefProcEnabled 来并行处理 Reference以加快处理速度缩短耗时。
同时还加入了其他一些优化参数,比如通过调整触发 GC 的参数来进行优化。
-Xmx6g -Xms6g -XX:MaxTenuringThreshold=3 -XX:+AlwaysPreTouch -XX:+Par
allelRefProcEnabled -XX:+CMSScavengeBeforeRemark -XX:+UseConcMarkSwe
epGC -XX:CMSInitiatingOccupancyFraction=80 -XX:+UseCMSInitiatingOccu
pancyOnly -XX:MetaspaceSize=256M -XX:MaxMetaspaceSize=256M
优化之后,效果不错,但并不是特别明显。经过评估,针对高峰时期的情况进行调研,我们决定再次提升机器性能,改用 8core16g 的机器。但是,这会带来另外一个问题。
高性能的机器带来了非常大的服务吞吐量,通过 jstat 进行监控,能够看到年轻代的分配速率明显提高,但随之而来的 MinorGC 时长却变的不可控,有时候会超过 1 秒。累积的请求造成了更加严重的后果。
这是由于堆空间明显加大造成的回收时间加长。为了获取较小的停顿时间,我们在堆上采用了 G1 垃圾回收器,把它的目标设定在 200ms。G1 是一款非常优秀的垃圾收集器,不仅适合堆内存大的应用,同时也简化了调优的工作。通过主要的参数初始和最大堆空间、以及最大容忍的 GC 暂停目标,就能得到不错的性能。所以为了照顾大对象的生成,我们把小堆区的大小修改为 16 M。修改之后虽然 GC 更加频繁了一些,但是停顿时间都比较小,应用的运行较为平滑。
-Xmx12g -Xms12g -XX:+UseG1GC -XX:InitiatingHeapOccupancyPercent=45 -XX:MaxGCPauseMillis=200 -XX:G1HeapRegionSize=16m -XX:MetaspaceSize=256m -XX:MaxMetaspaceSize=256m
这个时候,任务来了:业务部门发力,预计客户增长量增长 10 ~ 100 倍,报表系统需要评估其可行性,以便进行资源协调。可问题是,这个“千疮百孔”的报表系统,稍微一压测,就宕机,那如何应对十倍百倍的压力呢?
使用 MAT 分析堆快照,发现很多地方可以通过代码优化,那些占用内存特别多的对象,都是我们需要优化的。
代码优化
我们使用扩容硬件的方式,暂时缓解了 JVM 的问题,但是根本问题并没有触及到。为了减少内存的占用,肯定要清理无用的信息。通过对代码的仔细分析,首先要改造的就是 SQL 查询语句。
很多接口,其实并不需要把数据库的每个字段都查询出来,当你在计算和解析的时候,它们会不知不觉地“吃掉”你的内存。所以我们只需要获取所需的数据就够了,也就是把 **select *** 这种方式修改为具体的查询字段,对于报表系统来说这种优化尤其明显。
再一个就是 Cache 问题,通过排查代码,会发现一些命中率特别低,占用内存又特别大的对象,放到了 JVM 内的 Cache 中,造成了无用的浪费。
解决方式,就是把 Guava 的 Cache 引用级别改成弱引用WeakKeys尽量去掉无用的应用缓存。对于某些使用特别频繁的小 key使用分布式的 Redis 进行改造即可。
为了找到更多影响因子大的问题,我们部署了独立的环境,然后部署了 JVM 监控。在回放某个问题请求后,观察 JVM 的响应,通过这种方式,发现了更多的优化可能。
报表系统使用了 POI 组件进行导入导出功能的开发,结果客户在没有限制的情况下上传、下载了条数非常多的文件,直接让堆内存飙升。为了解决这种情况,我们在导入功能加入了文件大小的限制,强制客户进行拆分;在下载的时候指定范围,严禁跨度非常大的请求。
在完成代码改造之后,再把机器配置降级回 4core8g依然采用 G1 垃圾回收器,再也没有发生 OOM 的问题了GC 问题也得到了明显的缓解。
拒绝服务问题
上面解决的是 JVM 的内存问题,可以看到除了优化 JVM 参数、升级机器配置以外,代码修改带来的优化效果更加明显,但这个报表服务还有一个严重的问题。
刚开始我们提到过,由于没有微服务体系,有些数据需要使用 HttpClient 来获取进行补全。提供数据的服务有的响应时间可能会很长,也有可能会造成服务整体的阻塞。
如上图所示,接口 A 通过 HttpClient 访问服务 2响应 100ms 后返回;接口 B 访问服务 3耗时 2 秒。HttpClient 本身是有一个最大连接数限制的,如果服务 3 迟迟不返回,就会造成 HttpClient 的连接数达到上限,最上层的 Tomcat 线程也会一直阻塞在这里,进而连响应速度比较快的接口 A 也无法正常提供服务。
这是出现频率非常高的的一类故障,在工作中你会大概率遇见。概括来讲,就是同一服务,由于一个耗时非常长的接口,进而引起了整体的服务不可用。
这个时候,通过 jstack 打印栈信息,会发现大多数竟然阻塞在了接口 A 上,而不是耗时更长的接口 B。这是一种错觉其实是因为接口 A 的速度比较快,在问题发生点进入了更多的请求,它们全部都阻塞住了。
证据本身具有非常强的迷惑性。由于这种问题发生的频率很高,排查起来又比较困难,我这里专门做了一个小工程,用于还原解决这种问题的一个方式,参见 report-demo 工程。
demo 模拟了两个使用同一个 HttpClient 的接口。如下图所示fast 接口用来访问百度很快就能返回slow 接口访问谷歌,由于众所周知的原因,会阻塞直到超时,大约 10 s。
使用 wrk 工具对这两个接口发起压测。
wrk -t10 -c200 -d300s http://127.0.0.1:8084/slow
wrk -t10 -c200 -d300s http://127.0.0.1:8084/fast
此时访问一个简单的接口,耗时竟然能够达到 20 秒。
time curl http://localhost:8084/stat
fast648,slow:1curl http://localhost:8084/stat 0.01s user 0.01s system 0% cpu 20.937 total
使用 jstack 工具 dump 堆栈。首先使用 jps 命令找到进程号,然后把结果重定向到文件(可以参考 10271.jstack 文件)。
过滤一下 nio 关键字,可以查看 tomcat 相关的线程,足足有 200 个,这和 Spring Boot 默认的 maxThreads 个数不谋而合。更要命的是,有大多数线程,都处于 BLOCKED 状态,说明线程等待资源超时。
cat 10271.jstack |grep http-nio-80 -A 3
使用脚本分析,发现有大量的线程阻塞在 fast 方法上。我们上面也说过,这是一个假象,可能你到了这一步,会心生存疑,以至于无法再向下分析。
$ cat 10271.jstack |grep fast | wc -l
137
$ cat 10271.jstack |grep slow | wc -l
63
分析栈信息,你可能会直接查找 locked 关键字,如下图所示,但是这样的方法一般没什么用,我们需要做更多的统计。
注意下图中有一个处于 BLOCKED 状态的线程它阻塞在对锁的获取上wating to lock。大体浏览一下 DUMP 文件,会发现多处这种状态的线程,可以使用如下脚本进行统计。
cat 10271.tdump| grep "waiting to lock " | awk '{print $5}' | sort | uniq -c | sort -k1 -r
26 <0x0000000782e1b590>
18 <0x0000000787b00448>
16 <0x0000000787b38128>
10 <0x0000000787b14558>
8 <0x0000000787b25060>
4 <0x0000000787b2da18>
4 <0x0000000787b00020>
2 <0x0000000787b6e8e8>
2 <0x0000000787b03328>
2 <0x0000000782e8a660>
1 <0x0000000787b6ab18>
1 <0x0000000787b2ae00>
1 <0x0000000787b0d6c0>
1 <0x0000000787b073b8>
1 <0x0000000782fbcdf8>
1 <0x0000000782e11200>
1 <0x0000000782dfdae0>
我们找到给 0x0000000782e1b590 上锁的执行栈,可以发现全部卡在了 HttpClient 的读操作上。在实际场景中,可以看下排行比较靠前的几个锁地址,找一下共性。
返回头去再看一下代码。我们发现 HttpClient 是共用了一个连接池,当连接数超过 100 的时候,就会阻塞等待。它的连接超时时间是 10 秒,这和 slow 接口的耗时不相上下。
private final static HttpConnectionManager httpConnectionManager = new SimpleHttpConnectionManager(true);
static {
HttpConnectionManagerParams params = new HttpConnectionManagerParams();
params.setMaxTotalConnections(100);
params.setConnectionTimeout(1000 * 10);
params.setSoTimeout(defaultTimeout);
httpConnectionManager.setParams(params);
slow 接口和 fast 接口同时在争抢这些连接,让它时刻处在饱满的状态,进而让 tomcat 的线程等待、占满,造成服务不可用。
问题找到了,解决方式就简单多了。我们希望 slow 接口在阻塞的时候,并不影响 fast 接口的运行。这就可以对某一类接口进行限流,或者对不重要的接口进行熔断处理,这里不再深入讲解(具体可参考 Spring Boot 的限流熔断处理)。
现实情况是,对于一个运行的系统,我们并不知道是 slow 接口慢还是 fast 接口慢,这就需要加入一些额外的日志信息进行排查。当然,如果有一个监控系统能够看到这些数据是再好不过了。
项目中的 HttpClientUtil2 文件是改造后的一个版本。除了调大了连接数它还使用了多线程版本的连接管理器MultiThreadedHttpConnectionManager这个管理器根据请求的 host 进行划分,每个 host 的最大连接数不超过 20。还提供了 getConnectionsInPool 函数,用于查看当前连接池的统计信息。采用这些辅助的手段,可以快速找到问题服务,这是典型的情况。由于其他应用的服务水平低而引起的连锁反应,一般的做法是熔断、限流等,在此不多做介绍了。
jstack 产生的信息
为了观测一些状态,我上传了几个 Java 类,你可以实际运行一下,然后使用 jstack 来看一下它的状态。
waiting on condition
示例参见 SleepDemo.java。
public class SleepDemo {
public static void main(String[] args) {
new Thread(()->{
try {
Thread.sleep(Integer.MAX_VALUE);
} catch (InterruptedException e) {
e.printStackTrace();
}
},"sleep-demo").start();
}
}
这个状态出现在线程等待某个条件的发生,来把自己唤醒,或者调用了 sleep 函数,常见的情况就是等待网络读写,或者等待数据 I/O。如果发现大多数线程都处于这种状态证明后面的资源遇到了瓶颈。
此时线程状态大致分为以下两种:
java.lang.Thread.State: WAITING (parking):一直等待条件发生;
java.lang.Thread.State: TIMED_WAITING (parking 或 sleeping):定时的,即使条件不触发,也将定时唤醒。
"sleep-demo" #12 prio=5 os_prio=31 cpu=0.23ms elapsed=87.49s tid=0x00007fc7a7965000 nid=0x6003 waiting on condition [0x000070000756d000]
java.lang.Thread.State: TIMED_WAITING (sleeping)
at java.lang.Thread.sleep([email protected]/Native Method)
at SleepDemo.lambda$main$0(SleepDemo.java:5)
at SleepDemo$$Lambda$16/0x0000000800b45040.run(Unknown Source)
at java.lang.Thread.run([email protected]/Thread.java:830)
值的注意的是Java 中的可重入锁,也会让线程进入这种状态,但通常带有 parking 字样parking 指线程处于挂起中,要注意区别。代码可参见 LockDemo.java
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class LockDemo {
public static void main(String[] args) {
Lock lock = new ReentrantLock();
lock.lock();
new Thread(() -> {
try {
lock.lock();
} finally {
lock.unlock();
}
}, "lock-demo").start();
}
堆栈代码如下:
"lock-demo" #12 prio=5 os_prio=31 cpu=0.78ms elapsed=14.62s tid=0x00007ffc0b949000 nid=0x9f03 waiting on condition [0x0000700005826000]
java.lang.Thread.State: WAITING (parking)
at jdk.internal.misc.Unsafe.park([email protected]/Native Method)
- parking to wait for <0x0000000787cf0dd8> (a java.util.concurrent.locks.ReentrantLock$NonfairSync)
at java.util.concurrent.locks.LockSupport.park([email protected]/LockSupport.java:194)
at java.util.concurrent.locks.AbstractQueuedSynchronizer.parkAndCheckInterrupt([email protected]/AbstractQueuedSynchronizer.java:885)
at java.util.concurrent.locks.AbstractQueuedSynchronizer.acquireQueued([email protected]/AbstractQueuedSynchronizer.java:917)
at java.util.concurrent.locks.AbstractQueuedSynchronizer.acquire([email protected]/AbstractQueuedSynchronizer.java:1240)
at java.util.concurrent.locks.ReentrantLock.lock([email protected]/ReentrantLock.java:267)
at LockDemo.lambda$main$0(LockDemo.java:11)
at LockDemo$$Lambda$14/0x0000000800b44840.run(Unknown Source)
at java.lang.Thread.run([email protected]/Thread.java:830)
waiting for monitor entry
我们上面提到的 HttpClient 例子,就是大部分处于这种状态,线程都是 BLOCKED 的。这意味着它们都在等待进入一个临界区,需要重点关注。
"http-nio-8084-exec-120" #143 daemon prio=5 os_prio=31 cpu=122.86ms elapsed=317.88s tid=0x00007fedd8381000 nid=0x1af03 waiting for monitor entry [0x00007000150e1000]
java.lang.Thread.State: BLOCKED (on object monitor)
at java.io.BufferedInputStream.read([email protected]/BufferedInputStream.java:263)
- waiting to lock <0x0000000782e1b590> (a java.io.BufferedInputStream)
at org.apache.commons.httpclient.HttpParser.readRawLine(HttpParser.java:78)
at org.apache.commons.httpclient.HttpParser.readLine(HttpParser.java:106)
at org.apache.commons.httpclient.HttpConnection.readLine(HttpConnection.java:1116)
at org.apache.commons.httpclient.HttpMethodBase.readStatusLine(HttpMethodBase.java:1973)
at org.apache.commons.httpclient.HttpMethodBase.readResponse(HttpMethodBase.java:1735)
in Object.wait()
示例代码参见 WaitDemo.java
public class WaitDemo {
public static void main(String[] args) throws Exception {
Object o = new Object();
new Thread(() -> {
try {
synchronized (o) {
o.wait();
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}, "wait-demo").start();
Thread.sleep(1000);
synchronized (o) {
o.wait();
}
}
说明在获得了监视器之后,又调用了 java.lang.Object.wait() 方法。
关于这部分的原理可以参见一张经典的图。每个监视器Monitor在某个时刻只能被一个线程拥有该线程就是“Active Thread”而其他线程都是“Waiting Thread”分别在两个队列“Entry Set”和“Wait Set”里面等候。在“Entry Set”中等待的线程状态是“Waiting for monitor entry”而在“Wait Set”中等待的线程状态是“in Object.wait()”。
"wait-demo" #12 prio=5 os_prio=31 cpu=0.14ms elapsed=12.58s tid=0x00007fb66609e000 nid=0x6103 in Object.wait() [0x000070000f2bd000]
java.lang.Thread.State: WAITING (on object monitor)
at java.lang.Object.wait([email protected]/Native Method)
- waiting on <0x0000000787b48300> (a java.lang.Object)
at java.lang.Object.wait([email protected]/Object.java:326)
at WaitDemo.lambda$main$0(WaitDemo.java:7)
- locked <0x0000000787b48300> (a java.lang.Object)
at WaitDemo$$Lambda$14/0x0000000800b44840.run(Unknown Source)
at java.lang.Thread.run([email protected]/Thread.java:830)
死锁
代码参见 DeadLock.java
public class DeadLockDemo {
public static void main(String[] args) {
Object object1 = new Object();
Object object2 = new Object();
Thread t1 = new Thread(() -> {
synchronized (object1) {
try {
Thread.sleep(200);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (object2) {
}
}
}, "deadlock-demo-1");
t1.start();
Thread t2 = new Thread(() -> {
synchronized (object2) {
synchronized (object1) {
}
}
}, "deadlock-demo-2");
t2.start();
}
}
死锁属于比较严重的一种情况jstack 会以明显的信息进行提示。
Found one Java-level deadlock:
=============================
"deadlock-demo-1":
waiting to lock monitor 0x00007fe5e406f500 (object 0x0000000787cecd78, a java.lang.Object),
which is held by "deadlock-demo-2"
"deadlock-demo-2":
waiting to lock monitor 0x00007fe5e406d500 (object 0x0000000787cecd68, a java.lang.Object),
which is held by "deadlock-demo-1"
Java stack information for the threads listed above:
===================================================
"deadlock-demo-1":
at DeadLockDemo.lambda$main$0(DeadLockDemo.java:13)
- waiting to lock <0x0000000787cecd78> (a java.lang.Object)
- locked <0x0000000787cecd68> (a java.lang.Object)
at DeadLockDemo$$Lambda$14/0x0000000800b44c40.run(Unknown Source)
at java.lang.Thread.run([email protected]/Thread.java:830)
"deadlock-demo-2":
at DeadLockDemo.lambda$main$1(DeadLockDemo.java:21)
- waiting to lock <0x0000000787cecd68> (a java.lang.Object)
- locked <0x0000000787cecd78> (a java.lang.Object)
at DeadLockDemo$$Lambda$16/0x0000000800b45040.run(Unknown Source)
at java.lang.Thread.run([email protected]/Thread.java:830)
Found 1 deadlock
当然,关于线程的 dump也有一些线上分析工具可以使用。下图是 fastthread 的一个分析结果,但也需要你先了解这些情况发生的意义。
![本课时我们主要分析一个案例,那就是分库分表后,我的应用崩溃了。
前面介绍了一种由于数据库查询语句拼接问题,而引起的一类内存溢出。下面将详细介绍一下这个过程。
假设我们有一个用户表,想要通过用户名来查询某个用户,一句简单的 SQL 语句即可:
select * from user where fullname = "xxx" and other="other";
为了达到动态拼接的效果,这句 SQL 语句被一位同事进行了如下修改。他的本意是,当 fullname 或者 other 传入为空的时候,动态去掉这些查询条件。这种写法,在 MyBaits 的配置文件中,也非常常见。
List<User> query(String fullname, String other) {
StringBuilder sb = new StringBuilder("select * from user where 1=1 ");
if (!StringUtils.isEmpty(fullname)) {
sb.append(" and fullname=");
sb.append(" \"" + fullname + "\"");
}
if (!StringUtils.isEmpty(other)) {
sb.append(" and other=");
sb.append(" \"" + other + "\"");
}
String sql = sb.toString();
...
}
大多数情况下,这种写法是没有问题的,因为结果集合是可以控制的。但随着系统的运行,用户表的记录越来越多,当传入的 fullname 和 other 全部为空时悲剧的事情发生了SQL 被拼接成了如下的语句:
select * from user where 1=1
数据库中的所有记录,都会被查询出来,载入到 JVM 的内存中。由于数据库记录实在太多,直接把内存给撑爆了。
在工作中,由于这种原因引起的内存溢出,发生的频率非常高。通常的解决方式是强行加入分页功能,或者对一些必填的参数进行校验,但不总是有效。因为上面的示例仅展示了一个非常简单的 SQL 语句,而在实际工作中,这个 SQL 语句会非常长,每个条件对结果集的影响也会非常大,在进行数据筛选的时候,一定要小心。
内存使用问题
拿一个最简单的 Spring Boot 应用来说,请求会通过 Controller 层来接收数据,然后 Service 层会进行一些逻辑的封装,数据通过 Dao 层的 ORM 比如 JPA 或者 MyBatis 等,来调用底层的 JDBC 接口进行实际的数据获取。通常情况下JVM 对这种数据获取方式,表现都是非常温和的。我们挨个看一下每一层可能出现的一些不正常的内存使用问题(仅限 JVM 相关问题),以便对平常工作中的性能分析和性能优化有一个整体的思路。
首先,我们提到一种可能,那就是类似于 Fastjson 工具所产生的 bug这类问题只能通过升级依赖的包来解决属于一种极端案例。具体可参考这里
Controller 层
Controller 层用于接收前端查询参数,然后构造查询结果。现在很多项目都采用前后端分离架构,所以 Controller 层的方法,一般使用 @ResponseBody 注解,把查询的结果,解析成 JSON 数据返回。
这在数据集非常大的情况下,会占用很多内存资源。假如结果集在解析成 JSON 之前,占用的内存是 10MB那么在解析过程中有可能会使用 20M 或者更多的内存去做这个工作。如果结果集有非常深的嵌套层次,或者引用了另外一个占用内存很大,且对于本次请求无意义的对象(比如非常大的 byte[] 对象),那这些序列化工具会让问题变得更加严重。
因此,对于一般的服务,保持结果集的精简,是非常有必要的,这也是 DTOData Transfer Object存在的必要。如果你的项目返回的结果结构比较复杂对结果集进行一次转换是非常有必要的。互联网环境不怕小结果集的高并发请求却非常恐惧大结果集的耗时请求这是其中一方面的原因。
Service 层
Service 层用于处理具体的业务,更加贴合业务的功能需求。一个 Service可能会被多个 Controller 层所使用,也可能会使用多个 dao 结构的查询结果进行计算、拼装。
Service 的问题主要是对底层资源的不合理使用。举个例子,有一回在一次代码 review 中,发现了下面让人无语的逻辑:
//错误代码示例
int getUserSize() {
List<User> users = dao.getAllUser();
return null == users ? 0 : users.size();
}
这种代码,其实在一些现存的项目里大量存在,只不过由于项目规模和工期的原因,被隐藏了起来,成为内存问题的定时炸弹。
Service 层的另外一个问题就是,职责不清、代码混乱,以至于在发生故障的时候,让人无从下手。这种情况就更加常见了,比如使用了 Map 作为函数的入参,或者把多个接口的请求返回放在一个 Java 类中。
//错误代码示例
Object exec(Map<String,Object> params){
String q = getString(params,"q");
if(q.equals("insertToa")){
String q1 = getString(params,"q1");
String q2 = getString(params,"q2");
//do A
}else if(q.equals("getResources")){
String q3 = getString(params,"q3");
//do B
}
...
return null;
}
这种代码使用了万能参数和万能返回值exec 函数会被几十个上百个接口调用,进行逻辑的分发。这种将逻辑揉在一起的代码块,当发生问题时,即使使用了 Jstack也无法发现具体的调用关系在平常的开发中应该严格禁止。
ORM 层
ORM 层可能是发生内存问题最多的地方,除了本课时开始提到的 SQL 拼接问题,大多数是由于对这些 ORM 工具使用不当而引起的。
举个例子,在 JPA 中,如果加了一对多或者多对多的映射关系,而又没有开启懒加载、级联查询的时候就容易造成深层次的检索,内存的开销就超出了我们的期望,造成过度使用。
另外JPA 可以通过使用缓存来减少 SQL 的查询,它默认开启了一级缓存,也就是 EntityManager 层的缓存会话或事务缓存如果你的事务非常的大它会缓存很多不需要的数据JPA 还可以通过一定的配置来完成二级缓存,也就是全局缓存,造成更多的内存占用。
一般,项目中用到缓存的地方,要特别小心。除了容易造成数据不一致之外,对堆内内存的使用也要格外关注。如果使用量过多,很容易造成频繁 GC甚至内存溢出。
JPA 比起 MyBatis 等 ORM 拥有更多的特性,看起来容易使用,但精通门槛却比较高。
这并不代表 MyBatis 就没有内存问题,在这些 ORM 框架之中,存在着非常多的类型转换、数据拷贝。
举个例子,有一个批量导入服务,在 MyBatis 执行批量插入的时候,竟然产生了内存溢出,按道理这种插入操作是不会引起额外内存占用的,最后通过源码追踪到了问题。
这是因为 MyBatis 循环处理 batch 的时候,操作对象是数组,而我们在接口定义的时候,使用的是 List当传入一个非常大的 List 时,它需要调用 List 的 toArray 方法将列表转换成数组(浅拷贝);在最后的拼装阶段,使用了 StringBuilder 来拼接最终的 SQL所以实际使用的内存要比 List 多很多。
事实证明,不论是插入操作还是查询动作,只要涉及的数据集非常大,就容易出现问题。由于项目中众多框架的引入,想要分析这些具体的内存占用,就变得非常困难。保持小批量操作和结果集的干净,是一个非常好的习惯。
分库分表内存溢出
分库分表组件
如果数据库的记录非常多,达到千万或者亿级别,对于一个传统的 RDBMS 来说,最通用的解决方式就是分库分表。这也是海量数据的互联网公司必须面临的一个问题。
根据切入的层次,数据库中间件一般分为编码层、框架层、驱动层、代理层、实现层 5 大类。典型的框架有驱动层的 sharding-jdbc 和代理层的 MyCat。
MyCat 是一个独立部署的 Java 服务,它模拟了一个 MySQL 进行请求的处理,对于应用来说使用是透明的。而 sharding-jdbc 实际上是一个数据库驱动,或者说是一个 DataSource它是作为 jar 包直接嵌入在客户端应用的,所以它的行为会直接影响到主应用。
这里所要说的分库分表组件,就是 sharding-jdbc。不管是普通 Spring 环境,还是 Spring Boot 环境,经过一系列配置之后,我们都可以像下面这种方式来使用 sharding-jdbc应用层并不知晓底层实现的细节
@Autowired
private DataSource dataSource;
我们有一个线上订单应用,由于数据量过多的原因,进行了分库分表。但是在某些条件下,却经常发生内存溢出。
分库分表的内存溢出
一个最典型的内存溢出场景,就是在订单查询中使用了深分页,并且在查询的时候没有使用“切分键”。使用前面介绍的一些工具,比如 MAT、Jstack最终追踪到是由于 sharding-jdbc 内部实现所引起的。
这个过程也是比较好理解的,如图所示,订单数据被存放在两个库中。如果没有提供切分键,查询语句就会被分发到所有的数据库中,这里的查询语句是 limit 10、offset 1000最终结果只需要返回 10 条记录,但是数据库中间件要完成这种计算,则需要 (1000+10)*2=2020 条记录来完成这个计算过程。如果 offset 的值过大,使用的内存就会暴涨。虽然 sharding-jdbc 使用归并算法进行了一些优化,但在实际场景中,深分页仍然引起了内存和性能问题。
下面这一句简单的 SQL 语句,会产生严重的后果:
select * from order order by updateTime desc limit 10 offset 10000
这种在中间节点进行归并聚合的操作,在分布式框架中非常常见。比如在 ElasticSearch 中,就存在相似的数据获取逻辑,不加限制的深分页,同样会造成 ES 的内存问题。
另外一种情况,就是我们在进行一些复杂查询的时候,发现分页失效了,每次都是取出全部的数据。最后根据 Jstack定位到具体的执行逻辑发现分页被重写了。
private void appendLimitRowCount(final SQLBuilder sqlBuilder, final RowCountToken rowCountToken, final int count, final List<SQLToken> sqlTokens, final boolean isRewrite) {
SelectStatement selectStatement = (SelectStatement) sqlStatement;
Limit limit = selectStatement.getLimit();
if (!isRewrite) {
sqlBuilder.appendLiterals(String.valueOf(rowCountToken.getRowCount()));
} else if ((!selectStatement.getGroupByItems().isEmpty() || !selectStatement.getAggregationSelectItems().isEmpty()) && !selectStatement.isSameGroupByAndOrderByItems()) {
sqlBuilder.appendLiterals(String.valueOf(Integer.MAX_VALUE));
} else {
sqlBuilder.appendLiterals(String.valueOf(limit.isNeedRewriteRowCount() ? rowCountToken.getRowCount() + limit.getOffsetValue() : rowCountToken.getRowCount()));
}
int beginPosition = rowCountToken.getBeginPosition() + String.valueOf(rowCountToken.getRowCount()).length();
appendRest(sqlBuilder, count, sqlTokens, beginPosition);
}
如上代码,在进入一些复杂的条件判断时(参照 SQLRewriteEngine.java分页被重置为 Integer.MAX_VALUE。
总结
本课时以 Spring Boot 项目常见的分层结构,介绍了每一层可能会引起的内存问题,我们把结论归结为一点,那就是保持输入集或者结果集的简洁。一次性获取非常多的数据,会让中间过程变得非常不可控。最后,我们分析了一个驱动层的数据库中间件,以及对内存使用的一些问题。
很多程序员把这些耗时又耗内存的操作,写了非常复杂的 SQL 语句然后扔给最底层的数据库去解决这种情况大多数认为换汤不换药不过是把具体的问题冲突转移到另一个场景而已。img](assets/Cgq2xl5YswOALstRAAKRsvw-7ZU685.jpg)
小结
本课时主要介绍了一个处处有问题的报表系统,并逐步解决了它的 OOM 问题,同时定位到了拒绝服务的原因。
在研发资源不足的时候,我们简单粗暴的进行了硬件升级,并切换到了更加优秀的 G1 垃圾回收器,还通过代码手段进行了问题的根本解决:
缩减查询的字段,减少常驻内存的数据;
去掉不必要的、命中率低的堆内缓存,改为分布式缓存;
从产品层面限制了单次请求对内存的无限制使用。
在这个过程中,使用 MAT 分析堆数据进行问题代码定位,帮了大忙。代码优化的手段是最有效的,改造完毕后,可以节省更多的硬件资源。事实上,使用了 G1 垃圾回收器之后,那些乱七八糟的调优参数越来越少用了。
接下来,我们使用 jstack 分析了一个出现频率非常非常高的问题,主要是不同速度的接口在同一应用中的资源竞争问题,我们发现一些成熟的微服务框架,都会对这些资源进行限制和隔离。
最后,以 4 个简单的示例,展示了 jstack 输出内容的一些意义。代码都在 git 仓库里,你可以实际操作一下,希望对你有所帮助。

View File

@ -0,0 +1,175 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
17 案例分析:分库分表后,我的应用崩溃了
本课时我们主要分析一个案例,那就是分库分表后,我的应用崩溃了。
前面介绍了一种由于数据库查询语句拼接问题,而引起的一类内存溢出。下面将详细介绍一下这个过程。
假设我们有一个用户表,想要通过用户名来查询某个用户,一句简单的 SQL 语句即可:
select * from user where fullname = "xxx" and other="other";
为了达到动态拼接的效果,这句 SQL 语句被一位同事进行了如下修改。他的本意是,当 fullname 或者 other 传入为空的时候,动态去掉这些查询条件。这种写法,在 MyBaits 的配置文件中,也非常常见。
List<User> query(String fullname, String other) {
StringBuilder sb = new StringBuilder("select * from user where 1=1 ");
if (!StringUtils.isEmpty(fullname)) {
sb.append(" and fullname=");
sb.append(" \"" + fullname + "\"");
}
if (!StringUtils.isEmpty(other)) {
sb.append(" and other=");
sb.append(" \"" + other + "\"");
}
String sql = sb.toString();
...
}
大多数情况下,这种写法是没有问题的,因为结果集合是可以控制的。但随着系统的运行,用户表的记录越来越多,当传入的 fullname 和 other 全部为空时悲剧的事情发生了SQL 被拼接成了如下的语句:
select * from user where 1=1
数据库中的所有记录,都会被查询出来,载入到 JVM 的内存中。由于数据库记录实在太多,直接把内存给撑爆了。
在工作中,由于这种原因引起的内存溢出,发生的频率非常高。通常的解决方式是强行加入分页功能,或者对一些必填的参数进行校验,但不总是有效。因为上面的示例仅展示了一个非常简单的 SQL 语句,而在实际工作中,这个 SQL 语句会非常长,每个条件对结果集的影响也会非常大,在进行数据筛选的时候,一定要小心。
内存使用问题
拿一个最简单的 Spring Boot 应用来说,请求会通过 Controller 层来接收数据,然后 Service 层会进行一些逻辑的封装,数据通过 Dao 层的 ORM 比如 JPA 或者 MyBatis 等,来调用底层的 JDBC 接口进行实际的数据获取。通常情况下JVM 对这种数据获取方式,表现都是非常温和的。我们挨个看一下每一层可能出现的一些不正常的内存使用问题(仅限 JVM 相关问题),以便对平常工作中的性能分析和性能优化有一个整体的思路。
首先,我们提到一种可能,那就是类似于 Fastjson 工具所产生的 bug这类问题只能通过升级依赖的包来解决属于一种极端案例。具体可参考这里
Controller 层
Controller 层用于接收前端查询参数,然后构造查询结果。现在很多项目都采用前后端分离架构,所以 Controller 层的方法,一般使用 @ResponseBody 注解,把查询的结果,解析成 JSON 数据返回。
这在数据集非常大的情况下,会占用很多内存资源。假如结果集在解析成 JSON 之前,占用的内存是 10MB那么在解析过程中有可能会使用 20M 或者更多的内存去做这个工作。如果结果集有非常深的嵌套层次,或者引用了另外一个占用内存很大,且对于本次请求无意义的对象(比如非常大的 byte[] 对象),那这些序列化工具会让问题变得更加严重。
因此,对于一般的服务,保持结果集的精简,是非常有必要的,这也是 DTOData Transfer Object存在的必要。如果你的项目返回的结果结构比较复杂对结果集进行一次转换是非常有必要的。互联网环境不怕小结果集的高并发请求却非常恐惧大结果集的耗时请求这是其中一方面的原因。
Service 层
Service 层用于处理具体的业务,更加贴合业务的功能需求。一个 Service可能会被多个 Controller 层所使用,也可能会使用多个 dao 结构的查询结果进行计算、拼装。
Service 的问题主要是对底层资源的不合理使用。举个例子,有一回在一次代码 review 中,发现了下面让人无语的逻辑:
//错误代码示例
int getUserSize() {
List<User> users = dao.getAllUser();
return null == users ? 0 : users.size();
}
这种代码,其实在一些现存的项目里大量存在,只不过由于项目规模和工期的原因,被隐藏了起来,成为内存问题的定时炸弹。
Service 层的另外一个问题就是,职责不清、代码混乱,以至于在发生故障的时候,让人无从下手。这种情况就更加常见了,比如使用了 Map 作为函数的入参,或者把多个接口的请求返回放在一个 Java 类中。
//错误代码示例
Object exec(Map<String,Object> params){
String q = getString(params,"q");
if(q.equals("insertToa")){
String q1 = getString(params,"q1");
String q2 = getString(params,"q2");
//do A
}else if(q.equals("getResources")){
String q3 = getString(params,"q3");
//do B
}
...
return null;
}
这种代码使用了万能参数和万能返回值exec 函数会被几十个上百个接口调用,进行逻辑的分发。这种将逻辑揉在一起的代码块,当发生问题时,即使使用了 Jstack也无法发现具体的调用关系在平常的开发中应该严格禁止。
ORM 层
ORM 层可能是发生内存问题最多的地方,除了本课时开始提到的 SQL 拼接问题,大多数是由于对这些 ORM 工具使用不当而引起的。
举个例子,在 JPA 中,如果加了一对多或者多对多的映射关系,而又没有开启懒加载、级联查询的时候就容易造成深层次的检索,内存的开销就超出了我们的期望,造成过度使用。
另外JPA 可以通过使用缓存来减少 SQL 的查询,它默认开启了一级缓存,也就是 EntityManager 层的缓存会话或事务缓存如果你的事务非常的大它会缓存很多不需要的数据JPA 还可以通过一定的配置来完成二级缓存,也就是全局缓存,造成更多的内存占用。
一般,项目中用到缓存的地方,要特别小心。除了容易造成数据不一致之外,对堆内内存的使用也要格外关注。如果使用量过多,很容易造成频繁 GC甚至内存溢出。
JPA 比起 MyBatis 等 ORM 拥有更多的特性,看起来容易使用,但精通门槛却比较高。
这并不代表 MyBatis 就没有内存问题,在这些 ORM 框架之中,存在着非常多的类型转换、数据拷贝。
举个例子,有一个批量导入服务,在 MyBatis 执行批量插入的时候,竟然产生了内存溢出,按道理这种插入操作是不会引起额外内存占用的,最后通过源码追踪到了问题。
这是因为 MyBatis 循环处理 batch 的时候,操作对象是数组,而我们在接口定义的时候,使用的是 List当传入一个非常大的 List 时,它需要调用 List 的 toArray 方法将列表转换成数组(浅拷贝);在最后的拼装阶段,使用了 StringBuilder 来拼接最终的 SQL所以实际使用的内存要比 List 多很多。
事实证明,不论是插入操作还是查询动作,只要涉及的数据集非常大,就容易出现问题。由于项目中众多框架的引入,想要分析这些具体的内存占用,就变得非常困难。保持小批量操作和结果集的干净,是一个非常好的习惯。
分库分表内存溢出
分库分表组件
如果数据库的记录非常多,达到千万或者亿级别,对于一个传统的 RDBMS 来说,最通用的解决方式就是分库分表。这也是海量数据的互联网公司必须面临的一个问题。
根据切入的层次,数据库中间件一般分为编码层、框架层、驱动层、代理层、实现层 5 大类。典型的框架有驱动层的 sharding-jdbc 和代理层的 MyCat。
MyCat 是一个独立部署的 Java 服务,它模拟了一个 MySQL 进行请求的处理,对于应用来说使用是透明的。而 sharding-jdbc 实际上是一个数据库驱动,或者说是一个 DataSource它是作为 jar 包直接嵌入在客户端应用的,所以它的行为会直接影响到主应用。
这里所要说的分库分表组件,就是 sharding-jdbc。不管是普通 Spring 环境,还是 Spring Boot 环境,经过一系列配置之后,我们都可以像下面这种方式来使用 sharding-jdbc应用层并不知晓底层实现的细节
@Autowired
private DataSource dataSource;
我们有一个线上订单应用,由于数据量过多的原因,进行了分库分表。但是在某些条件下,却经常发生内存溢出。
分库分表的内存溢出
一个最典型的内存溢出场景,就是在订单查询中使用了深分页,并且在查询的时候没有使用“切分键”。使用前面介绍的一些工具,比如 MAT、Jstack最终追踪到是由于 sharding-jdbc 内部实现所引起的。
这个过程也是比较好理解的,如图所示,订单数据被存放在两个库中。如果没有提供切分键,查询语句就会被分发到所有的数据库中,这里的查询语句是 limit 10、offset 1000最终结果只需要返回 10 条记录,但是数据库中间件要完成这种计算,则需要 (1000+10)*2=2020 条记录来完成这个计算过程。如果 offset 的值过大,使用的内存就会暴涨。虽然 sharding-jdbc 使用归并算法进行了一些优化,但在实际场景中,深分页仍然引起了内存和性能问题。
下面这一句简单的 SQL 语句,会产生严重的后果:
select * from order order by updateTime desc limit 10 offset 10000
这种在中间节点进行归并聚合的操作,在分布式框架中非常常见。比如在 ElasticSearch 中,就存在相似的数据获取逻辑,不加限制的深分页,同样会造成 ES 的内存问题。
另外一种情况,就是我们在进行一些复杂查询的时候,发现分页失效了,每次都是取出全部的数据。最后根据 Jstack定位到具体的执行逻辑发现分页被重写了。
private void appendLimitRowCount(final SQLBuilder sqlBuilder, final RowCountToken rowCountToken, final int count, final List<SQLToken> sqlTokens, final boolean isRewrite) {
SelectStatement selectStatement = (SelectStatement) sqlStatement;
Limit limit = selectStatement.getLimit();
if (!isRewrite) {
sqlBuilder.appendLiterals(String.valueOf(rowCountToken.getRowCount()));
} else if ((!selectStatement.getGroupByItems().isEmpty() || !selectStatement.getAggregationSelectItems().isEmpty()) && !selectStatement.isSameGroupByAndOrderByItems()) {
sqlBuilder.appendLiterals(String.valueOf(Integer.MAX_VALUE));
} else {
sqlBuilder.appendLiterals(String.valueOf(limit.isNeedRewriteRowCount() ? rowCountToken.getRowCount() + limit.getOffsetValue() : rowCountToken.getRowCount()));
}
int beginPosition = rowCountToken.getBeginPosition() + String.valueOf(rowCountToken.getRowCount()).length();
appendRest(sqlBuilder, count, sqlTokens, beginPosition);
}
如上代码,在进入一些复杂的条件判断时(参照 SQLRewriteEngine.java分页被重置为 Integer.MAX_VALUE。
总结
本课时以 Spring Boot 项目常见的分层结构,介绍了每一层可能会引起的内存问题,我们把结论归结为一点,那就是保持输入集或者结果集的简洁。一次性获取非常多的数据,会让中间过程变得非常不可控。最后,我们分析了一个驱动层的数据库中间件,以及对内存使用的一些问题。
很多程序员把这些耗时又耗内存的操作,写了非常复杂的 SQL 语句,然后扔给最底层的数据库去解决,这种情况大多数认为换汤不换药,不过是把具体的问题冲突,转移到另一个场景而已。

View File

@ -0,0 +1,338 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
18 动手实践:从字节码看方法调用的底层实现
本课时我们主要分析从字节码看方法调用的底层实现。
字节码结构
基本结构
在开始之前,我们先简要地介绍一下 class 文件的内容,这个结构和我们前面使用的 jclasslib 是一样的。关于 class 文件结构的资料已经非常多了(点击这里可查看官网详细介绍),这里不再展开讲解了,大体介绍如下。
magic魔数用于标识当前 class 的文件格式JVM 可据此判断该文件是否可以被解析,目前固定为 0xCAFEBABE。
major_version主版本号。
minor_version副版本号这两个版本号用来标识编译时的 JDK 版本,常见的一个异常比如 Unsupported major.minor version 52.0 就是因为运行时的 JDK 版本低于编译时的 JDK 版本52 是 Java 8 的主版本号)。
constant_pool_count常量池计数器等于常量池中的成员数加 1。
constant_pool常量池是一种表结构包含 class 文件结构和子结构中引用的所有字符串常量,类或者接口名,字段名和其他常量。
access_flags表示某个类或者接口的访问权限和属性。
this_class类索引该值必须是对常量池中某个常量的一个有效索引值该索引处的成员必须是一个 CONSTANT_Class_info 类型的结构体,表示这个 class 文件所定义的类和接口。
super_class父类索引。
interfaces_count接口计数器表示当前类或者接口直接继承接口的数量。
interfaces接口表是一个表结构成员同 this_class是对常量池中 CONSTANT_Class_info 类型的一个有效索引值。
fields_count字段计数器当前 class 文件所有字段的数量。
fields字段表是一个表结构表中每个成员必须是 filed_info 数据结构,用于表示当前类或者接口的某个字段的完整描述,但它不包含从父类或者父接口继承的字段。
methods_count方法计数器表示当前类方法表的成员个数。
methods方法表是一个表结构表中每个成员必须是 method_info 数据结构,用于表示当前类或者接口的某个方法的完整描述。
attributes_count属性计数器表示当前 class 文件 attributes 属性表的成员个数。
attributes属性表是一个表结构表中每个成员必须是 attribute_info 数据结构,这里的属性是对 class 文件本身,方法或者字段的补充描述,比如 SourceFile 属性用于表示 class 文件的源代码文件名。
当然class 文件结构的细节是非常多的,如上图,展示了一个简单方法的字节码描述,可以看到真正的执行指令在整个文件结构中的位置。
实际观测
为了避免枯燥的二进制对比分析,直接定位到真正的数据结构,这里介绍一个小工具,使用这种方式学习字节码会节省很多时间。这个工具就是 asmtools为了方便使用我已经编译了一个 jar 包,放在了仓库里。
执行下面的命令,将看到类的 JCOD 语法结果。
java -jar asmtools-7.0.jar jdec LambdaDemo.class
输出的结果类似于下面的结构,它与我们上面介绍的字节码组成是一一对应的,对照官网或者资料去学习,速度飞快。若想要细挖字节码,一定要掌握好它。
class LambdaDemo {
0xCAFEBABE;
0; // minor version
52; // version
[] { // Constant Pool
; // first element is empty
Method #8 #25; // #1
InvokeDynamic 0s #30; // #2
InterfaceMethod #31 #32; // #3
Field #33 #34; // #4
String #35; // #5
Method #36 #37; // #6
class #38; // #7
class #39; // #8
Utf8 "<init>"; // #9
Utf8 "()V"; // #10
Utf8 "Code"; // #11
了解了类的文件组织方式,下面我们来看一下,类文件在加载到内存中以后,是一个怎样的表现形式。
内存表示
准备以下代码,使用 javac -g InvokeDemo.java 进行编译,然后使用 java 命令执行。程序将阻塞在 sleep 函数上,我们来看一下它的内存分布:
interface I {
default void infMethod() { }
void inf();
}
abstract class Abs {
abstract void abs();
}
public class InvokeDemo extends Abs implements I {
static void staticMethod() { }
private void privateMethod() { }
public void publicMethod() { }
@Override
public void inf() { }
@Override
void abs() { }
public static void main(String[] args) throws Exception{
InvokeDemo demo = new InvokeDemo();
InvokeDemo.staticMethod();
demo.abs();
((Abs) demo).abs();
demo.inf();
((I) demo).inf();
demo.privateMethod();
demo.publicMethod();
demo.infMethod();
((I) demo).infMethod();
Thread.sleep(Integer.MAX_VAL
为了更加明显的看到这个过程,下面介绍一个 jhsdb 工具,这是在 Java 9 之后 JDK 先加入的调试工具,我们可以在命令行中使用 jhsdb hsdb 来启动它。注意,要加载相应的进程时,必须确保是同一个版本的应用进程,否则会产生报错。
attach 启动 Java 进程后,可以在 Class Browser 菜单中查看加载的所有类信息。我们在搜索框中输入 InvokeDemo找到要查看的类。
@ 符号后面的,就是具体的内存地址,我们可以复制一个,然后在 Inspector 视图中查看具体的属性,可以大体认为这就是类在方法区的具体存储。
在 Inspector 视图中,我们找到方法相关的属性 _methods可惜它无法点开也无法查看。
接下来使用命令行来检查这个数组里面的值。打开菜单中的 Console然后输入 examine 命令,可以看到这个数组里的内容,对应的地址就是 Class 视图中的方法地址。
examine 0x000000010e650570/10
我们可以在 Inspect 视图中看到方法所对应的内存信息,这确实是一个 Method 方法的表示。
相比较起来,对象就简单了,它只需要保存一个到达 Class 对象的指针即可。我们需要先从对象视图中进入,然后找到它,一步步进入 Inspect 视图。
由以上的这些分析,可以得出下面这张图。执行引擎想要运行某个对象的方法,需要先在栈上找到这个对象的引用,然后再通过对象的指针,找到相应的方法字节码。
方法调用指令
关于方法的调用Java 共提供了 5 个指令,来调用不同类型的函数:
invokestatic 用来调用静态方法;
invokevirtual 用于调用非私有实例方法,比如 public 和 protected大多数方法调用属于这一种
invokeinterface 和上面这条指令类似,不过作用于接口类;
invokespecial 用于调用私有实例方法、构造器及 super 关键字等;
invokedynamic 用于调用动态方法。
我们依然使用上面的代码片段来看一下前四个指令的使用场景。代码中包含一个接口 I、一个抽象类 Abs、一个实现和继承了两者类的 InvokeDemo。
回想一下,第 03 课时讲到的类加载机制,在 class 文件被加载到方法区以后,就完成了从符号引用到具体地址的转换过程。
我们可以看一下编译后的 main 方法字节码,尤其需要注意的是对于接口方法的调用。使用实例对象直接调用,和强制转化成接口调用,所调用的字节码指令分别是 invokevirtual 和 invokeinterface它们是有所不同的。
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=2, args_size=1
0: new #2 // class InvokeDemo
3: dup
4: invokespecial #3 // Method "<init>":()V
7: astore_1
8: invokestatic #4 // Method staticMethod:()V
11: aload_1
12: invokevirtual #5 // Method abs:()V
15: aload_1
16: invokevirtual #6 // Method Abs.abs:()V
19: aload_1
20: invokevirtual #7 // Method inf:()V
23: aload_1
24: invokeinterface #8, 1 // InterfaceMethod I.inf:()V
29: aload_1
30: invokespecial #9 // Method privateMethod:()V
33: aload_1
34: invokevirtual #10 // Method publicMethod:()V
37: aload_1
38: invokevirtual #11 // Method infMethod:()V
41: aload_1
42: invokeinterface #12, 1 // InterfaceMethod I.infMethod:()V
47: return
另外还有一点,和我们想象中的不同,大多数普通方法调用,使用的是 invokevirtual 指令,它其实和 invokeinterface 是一类的都属于虚方法调用。很多时候JVM 需要根据调用者的动态类型,来确定调用的目标方法,这就是动态绑定的过程。
invokevirtual 指令有多态查找的机制,该指令运行时,解析过程如下:
找到操作数栈顶的第一个元素所指向的对象实际类型,记做 c
如果在类型 c 中找到与常量中的描述符和简单名称都相符的方法,则进行访问权限校验,如果通过则返回这个方法直接引用,查找过程结束,不通过则返回 java.lang.IllegalAccessError
否则,按照继承关系从下往上依次对 c 的各个父类进行第二步的搜索和验证过程;
如果始终没找到合适的方法,则抛出 java.lang.AbstractMethodError 异常,这就是 Java 语言中方法重写的本质。
相对比invokestatic 指令加上 invokespecial 指令,就属于静态绑定过程。
所以静态绑定,指的是能够直接识别目标方法的情况,而动态绑定指的是需要在运行过程中根据调用者的类型来确定目标方法的情况。
可以想象相对于静态绑定的方法调用来说动态绑定的调用会更加耗时一些。由于方法的调用非常的频繁JVM 对动态调用的代码进行了比较多的优化,比如使用方法表来加快对具体方法的寻址,以及使用更快的缓冲区来直接寻址( 内联缓存)。
invokedynamic
有时候在写一些 Python 脚本或者JS 脚本时,特别羡慕这些动态语言。如果把查找目标方法的决定权,从虚拟机转嫁给用户代码,我们就会有更高的自由度。
之所以单独把 invokedynamic 抽离出来介绍,是因为它比较复杂。和反射类似,它用于一些动态的调用场景,但它和反射有着本质的不同,效率也比反射要高得多。
这个指令通常在 Lambda 语法中出现,我们来看一下一小段代码:
public class LambdaDemo {
public static void main(String[] args) {
Runnable r = () -> System.out.println("Hello Lambda");
r.run();
}
}
使用 javap -p -v 命令可以在 main 方法中看到 invokedynamic 指令:
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=1, locals=2, args_size=1
0: invokedynamic #2, 0 // InvokeDynamic #0:run:()Ljava/lang/Runnable;
5: astore_1
6: aload_1
7: invokeinterface #3, 1 // InterfaceMethod java/lang/Runnable.run:()V
12: return
另外,我们在 javap 的输出中找到了一些奇怪的东西:
BootstrapMethods:
0: #27 invokestatic java/lang/invoke/LambdaMetafactory.metafactory:
(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang
/invoke/MethodType;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/
MethodHandle;Ljava/lang/invoke/MethodType;)Ljava/lang/invoke/CallSite;
Method arguments:
#28 ()V
#29 invokestatic LambdaDemo.lambda$main$0:()V
#28 ()V
BootstrapMethods 属性在 Java 1.7 以后才有,位于类文件的属性列表中,这个属性用于保存 invokedynamic 指令引用的引导方法限定符。
和上面介绍的四个指令不同invokedynamic 并没有确切的接受对象,取而代之的,是一个叫 CallSite 的对象。
static CallSite bootstrap(MethodHandles.Lookup caller, String name, MethodType type);
其实invokedynamic 指令的底层是使用方法句柄MethodHandle来实现的。方法句柄是一个能够被执行的引用它可以指向静态方法和实例方法以及虚构的 get 和 set 方法,从 IDE 中可以看到这些函数。
句柄类型MethodType是我们对方法的具体描述配合方法名称能够定位到一类函数。访问方法句柄和调用原来的指令基本一致但它的调用异常包括一些权限检查在运行时才能被发现。
下面这段代码,可以完成一些动态语言的特性,通过方法名称和传入的对象主体,进行不同的调用,而 Bike 和 Man 类,可以没有任何关系。
import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodHandles;
import java.lang.invoke.MethodType;
public class MethodHandleDemo {
static class Bike {
String sound() {
return "ding ding";
}
}
static class Animal {
String sound() {
return "wow wow";
}
}
static class Man extends Animal {
@Override
String sound() {
return "hou hou";
}
}
String sound(Object o) throws Throwable {
MethodHandles.Lookup lookup = MethodHandles.lookup();
MethodType methodType = MethodType.methodType(String.class);
MethodHandle methodHandle = lookup.findVirtual(o.getClass(), "sound", methodType);
String obj = (String) methodHandle.invoke(o);
return obj;
}
public static void main(String[] args) throws Throwable {
String str = new MethodHandleDemo().sound(new Bike());
System.out.println(str);
str = new MethodHandleDemo().sound(new Animal());
System.out.println(str);
str = new MethodHandleDemo().sound(new Man());
System.out.println(str);
可以看到 Lambda 语言实际上是通过方法句柄来完成的,在调用链上自然也多了一些调用步骤,那么在性能上,是否就意味着 Lambda 性能低呢?对于大部分“非捕获”的 Lambda 表达式来说JIT 编译器的逃逸分析能够优化这部分差异,性能和传统方式无异;但对于“捕获型”的表达式来说,则需要通过方法句柄,不断地生成适配器,性能自然就低了很多(不过和便捷性相比,一丁点性能损失是可接受的)。
除了 Lambda 表达式,我们还没有其他的方式来产生 invokedynamic 指令。但可以使用一些外部的字节码修改工具,比如 ASM来生成一些带有这个指令的字节码这通常能够完成一些非常酷的功能比如完成一门弱类型检查的 JVM-Base 语言。
小结
本课时从 Java 字节码的顶层结构介绍开始,通过一个实际代码,了解了类加载以后,在 JVM 内存里的表现形式,并学习了 jhsdb 对 Java 进程的观测方式。
接下来,我们分析了 invokestatic、invokevirtual、invokeinterface、invokespecial 这四个字节码指令的使用场景,并从字节码中看到了这些区别。
最后,了解了 Java 7 之后的 invokedynamic 指令,它实际上是通过方法句柄来实现的。和我们关系最大的就是 Lambda 语法,了解了这些原理,可以忽略那些对 Lambda 性能高低的争论,要尽量写一些“非捕获”的 Lambda 表达式。

View File

@ -0,0 +1,235 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
19 大厂面试题:不要搞混 JMM 与 JVM
本课时我们主要分析一个大厂面试题:不要搞混 JMM 与 JVM。
在面试的时候,有一个问题经常被问到,那就是 Java 的内存模型,它已经成为了面试中的标配,是非常具有原理性的一个知识点。但是,有不少人把它和 JVM 的内存布局搞混了,以至于答非所问。这个现象在一些工作多年的程序员中非常普遍,主要是因为 JMM 与多线程有关,而且相对于底层而言,很多人平常的工作就是 CRUD很难接触到这方面的知识。
预警:本课时假设你已经熟悉 Java 并发编程的 API且有实际的编程经验。如果不是很了解那么本课时和下一课时的一些内容可能会比较晦涩。
JMM 概念
在第 02 课时,就已经了解了 JVM 的内存布局,你可以认为这是 JVM 的数据存储模型;但对于 JVM 的运行时模型还有一个和多线程相关的且非常容易搞混的概念——Java 的内存模型JMMJava Memory Model
我们在 Java 的内存布局课时第02课时还了解了 Java 的虚拟机栈,它和线程相关,也就是我们的字节码指令其实是靠操作栈来完成的。现在,用一小段代码,来看一下这个执行引擎的一些特点。
import java.util.stream.IntStream;
public class JMMDemo {
int value = 0;
void add() {
value++;
}
public static void main(String[] args) throws Exception {
final int count = 100000;
final JMMDemo demo = new JMMDemo();
Thread t1 = new Thread(() -> IntStream.range(0, count).forEach((i) -> demo.add()));
Thread t2 = new Thread(() -> IntStream.range(0, count).forEach((i) -> demo.add()));
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(demo.value);
上面的代码没有任何同步块,每个线程单独运行后,都会对 value 加 10 万,但执行之后,大概率不会输出 20 万。深层次的原因,我们将使用 javap 命令从字节码层面找一下。
void add();
descriptor: ()V
flags:
Code:
stack=3, locals=1, args_size=1
0: aload_0
1: dup
2: getfield #2 // Field value:I
5: iconst_1
6: iadd
7: putfield #2 // Field value:I
10: return
LineNumberTable:
line 7: 0
line 8: 10
LocalVariableTable:
Start Length Slot Name Signature
0 11 0 this LJMMDemo;
着重看一下 add 方法,可以看到一个简单的 i++ 操作,竟然有这么多的字节码,而它们都是傻乎乎按照“顺序执行”的。当它自己执行的时候不会有什么问题,但是如果放在多线程环境中,执行顺序就变得不可预料了。
上图展示了这个乱序的过程。线程 A 和线程 B“并发”执行相同的代码块 add执行的顺序如图中的标号它们在线程中是有序的1、2、5 或者 3、4、6但整体顺序是不可预测的。
线程 A 和 B 各自执行了一次加 1 操作,但在这种场景中,线程 B 的 putfield 指令直接覆盖了线程 A 的值,最终 value 的结果是 101。
上面的示例仅仅是字节码层面上的更加复杂的是CPU 和内存之间同样存在一致性问题。很多人认为 CPU 是一个计算组件,并没有数据一致性的问题。但事实上,由于内存的发展速度跟不上 CPU 的更新,在 CPU 和内存之间,存在着多层的高速缓存。
原因就是由于多核所引起的,这些高速缓存,往往会有多层。如果一个线程的时间片跨越了多个 CPU那么同样存在同步的问题。
另外在执行过程中CPU 可能也会对输入的代码进行乱序执行优化Java 虚拟机的即时编译器也有类似的指令重排序优化。整个函数的执行步骤就分的更加细致,看起来非常的碎片化(比字节码指令要细很多)。
不管是字节码的原因,还是硬件的原因,在粗粒度上简化来看,比较浅显且明显的因素,那就是线程 add 方法的操作并不是原子性的。
为了解决这个问题,我们可以在 add 方法上添加 synchronized 关键字,它不仅保证了内存上的同步,而且还保证了 CPU 的同步。这个时候,各个线程只能排队进入 add 方法,我们也能够得到期望的结果 102。
synchronized void add() {
value++;
}
讲到这里Java 的内存模型就呼之欲出了。JMM 是一个抽象的概念,它描述了一系列的规则或者规范,用来解决多线程的共享变量问题,比如 volatile、synchronized 等关键字就是围绕 JMM 的语法。这里所说的变量,包括实例字段、静态字段,但不包括局部变量和方法参数,因为后者是线程私有的,不存在竞争问题。
JVM 试图定义一种统一的内存模型,能将各种底层硬件,以及操作系统的内存访问差异进行封装,使 Java 程序在不同硬件及操作系统上都能达到相同的并发效果。
JMM 的结构
JMM 分为主存储器Main Memory和工作存储器Working Memory两种。
主存储器是实例位置所在的区域,所有的实例都存在于主存储器内。比如,实例所拥有的字段即位于主存储器内,主存储器是所有的线程所共享的。
工作存储器是线程所拥有的作业区每个线程都有其专用的工作存储器。工作存储器存有主存储器中必要部分的拷贝称之为工作拷贝Working Copy
在这个模型中,线程无法对主存储器直接进行操作。如下图,线程 A 想要和线程 B 通信,只能通过主存进行交换。
那这些内存区域都是在哪存储的呢?如果非要有个对应的话,你可以认为主存中的内容是 Java 堆中的对象,而工作内存对应的是虚拟机栈中的内容。但实际上,主内存也可能存在于高速缓存,或者 CPU 的寄存器上;工作内存也可能存在于硬件内存中,我们不用太纠结具体的存储位置。
8 个 Action
操作类型
为了支持 JMMJava 定义了 8 种原子操作Action用来控制主存与工作内存之间的交互。
1read读取作用于主内存它把变量从主内存传动到线程的工作内存中供后面的 load 动作使用。
2load载入作用于工作内存它把 read 操作的值放入到工作内存中的变量副本中。
3store存储作用于工作内存它把工作内存中的一个变量传送给主内存中以备随后的 write 操作使用。
4write (写入)作用于主内存,它把 store 传送值放到主内存中的变量中。
5use使用作用于工作内存它把工作内存中的值传递给执行引擎每当虚拟机遇到一个需要使用这个变量的指令时将会执行这个动作。
6assign赋值作用于工作内存它把从执行引擎获取的值赋值给工作内存中的变量每当虚拟机遇到一个给变量赋值的指令时执行该操作。
7lock锁定作用于主内存把变量标记为线程独占状态。
8unlock解锁作用于主内存它将释放独占状态。
如上图所示,把一个变量从主内存复制到工作内存,就要顺序执行 read 和 load而把变量从工作内存同步回主内存就要顺序执行 store 和 write 操作。
三大特征
1原子性
JMM 保证了 read、load、assign、use、store 和 write 六个操作具有原子性,可以认为除了 long 和 double 类型以外,对其他基本数据类型所对应的内存单元的访问读写都是原子的。
如果想要一个颗粒度更大的原子性保证,就可以使用 lock 和 unlock 这两个操作。
2可见性
可见性是指当一个线程修改了共享变量的值,其他线程也能立即感知到这种变化。
我们从前面的图中可以看到,要保证这种效果,需要经历多次操作。一个线程对变量的修改,需要先同步给主内存,赶在另外一个线程的读取之前刷新变量值。
volatile、synchronized、final 和锁,都是保证可见性的方式。
这里要着重提一下 volatile因为它的特点最显著。使用了 volatile 关键字的变量,每当变量的值有变动时,都会把更改立即同步到主内存中;而如果某个线程想要使用这个变量,则先要从主存中刷新到工作内存上,这样就确保了变量的可见性。
而锁和同步关键字就比较好理解一些,它是把更多个操作强制转化为原子化的过程。由于只有一把锁,变量的可见性就更容易保证。
3有序性
Java 程序很有意思,从上面的 add 操作可以看出,如果在线程中观察,则所有的操作都是有序的;而如果在另一个线程中观察,则所有的操作都是无序的。
除了多线程这种无序性的观测,无序的产生还来源于指令重排。
指令重排序是 JVM 为了优化指令,来提高程序运行效率的,在不影响单线程程序执行结果的前提下,按照一定的规则进行指令优化。在某些情况下,这种优化会带来一些执行的逻辑问题,在并发执行的情况下,按照不同的逻辑会得到不同的结果。
我们可以看一下 Java 语言中默认的一些“有序”行为也就是先行发生happens-before原则这些可能在写代码的时候没有感知因为它是一种默认行为。
先行发生是一个非常重要的概念,如果操作 A 先行发生于操作 B那么操作 A 产生的影响能够被操作 B 感知到。
下面的原则是《Java 并发编程实践》这本书中对一些法则的描述。
程序次序:一个线程内,按照代码顺序,写在前面的操作先行发生于写在后面的操作。
监视器锁定unLock 操作先行发生于后面对同一个锁的 lock 操作。
volatile对一个变量的写操作先行发生于后面对这个变量的读操作。
传递规则:如果操作 A 先行发生于操作 B而操作 B 又先行发生于操作 C则可以得出操作 A 先行发生于操作 C。
线程启动:对线程 start() 的操作先行发生于线程内的任何操作。
线程中断:对线程 interrupt() 的调用先行发生于线程代码中检测到中断事件的发生,可以通过 Thread.interrupted() 方法检测是否发生中断。
线程终结规则:线程中的所有操作先行发生于检测到线程终止,可以通过 Thread.join()、Thread.isAlive() 的返回值检测线程是否已经终止。
对象终结规则:一个对象的初始化完成先行发生于它的 finalize() 方法的开始。
内存屏障
那我们上面提到这么多规则和特性,是靠什么保证的呢?
内存屏障Memory Barrier用于控制在特定条件下的重排序和内存可见性问题。JMM 内存屏障可分为读屏障和写屏障Java 的内存屏障实际上也是上述两种的组合完成一系列的屏障和数据同步功能。Java 编译器在生成字节码时,会在执行指令序列的适当位置插入内存屏障来限制处理器的重排序。
下面介绍一下这些组合。
Load-Load Barriers
保证 load1 数据的装载优先于 load2 以及所有后续装载指令的装载。对于 Load Barrier 来说,在指令前插入 Load Barrier可以让高速缓存中的数据失效强制重新从主内存加载数据。
load1
LoadLoad
load2
Load-Store Barriers
保证 load1 数据装载优先于 store2 以及后续的存储指令刷新到内存。
load1
LoadStore
store2
Store-Store Barriers
保证 store1 数据对其他处理器可见,优先于 store2 以及所有后续存储指令的存储。对于 Store Barrier 来说,在指令后插入 Store Barrier能让写入缓存中的最新数据更新写入主内存让其他线程可见。
store1
StoreStore
store
Store-Load Barriers
在 Load2 及后续所有读取操作执行前,保证 Store1 的写入对所有处理器可见。这条内存屏障指令是一个全能型的屏障,它同时具有其他 3 条屏障的效果,而且它的开销也是四种屏障中最大的一个。
store1
StoreLoad
load2
小结
好了,到这里我们已经简要地介绍完了 JMM 相关的知识点。前面提到过,“请谈一下 Java 的内存模型”这个面试题非常容易被误解,甚至很多面试官自己也不清楚这个概念。其实,如果我们把 JMM 叫作“Java 的并发内存模型”,会更容易理解。
这个时候,可以和面试官确认一下,问的是 Java 内存布局,还是和多线程相关的 JMM如果不是 JMM你就需要回答一下第 02 课时的相关知识了。
JMM 可以说是 Java 并发的基础,它的定义将直接影响多线程实现的机制,如果你想要深入了解多线程并发中的相关问题现象,对 JMM 的深入研究是必不可少的。

View File

@ -0,0 +1,240 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
20 动手实践:从字节码看并发编程的底层实现
本课时我们主要分享一个实践案例:从字节码看并发编程的底层实现。
我们在上一课时中简单学习了 JMM 的概念,知道了 Java 语言中一些默认的 happens-before 规则,是靠内存屏障完成的。其中的 lock 和 unlock 两个 Action就属于粒度最大的两个操作。
如下图所示Java 中的多线程,第一类是 Thread 类。它有三种实现方式:第 1 种是通过继承 Thread 覆盖它的 run 方法;第 2 种是通过 Runnable 接口,实现它的 run 方法;而第 3 种是通过创建线程,就是通过线程池的方法去创建。
多线程除了增加任务的执行速度,同样也有共享变量的同步问题。传统的线程同步方式,是使用 synchronized 关键字,或者 wait、notify 方法等,比如我们在第 15 课时中所介绍的,使用 jstack 命令可以观测到各种线程的状态。在目前的并发编程中,使用 concurrent 包里的工具更多一些。
线程模型
我们首先来看一下 JVM 的线程模型,以及它和操作系统进程之间的关系。
如下图所示,对于 Hotspot 来说,每一个 Java 线程都会映射到一条轻量级进程中LWPLight Weight Process。轻量级进程是用户进程调用系统内核所提供的一套接口实际上它还需要调用更加底层的内核线程KLTKernel-Level Thread。而具体的功能比如创建、同步等则需要进行系统调用。
这些系统调用的操作代价都比较高需要在用户态User Mode和内核态Kernel Mode中来回切换也就是我们常说的线程上下文切换 CSContext Switch
使用 vmstat 命令能够方便地观测到这个数值。
Java 在保证正确的前提下,要想高效并发,就要尽量减少上下文的切换。
一般有下面几种做法来减少上下文的切换:
CAS 算法,比如 Java 的 Atomic 类,如果使用 CAS 来更新数据,则不需要加锁;
减少锁粒度多线程竞争会引起上下文的频繁切换如果在处理数据的时候能够将数据分段即可减少竞争Java 的 ConcurrentHashMap、LongAddr 等就是这样的思路;
协程,在单线程里实现多任务调度,并在单线程里支持多个任务之间的切换;
对加锁的对象进行智能判断,让操作更加轻量级。
CAS 和无锁并发一般是建立在 concurrent 包里面的 AQS 模型之上,大多数属于 Java 语言层面上的知识点。本课时在对其进行简单的描述后,会把重点放在普通锁的优化上。
CAS
CASCompare And Swap比较并替换机制中使用了 3 个基本操作数:内存地址 V、旧的预期值 A 和要修改的新值 B。更新一个变量时只有当变量的预期值 A 和内存地址 V 当中的实际值相同时,才会将内存地址 V 对应的值修改为 B。
如果修改不成功CAS 将不断重试。
拿 AtomicInteger 类来说,相关的代码如下:
public final boolean compareAndSet(int expectedValue, int newValue) {
return U.compareAndSetInt(this, VALUE, expectedValue, newValue);
}
可以看到,这个操作,是由 jdk.internal.misc.Unsafe 类进行操作的,而这是一个 native 方法:
@HotSpotIntrinsicCandidate
public final native boolean compareAndSetInt(Object o, long offset,
int expected,
int x);
我们继续向下跟踪,在 Linux 机器上参照 os_cpu/linux_x86/atomic_linux_x86.hpp
template<>
template<typename T>
inline T Atomic::PlatformCmpxchg<4>::operator()(T exchange_value,
T volatile* dest,
T compare_value,
atomic_memory_order /* order */) const {
STATIC_ASSERT(4 == sizeof(T));
__asm__ volatile ("lock cmpxchgl %1,(%3)"
: "=a" (exchange_value)
: "r" (exchange_value), "a" (compare_value), "r" (dest)
: "cc", "memory");
return exchange_value;
}
可以看到,最底层的调用是汇编语言,而最重要的就是 cmpxchgl 指令,到这里没法再往下找代码了,也就是说 CAS 的原子性实际上是硬件 CPU 直接实现的。
synchronized
字节码
synchronized 可以在是多线程中使用的最多的关键字了。在开始介绍之前,请思考一个问题:在执行速度方面,是基于 CAS 的 Lock 效率高一些,还是同步关键字效率高一些?
synchronized 关键字给代码或者方法上锁时,会有显示或者隐藏的上锁对象。当一个线程试图访问同步代码块时,它必须先得到锁,而在退出或抛出异常时必须释放锁。
给普通方法加锁时,上锁的对象是 this如代码中的方法 m1 。
给静态方法加锁时,锁的是 class 对象,如代码中的方法 m2 。
给代码块加锁时,可以指定一个具体的对象。
关于对象对锁的争夺,我们依然拿前面讲的一张图来看一下这个过程。
下面我们来看一段简单的代码,并观测一下它的字节码。
public class SynchronizedDemo {
synchronized void m1() {
System.out.println("m1");
}
static synchronized void m2() {
System.out.println("m2");
}
final Object lock = new Object();
void doLock() {
synchronized (lock) {
System.out.println("lock");
}
}
}
下面是普通方法 m1 的字节码。
synchronized void m1();
descriptor: ()V
flags: ACC_SYNCHRONIZED
Code:
stack=2, locals=1, args_size=1
0: getstatic #4
3: ldc #5
5: invokevirtual #6
8: return
可以看到,在字节码的体现上,它只给方法加了一个 flagACC_SYNCHRONIZED。
静态方法 m2 和 m1 区别不大,只不过 flags 上多了一个参数ACC_STATIC。
相比较起来doLock 方法就麻烦了一些,其中出现了 monitorenter 和 monitorexit 等字节码指令。
void doLock();
descriptor: ()V
flags:
Code:
stack=2, locals=3, args_size=1
0: aload_0
1: getfield #3 // Field lock:Ljava/lang/Object;
4: dup
5: astore_1
6: monitorenter
7: getstatic #4 // Field java/lang/System.out:Ljava/io/PrintStream;
10: ldc #8 // String lock
12: invokevirtual #6 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
15: aload_1
16: monitorexit
17: goto 25
20: astore_2
21: aload_1
22: monitorexit
23: aload_2
24: athrow
25: return
Exception table:
from to target type
7 17 20 any
20 23 20 any
很多人都认为synchronized 是一种悲观锁、一种重量级锁;而基于 CAS 的 AQS 是一种乐观锁这种理解并不全对。JDK1.6 之后JVM 对同步关键字进行了很多的优化,这把锁有了不同的状态,大多数情况下的效率,已经和 concurrent 包下的 Lock 不相上下了,甚至更高。
对象内存布局
说到 synchronized 加锁原理,就不得不先说 Java 对象在内存中的布局Java 对象内存布局如下图所示。
我来分别解释一下各个部分的含义。
Mark Word用来存储 hashCode、GC 分代年龄、锁类型标记、偏向锁线程 ID、CAS 锁指向线程 LockRecord 的指针等synconized 锁的机制与这里密切相关,这有点像 TCP/IP 中的协议头。
Class Pointer用来存储对象指向它的类元数据指针、JVM 就是通过它来确定是哪个 Class 的实例。
Instance Data存储的是对象真正有效的信息比如对象中所有字段的内容。
PaddingHostSpot 规定对象的起始地址必须是 8 字节的整数倍,这是为了高效读取对象而做的一种“对齐”操作。
可重入锁
synchronized 是一把可重入锁。因此,在一个线程使用 synchronized 方法时可以调用该对象的另一个 synchronized 方法,即一个线程得到一个对象锁后再次请求该对象锁,是可以永远拿到锁的。
Java 中线程获得对象锁的操作是以线程而不是以调用为单位的。synchronized 锁的对象头的 Mark Work 中会记录该锁的线程持有者和计数器。当一个线程请求成功后JVM 会记下持有锁的线程,并将计数器计为 1 。此时如果有其他线程请求该锁,则必须等待。而该持有锁的线程如果再次请求这个锁,就可以再次拿到这个锁,同时计数器会递增。当线程退出一个 synchronized 方法/块时,计数器会递减,如果计数器为 0 则释放该锁。
锁升级
根据使用情况,锁升级大体可以按照下面的路径:偏向锁→轻量级锁→重量级锁,锁只能升级不能降级,所以一旦锁升级为重量级锁,就只能依靠操作系统进行调度。
我们再看一下 Mark Word 的结构。其中Biased 有 1 bit 大小Tag 有 2 bit 大小,锁升级就是通过 Thread Id、Biased、Tag 这三个变量值来判断的。
偏向锁
偏向锁,其实是一把偏心锁(一般不这么描述)。在 JVM 中,当只有一个线程使用了锁的情况下,偏向锁才能够保证更高的效率。
当第 1 个线程第一次访问同步块时,会先检测对象头 Mark Word 中的标志位Tag是否为 01以此来判断此时对象锁是否处于无锁状态或者偏向锁状态匿名偏向锁
这也是锁默认的状态,线程一旦获取了这把锁,就会把自己的线程 ID 写到 Mark Word 中,在其他线程来获取这把锁之前,该线程都处于偏向锁状态。
轻量级锁
当下一个线程参与到偏向锁竞争时,会先判断 Mark Word 中保存的线程 ID 是否与这个线程 ID 相等,如果不相等,则会立即撤销偏向锁,升级为轻量级锁。
轻量级锁的获取是怎么进行的呢?它们使用的是自旋方式。
参与竞争的每个线程,会在自己的线程栈中生成一个 LockRecord ( LR ),然后每个线程通过 CAS自旋的操作将锁对象头中的 Mark Work 设置为指向自己的 LR 指针哪个线程设置成功就意味着哪个线程获得锁。在这种情况下JVM 不会依赖内核进行线程调度。
当锁处于轻量级锁的状态时,就不能够再通过简单的对比 Tag 值进行判断了,每次对锁的获取,都需要通过自旋的操作。
当然自旋也是面向不存在锁竞争的场景比如一个线程运行完了另外一个线程去获取这把锁。但如果自旋失败达到一定的次数JVM 自动管理)时,就会膨胀为重量级锁。
重量级锁
重量级锁即为我们对 synchronized 的直观认识,在这种情况下,线程会挂起,进入到操作系统内核态,等待操作系统的调度,然后再映射回用户态。系统调用是昂贵的,重量级锁的名称也由此而来。
如果系统的共享变量竞争非常激烈,那么锁会迅速膨胀到重量级锁,这些优化也就名存实亡了。如果并发非常严重,则可以通过参数 -XX:-UseBiasedLocking 禁用偏向锁。这种方法在理论上会有一些性能提升,但实际上并不确定。
因为synchronized 在 JDK包括一些框架代码中的应用是非常广泛的。在一些不需要同步的场景中即使加上了 synchronized 关键字,由于锁升级的原因,效率也不会太差。
下面这张图展示了三种锁的状态和 Mark Word 值的变化。
小结
在本课时中,我们首先介绍了多线程的一些特点,然后熟悉了 Java 中的线程和它在操作系统中的一些表现形式;还了解了,线程上下文切换会严重影响系统的性能,所以 Java 的锁有基于硬件 CAS 自旋,也有基于比较轻量级的“轻量级锁”和“偏向锁”。
它们的目标是,在不改变编程模型的基础上,尽量提高系统的性能,进行更加高效的并发。

View File

@ -0,0 +1,459 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
21 动手实践:不为人熟知的字节码指令
本课时我们主要分享一个实践案例:不为人熟知的字节码指令。
下面将通过介绍 Java 语言中的一些常见特性,来看一下字节码的应用,由于 Java 特性非常多这里我们仅介绍一些经常遇到的特性。javap 是手中的利器,复杂的概念都可以在这里现出原形,并且能让你对此产生深刻的印象。
本课时代码比较多,相关代码示例都可以在仓库中找到,建议实际操作一下。
异常处理
在上一课时中,细心的你可能注意到了,在 synchronized 生成的字节码中,其实包含两条 monitorexit 指令,是为了保证所有的异常条件,都能够退出。
这就涉及到了 Java 字节码的异常处理机制,如下图所示。
如果你熟悉 Java 语言那么对上面的异常继承体系一定不会陌生其中Error 和 RuntimeException 是非检查型异常Unchecked Exception也就是不需要 catch 语句去捕获的异常;而其他异常,则需要程序员手动去处理。
异常表
在发生异常的时候Java 就可以通过 Java 执行栈,来构造异常栈。回想一下第 02 课时中的栈帧,获取这个异常栈只需要遍历一下它们就可以了。
但是这种操作比起常规操作要昂贵的多。Java 的 Log 日志框架,通常会把所有错误信息打印到日志中,在异常非常多的情况下,会显著影响性能。
我们还是看一下上一课时生成的字节码:
void doLock();
descriptor: ()V
flags:
Code:
stack=2, locals=3, args_size=1
0: aload_0
1: getfield #3 // Field lock:Ljava/lang/Object;
4: dup
5: astore_1
6: monitorenter
7: getstatic #4 // Field java/lang/System.out:Ljava/io/PrintStream;
10: ldc #8 // String lock
12: invokevirtual #6 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
15: aload_1
16: monitorexit
17: goto 25
20: astore_2
21: aload_1
22: monitorexit
23: aload_2
24: athrow
25: return
Exception table:
from to target type
7 17 20 any
20 23 20 any
可以看到,编译后的字节码,带有一个叫 Exception table 的异常表,里面的每一行数据,都是一个异常处理器:
from 指定字节码索引的开始位置
to 指定字节码索引的结束位置
target 异常处理的起始位置
type 异常类型
也就是说,只要在 from 和 to 之间发生了异常,就会跳转到 target 所指定的位置。
finally
通常我们在做一些文件读取的时候,都会在 finally 代码块中关闭流,以避免内存的溢出。关于这个场景,我们再分析一下下面这段代码的异常表。
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
public class A {
public void read() {
InputStream in = null;
try {
in = new FileInputStream("A.java");
} catch (FileNotFoundException e) {
e.printStackTrace();
} finally {
if (null != in) {
try {
in.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
}
上面的代码,捕获了一个 FileNotFoundException 异常,然后在 finally 中捕获了 IOException 异常。当我们分析字节码的时候却发现了一个有意思的地方IOException 足足出现了三次。
Exception table:
from to target type
17 21 24 Class java/io/IOException
2 12 32 Class java/io/FileNotFoundException
42 46 49 Class java/io/IOException
2 12 57 any
32 37 57 any
63 67 70 Class java/io/IOException
Java 编译器使用了一种比较傻的方式来组织 finally 的字节码,它分别在 try、catch 的正常执行路径上,复制一份 finally 代码,追加在 正常执行逻辑的后面;同时,再复制一份到其他异常执行逻辑的出口处。
这也是下面这段方法不报错的原因,都可以在字节码中找到答案。
//B.java
public int read() {
try {
int a = 1 / 0;
return a;
} finally {
return 1;
}
}
下面是上面程序的字节码,可以看到,异常之后,直接跳转到序号 8 了。
stack=2, locals=4, args_size=1
0: iconst_1
1: iconst_0
2: idiv
3: istore_1
4: iload_1
5: istore_2
6: iconst_1
7: ireturn
8: astore_3
9: iconst_1
10: ireturn
Exception table:
from to target type
0 6 8 any
装箱拆箱
在刚开始学习 Java 语言的你可能会被自动装箱和拆箱搞得晕头转向。Java 中有 8 种基本类型,但鉴于 Java 面向对象的特点,它们同样有着对应的 8 个包装类型,比如 int 和 Integer包装类型的值可以为 null很多时候它们都能够相互赋值。
我们使用下面的代码从字节码层面上来观察一下:
public class Box {
public Integer cal() {
Integer a = 1000;
int b = a * 10;
return b;
}
}
上面是一段简单的代码,首先使用包装类型,构造了一个值为 1000 的数字,然后乘以 10 后返回,但是中间的计算过程,使用了普通类型 int。
public java.lang.Integer read();
descriptor: ()Ljava/lang/Integer;
flags: ACC_PUBLIC
Code:
stack=2, locals=3, args_size=1
0: sipush 1000
3: invokestatic #2 // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
6: astore_1
7: aload_1
8: invokevirtual #3 // Method java/lang/Integer.intValue:()I
11: bipush 10
13: imul
14: istore_2
15: iload_2
16: invokestatic #2 // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
19: areturn
通过观察字节码,我们发现赋值操作使用的是 Integer.valueOf 方法,在进行乘法运算的时候,调用了 Integer.intValue 方法来获取基本类型的值。在方法返回的时候,再次使用了 Integer.valueOf 方法对结果进行了包装。
这就是 Java 中的自动装箱拆箱的底层实现。
但这里有一个 Java 层面的陷阱问题,我们继续跟踪 Integer.valueOf 方法。
@HotSpotIntrinsicCandidate
public static Integer valueOf(int i) {
if (i >= IntegerCache.low && i <= IntegerCache.high)
return IntegerCache.cache[i + (-IntegerCache.low)];
return new Integer(i);
}
这个 IntegerCache缓存了 low 和 high 之间的 Integer 对象,可以通过 -XX:AutoBoxCacheMax 来修改上限。
下面是一道经典的面试题,请考虑一下运行代码后,会输出什么结果?
public class BoxCacheError{
public static void main(String[] args) {
Integer n1 = 123;
Integer n2 = 123;
Integer n3 = 128;
Integer n4 = 128;
System.out.println(n1 == n2);
System.out.println(n3 == n4);
}
当我使用 java BoxCacheError 执行时,是 true,false当我加上参数 java -XX:AutoBoxCacheMax=256 BoxCacheError 执行时,结果是 true,ture原因就在于此。
数组访问
我们都知道,在访问一个数组长度的时候,直接使用它的属性 .length 就能获取,而在 Java 中却无法找到对于数组的定义。
比如 int[] 这种类型,通过 getClassgetClass 是 Object 类中的方法)可以获取它的具体类型是 [I。
其实,数组是 JVM 内置的一种对象类型,这个对象同样是继承的 Object 类。
我们使用下面一段代码来观察一下数组的生成和访问。
public class ArrayDemo {
int getValue() {
int[] arr = new int[]{
1111, 2222, 3333, 4444
};
return arr[2];
}
int getLength(int[] arr) {
return arr.length;
}
}
首先看一下 getValue 方法的字节码。
int getValue();
descriptor: ()I
flags:
Code:
stack=4, locals=2, args_size=1
0: iconst_4
1: newarray int
3: dup
4: iconst_0
5: sipush 1111
8: iastorae
9: dup
10: iconst_1
11: sipush 2222
14: iastore
15: dup
16: iconst_2
17: sipush 3333
20: iastore
21: dup
22: iconst_3
23: sipush 4444
26: iastore
27: astore_1
28: aload_1
29: iconst_2
30: iaload
31: ireturn
可以看到,新建数组的代码,被编译成了 newarray 指令。数组里的初始内容,被顺序编译成了一系列指令放入:
sipush 将一个短整型常量值推送至栈顶;
iastore 将栈顶 int 型数值存入指定数组的指定索引位置。
为了支持多种类型从操作数栈存储到数组有更多的指令bastore、castore、sastore、iastore、lastore、fastore、dastore、aastore。
数组元素的访问,是通过第 28 ~ 30 行代码来实现的:
aload_1 将第二个引用类型本地变量推送至栈顶,这里是生成的数组;
iconst_2 将 int 型 2 推送至栈顶;
iaload 将 int 型数组指定索引的值推送至栈顶。
值得注意的是,在这段代码运行期间,有可能会产生 ArrayIndexOutOfBoundsException但由于它是一种非捕获型异常我们不必为这种异常提供异常处理器。
我们再看一下 getLength 的字节码,字节码如下:
int getLength(int[]);
descriptor: ([I)I
flags:
Code:
stack=1, locals=2, args_size=2
0: aload_1
1: arraylength
2: ireturn
可以看到,获取数组的长度,是由字节码指令 arraylength 来完成的。
foreach
无论是 Java 的数组,还是 List都可以使用 foreach 语句进行遍历,比较典型的代码如下:
import java.util.List;
public class ForDemo {
void loop(int[] arr) {
for (int i : arr) {
System.out.println(i);
}
}
void loop(List<Integer> arr) {
for (int i : arr) {
System.out.println(i);
}
}
虽然在语言层面它们的表现形式是一致的,但实际实现的方法并不同。我们先看一下遍历数组的字节码:
void loop(int[]);
descriptor: ([I)V
flags:
Code:
stack=2, locals=6, args_size=2
0: aload_1
1: astore_2
2: aload_2
3: arraylength
4: istore_3
5: iconst_0
6: istore 4
8: iload 4
10: iload_3
11: if_icmpge 34
14: aload_2
15: iload 4
17: iaload
18: istore 5
20: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
23: iload 5
25: invokevirtual #3 // Method java/io/PrintStream.println:(I)V
28: iinc 4, 1
31: goto 8
34: return
可以很容易看到,它将代码解释成了传统的变量方式,即 for(int i;i 的形式。
而 List 的字节码如下:
void loop(java.util.List<java.lang.Integer>);
Code:
0: aload_1
1: invokeinterface #4, 1 // InterfaceMethod java/util/List.iterator:()Ljava/util/Iterator;
6: astore_2-
7: aload_2
8: invokeinterface #5, 1 // InterfaceMethod java/util/Iterator.hasNext:()Z
13: ifeq 39
16: aload_2
17: invokeinterface #6, 1 // InterfaceMethod java/util/Iterator.next:()Ljava/lang/Object;
22: checkcast #7 // class java/lang/Integer
25: invokevirtual #8 // Method java/lang/Integer.intValue:()I
28: istore_3
29: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
32: iload_3
33: invokevirtual #3 // Method java/io/PrintStream.println:(I)V
36: goto 7
39: return
它实际是把 list 对象进行迭代并遍历的,在循环中,使用了 Iterator.next() 方法。
使用 jd-gui 等反编译工具,可以看到实际生成的代码:
void loop(List<Integer> paramList) {
for (Iterator<Integer> iterator = paramList.iterator(); iterator.hasNext(); ) {
int i = ((Integer)iterator.next()).intValue();
System.out.println(i);
}
}
注解
注解在 Java 中得到了广泛的应用Spring 框架更是由于注解的存在而起死回生。注解在开发中的作用就是做数据约束和标准定义,可以将其理解成代码的规范标准,并帮助我们写出方便、快捷、简洁的代码。
那么注解信息是存放在哪里的呢?我们使用两个 Java 文件来看一下其中的一种情况。
MyAnnotation.java
public @interface MyAnnotation {
}
AnnotationDemo
@MyAnnotation
public class AnnotationDemo {
@MyAnnotation
public void test(@MyAnnotation int a){
}
}
下面我们来看一下字节码信息。
{
public AnnotationDemo();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
LineNumberTable:
line 2: 0
public void test(int);
descriptor: (I)V
flags: ACC_PUBLIC
Code:
stack=0, locals=2, args_size=2
0: return
LineNumberTable:
line 6: 0
RuntimeInvisibleAnnotations:
0: #11()
RuntimeInvisibleParameterAnnotations:
0:
0: #11()
}
SourceFile: "AnnotationDemo.java"
RuntimeInvisibleAnnotations:
0: #11()
可以看到,无论是类的注解,还是方法注解,都是由一个叫做 RuntimeInvisibleAnnotations 的结构来存储的,而参数的存储,是由 RuntimeInvisibleParameterAnotations 来保证的。
小结
本课时我们简单介绍了一下工作中常见的一些问题并从字节码层面分析了它的原理包括异常的处理、finally 块的执行顺序;以及隐藏的装箱拆箱和 foreach 语法糖的底层实现。
由于 Java 的特性非常多,这里不再一一列出,但都可以使用这种简单的方式,一窥究竟。可以认为本课时属于抛砖引玉,给出了一种学习思路。
另外,也可以对其中的性能和复杂度进行思考。可以注意到,在隐藏的装箱拆箱操作中,会造成很多冗余的字节码指令生成。那么,这个东西会耗性能吗?答案是肯定的,但是也不必纠结于此。
你所看到的字节码指令可能洋洋洒洒几千行看起来很吓人但执行速度几乎都是纳秒级别的。Java 的无数框架,包括 JDK也不会为了优化这种性能对代码进行限制。了解其原理但不要舍本逐末比如减少一次 Java 线程的上下文切换,就比你优化几千个装箱拆箱动作,来的更快捷一些。

View File

@ -0,0 +1,238 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
22 深入剖析:如何使用 Java Agent 技术对字节码进行修改
本课时我们主要分析如何使用 Java Agent 技术对字节码进行修改。
Java 5 版本以后JDK 有一个包叫做 instrument ,能够实现一些非常酷的功能,市面上一些 APM 工具,就是通过它来进行的增强,这个功能对于业务开发者来说,是比较偏门的。但你可能在无意中已经用到它了,比如 Jrebel 酷炫的热部署功能(这个工具能够显著增加开发效率)。
本课时将以一个例子来看一下具体的应用场景然后介绍一个在线上常用的问题排查工具Arthas。
Java Agent 介绍
我们上面说的这些工具的基础,就是 Java Agent 技术,可以利用它来构建一个附加的代理程序,用来协助检测性能,还可以替换一些现有功能,甚至 JDK 的一些类我们也能修改,有点像 JVM 级别的 AOP 功能。
通常Java 入口是一个 main 方法,这是毋庸置疑的,而 Java Agent 的入口方法叫做 premain表明是在 main 运行之前的一些操作。Java Agent 就是这样的一个 jar 包,定义了一个标准的入口方法,它并不需要继承或者实现任何其他的类,属于无侵入的一种开发模式。
为什么叫 premain这是一个约定并没有什么其他的理由这个方法无论是第一次加载还是每次新的 ClassLoader 加载,都会执行。
我们可以在这个前置的方法里,对字节码进行一些修改,来增加功能或者改变代码的行为,这种方法没有侵入性,只需要在启动命令中加上 -javaagent 参数就可以了。Java 6 以后,甚至可以通过 attach 的方式,动态的给运行中的程序设置加载代理类。
其实instrument 一共有两个 main 方法,一个是 premain另一个是 agentmain但在一个 JVM 中,只会调用一个;前者是 main 执行之前的修改后者是控制类运行时的行为。它们还是有一些区别的agentmain 因为能够动态修改大部分代码,比较危险,限制会更大一些。
有什么用
获取统计信息
在许多 APM 产品里,比如 Pinpoint、SkyWalking 等,就是使用 Java Agent 对代码进行的增强。通过在方法执行前后动态加入的统计代码,来进行监控信息的收集;通过兼容 OpenTracing 协议,可以实现分布式链路追踪的功能。
它的原理类似于 AOP最终以字节码的形式存在性能损失取决于你的代码逻辑。
热部署
通过自定义的 ClassLoader可以实现代码的热替换。使用 agentmain实现热部署功能会更加便捷通过 agentmain 获取到 Instrumentation 以后,就可以对类进行动态重定义了。
诊断
配合 JVMTI 技术,可以 attach 到某个进程进行运行时的统计和调试,比较流行的 btrace 和 arthas ,其底层就是这种技术。
代码示例
要构建一个 agent 程序,大体可分为以下步骤:
使用字节码增强工具,编写增强代码;
在 manifest 中指定 Premain-Class/Agent-Class 属性;
使用参数加载或者使用 attach 方式。
我们来详细介绍一下这个过程。
编写 Agent
Java Agent 最终的体现方式是一个 jar 包,使用 IDEA 创建一个默认的 maven 工程即可。
创建一个普通的 Java 类,添加 premain 或者 agentmain 方法,它们的参数完全一样。
编写 Transformer
实际的代码逻辑需要实现 ClassFileTransformer 接口。假如我们要统计某个方法的执行时间,使用 JavaAssist 工具来增强字节码,则可以通过以下代码来实现:
获取 MainRun 类的字节码实例;
获取 hello 方法的字节码实例;
在方法前后,加入时间统计,首先定义变量 _begin然后追加要写的代码。
别忘了加入 maven 依赖,我们借用 javassist 完成字节码增强:
<dependency>
<groupId>org.javassist</groupId>
<artifactId>javassist</artifactId>
<version>3.24.1-GA</version>
</dependency>
字节码增强也可以使用 Cglib、ASM 等其他工具。
MANIFEST.MF 文件
那么我们编写的代码是如何让外界知晓的呢?那就是依靠 MANIFEST.MF 文件,具体路径在
src/main/resources/META-INF/MANIFEST.MF
Manifest-Version: 1.0
premain-class: com.sayhiai.example.javaagent.AgentApp
一般的maven 打包会覆盖这个文件,所以我们需要为它指定一个。
<build><plugins><plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<configuration>
<archive>
<manifestFile>src/main/resources/META-INF/MANIFEST.MF</manifestFile>
</archive>
</configuration></plugin></plugins></build>
然后,在命令行,执行 mvn install 安装到本地代码库,或者使用 mvn deploy 发布到私服上。
附 MANIFEST.MF 参数清单:
Premain-Class
Agent-Class
Boot-Class-Path
Can-Redefine-Classes
Can-Retransform-Classes
Can-Set-Native-Method-Prefix
使用
使用方式取决于你使用的 premain 还是 agentmain它们之间有一些区别具体如下。
premain
在我们的例子中,直接在启动命令行中加入参数即可,在 jvm 启动时启用代理。
java -javaagent:agent.jar MainRun
在 IDEA 中,可以将参数附着在 jvm options 里。
接下来看一下测试代码。
这是我们的执行类,执行后,直接输出 hello world。通过增强以后还额外的输出了执行时间以及一些 debug 信息。其中debug 信息在 main 方法执行之前输出。
agentmain
这种模式一般用在一些诊断工具上。使用 jdk/lib/tools.jar 中的工具类,可以动态的为运行中的程序加入一些功能。它的主要运行步骤如下:
获取机器上运行的所有 JVM 进程 ID
选择要诊断的 jvm
将 jvm 使用 attach 函数链接上;
使用 loadAgent 函数加载 agent动态修改字节码
卸载 jvm。
代码样例如下:
import com.sun.tools.attach.VirtualMachine;
import com.sun.tools.attach.VirtualMachineDescriptor;
import java.util.List;
public class JvmAttach {
public static void main(String[] args)
throws Exception {
List<VirtualMachineDescriptor> list = VirtualMachine.list();
for (VirtualMachineDescriptor vmd : list) {
if (vmd.displayName().endsWith("MainRun")) {
VirtualMachine virtualMachine = VirtualMachine.attach(vmd.id());
virtualMachine.loadAgent("test.jar ", "...");
//.....
virtualMachine.detach();
}
}
}
这些代码功能虽然强大,但都是比较危险的,这就是为什么 Btrace 说了这么多年还是只在小范围内被小心的使用。相对来说Arthas 显的友好而且安全的多。
使用注意点
1jar 包依赖方式
一般Agent 的 jar 包会以 fatjar 的方式提供,即将所有的依赖打包到一个大的 jar 包中。如果你的功能复杂、依赖多,那么这个 jar 包将会特别的大。
使用独立的 bom 文件维护这些依赖是另外一种方法。使用方自行管理依赖问题,但这通常会发生一些找不到 jar 包的错误,更糟糕的是,大多数在运行时才发现。
2类名称重复
不要使用和 jdk 及 instrument 包中相同的类名(包括包名),有时候你能够侥幸过关,但也会陷入无法控制的异常中。
3做有限的功能
可以看到,给系统动态的增加功能是非常酷的,但大多数情况下非常耗费性能。你会发现,一些简单的诊断工具,会占用你 1 核的 CPU这是很平常的事情。
4ClassLoader
如果你用的 JVM 比较旧,频繁地生成大量的代理类,会造成元空间的膨胀,容易发生内存占用问题。
ClassLoader 有双亲委派机制,如果你想要替换相应的类,一定要搞清楚它的类加载器应该用哪个,否则替换的类,是不生效的。
具体的调试方法,可以在 Java 进程启动时,加入 -verbose:class 参数,用来监视引用程序对类的加载。
Arthas
我们来回顾一下在故障排查时所做的一些准备和工具支持。
在第 09 课时,我们了解了 jstat 工具,还有 jmap 等查看内存状态的工具;第 11 课时,介绍了超过 20 个工具的使用,这需要开发和分析的人员具有较高的水平;第 15 课时,还介绍了 jstack 的一些典型状态。对于这种瞬时态问题的分析,需要综合很多工具,对刚进入这个行业的人来说,很不友好。
Arthas 就是使用 Java Agent 技术编写的一个工具,具体采用的方式,就是我们上面提到的 attach 方式,它会无侵入的 attach 到具体的执行进程上,方便进行问题分析。
你甚至可以像 debug 本地的 Java 代码一样,观测到方法执行的参数值,甚至做一些统计分析。这通常可以解决下面的问题:
哪个线程使用了最多的 CPU
运行中是否有死锁,是否有阻塞
如何监测一个方法哪里耗时最高
追加打印一些 debug 信息
监测 JVM 的实时运行状态
Arthas 官方文档十分详细,也可以点击这里参考。
但无论工具如何强大,一些基础知识是需要牢固掌握的,否则,工具中出现的那些术语,也会让人一头雾水。
工具常变,但基础更加重要。如果你想要一个适应性更强的技术栈,还是要多花点时间在原始的排查方法上。
小结
本课时介绍了开发人员极少接触的 Java Agent 技术,但在平常的工作中你可能不知不觉就用到它了。在平常的面试中,一些面试官也会经常问一些相关的问题,以此来判断你对整个 Java 体系的掌握程度,如果你能回答上来,说明你已经脱颖而出了。
值得注意的是,这个知识点,对于做基础架构(比如中间件研发)的人来说,是必备技能,如果不了解,那面试可能就要凉了。
从实用角度来说,阿里开源的 Arthas 工具,是非常好用的,如果你有线上的运维权限,不妨尝试一下。

View File

@ -0,0 +1,286 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
23 动手实践JIT 参数配置如何影响程序运行?
本课时我们主要分享一个实践案例JIT 参数配置是如何影响程序运行的。
我们在前面的课时中介绍了很多字节码指令,这也是 Java 能够跨平台的保证。程序在运行的时候,这些指令会按照顺序解释执行,但是,这种解释执行的方式是非常低效的,它需要把字节码先翻译成机器码,才能往下执行。另外,字节码是 Java 编译器做的一次初级优化,许多代码可以满足语法分析,但还有很大的优化空间。
所以为了提高热点代码的执行效率在运行时虚拟机将会把这些代码编译成与本地平台相关的机器码并进行各种层次的优化。完成这个任务的编译器就称为即时编译器Just In Time Compiler简称 JIT 编译器。
热点代码,就是那些被频繁调用的代码,比如调用次数很高或者在 for 循环里的那些代码。这些再次编译后的机器码会被缓存起来,以备下次使用,但对于那些执行次数很少的代码来说,这种编译动作就纯属浪费。
在第 14 课时我们提到了参数“-XX:ReservedCodeCacheSize”用来限制 CodeCache 的大小。也就是说JIT 编译后的代码都会放在 CodeCache 里。
如果这个空间不足JIT 就无法继续编译编译执行会变成解释执行性能会降低一个数量级。同时JIT 编译器会一直尝试去优化代码,从而造成了 CPU 占用上升。
JITWatch
在开始之前,我们首先介绍一个观察 JIT 执行过程的图形化工具JITWatch这个工具非常好用可以解析 JIT 的日志并友好地展示出来。项目地址请点击这里查看。
下载之后,进入解压目录,执行 ant 即可编译出执行文件。
产生 JIT 日志
我们观察下面的一段代码,这段代码没有什么意义,而且写得很烂。在 test 函数中循环 cal 函数 1 千万次,在 cal 函数中,还有一些冗余的上锁操作和赋值操作,这些操作在解释执行的时候,会加重 JVM 的负担。
public class JITDemo {
Integer a = 1000;
public void setA(Integer a) {
this.a = a; }
public Integer getA() {
return this.a;
}
public Integer cal(int num) {
synchronized (new Object()) {
Integer a = getA();
int b = a * 10;
b = a * 100;
return b + num;
}
}
public int test() {
synchronized (new Object()) {
int total = 0;
int count = 100_000_00;
for (int i = 0; i < count; i++) {
total += cal(i);
if (i % 1000 == 0) {
System.out.println(i * 1000);
}
}
return total;
}
}
public static void main(String[] args) {
JITDemo demo = new JITDemo();
int total = demo.test();
在方法执行的时候,我们加上一系列参数,用来打印 JIT 最终生成的机器码,执行命令如下所示:
$JAVA_HOME_13/bin/java -server -XX:+UnlockDiagnosticVMOptions -XX:+TraceClassLoading -XX:+PrintAssembly -XX:+LogCompilation -XX:LogFile=jitdemo.log JITDemo
执行的过程,会输入到 jitdemo.log 文件里,接下来我们分析这个文件。
使用
单击 open log 按钮,打开我们生成的日志文件。
单击 config 按钮,加入要分析的源代码目录和字节码目录。确认后,单击 start 按钮进行分析。
在右侧找到我们的 test 方法,聚焦光标后,将弹出我们要分析的主要界面。
在同一个界面上,我们能够看到源代码、字节码、机器码的对应关系。在右上角,还有 C2/OSR/Level4 这样的字样,可以单击切换。
单击上图中的 Chain 按钮,还会弹出一个依赖链界面,该界面显示了哪些方法已经被编译了、哪些被内联、哪些是通过普通的方法调用运行的。
使用 JITWatch 可以看到,调用了 1 千万次的 for 循环代码,已经被 C2 进行编译了。
编译层次
HotSpot 虚拟机包含多个即时编译器,有 C1、C2 和 Graal采用的是分层编译的模式。使用 jstack 获得的线程信息,经常能看到它们的身影。
实验性质的 Graal 可以通过追加 JVM 参数进行开启,命令行如下:
$JAVA_HOME_13/bin/java -server -XX:+UnlockDiagnosticVMOptions -XX:+TraceClassLoading
-XX:+PrintAssembly -XX:+LogCompilation -XX:+UnlockExperimentalVMOptions
-XX:+UseJVMCICompiler -XX:LogFile=jitdemo.log JITDemo
不同层次的编译器会产生不一样的效果,机器码也会不同,我们仅看 C1、C2 的一些特点。
JIT 编译方式有两种:一种是编译方法,另一种是编译循环。分层编译将 JVM 的执行状态分为了五个层次:
字节码的解释执行;
执行不带 profiling 的 C1 代码;
执行仅带方法调用次数,以及循环执行次数 profiling 的 C1 代码;
执行带所有 profiling 的 C1 代码;
执行 C2 代码。
其中profiling 指的是运行时的程序执行状态数据比如循环调用的次数、方法调用的次数、分支跳转次数、类型转换次数等。JDK 中的 hprof 工具就是一种 profiler。
在不启用分层编译的情况下,当方法的调用次数和循环回边的次数总和,超过由参数 -XX:CompileThreshold 指定的阈值时,便会触发即时编译;当启用分层编译时,这个参数将会失效,会采用动态调整的方式进行。
常见的优化方法有以下几种:
公共子表达式消除
数组范围检查消除
方法内联
逃逸分析
我们重点看一下方法内联和逃逸分析。
方法内联
在第 17 课时里,我们可以看到方法调用的开销是比较大的,尤其是在调用量非常大的情况下。拿简单的 getter/setter 方法来说,这种方法在 Java 代码中大量存在,我们在访问的时候,需要创建相应的栈帧,访问到需要的字段后,再弹出栈帧,恢复原程序的执行。
如果能够把这些对象的访问和操作,纳入到目标方法的调用范围之内,就少了一次方法调用,速度就能得到提升,这就是方法内联的概念。
C2 编译器会在解析字节码的过程中完成方法内联。内联后的代码和调用方法的代码,会组成新的机器码,存放在 CodeCache 区域里。
在 JDK 的源码里,有很多被 @ForceInline 注解的方法,这些方法会在执行的时候被强制进行内联;而被 @DontInline 注解的方法,则始终不会被内联,比如下面的一段代码。
java.lang.ClassLoader 的 getClassLoader 方法将会被强制内联。
@CallerSensitive
@ForceInline // to ensure Reflection.getCallerClass optimization
public ClassLoader getClassLoader() {
ClassLoader cl = getClassLoader0();
if (cl == null)
return null;
SecurityManager sm = System.getSecurityManager();
if (sm != null) {
ClassLoader.checkClassLoaderPermission(cl, Reflection.getCallerClass());
}
return cl;
}
方法内联的过程是非常智能的内联后的代码会按照一定规则进行再次优化。最终的机器码在保证逻辑正确的前提下可能和我们推理的完全不一样。在非常小的概率下JIT 会出现 Bug这时候可以关闭问题方法的内联或者直接关闭 JIT 的优化,保持解释执行。实际上,这种 Bug 我从来没碰到过。
-XX:CompileCommand=exclude,com/lagou/Test,test
上面的参数,表示 com.lagou.Test 的 test 方法将不会进行 JIT 编译,一直解释执行。
另外C2 支持的内联层次不超过 9 层太高的话CodeCache 区域会被挤爆,这个阈值可以通过 -XX:MaxInlineLevel 进行调整。相似的,编译后的代码超过一定大小也不会再内联,这个参数由 -XX:InlineSmallCode 进行调整。
有非常多的参数,被用来控制对内联方法的选择,整体来说,短小精悍的小方法更容易被优化。
这和我们在日常中的编码要求是一致的:代码块精简,逻辑清晰的代码,更容易获得优化的空间。
我们使用 JITWatch 再看一下对于 getA() 方法的调用,将鼠标悬浮在字节码指令上,可以看到方法已经被内联了。
逃逸分析
逃逸分析Escape Analysis是目前 JVM 中比较前沿的优化技术。通过逃逸分析JVM 能够分析出一个新的对象使用范围,从而决定是否要将这个对象分配到堆上。
使用 -XX:+DoEscapeAnalysis 参数可以开启逃逸分析,逃逸分析现在是 JVM 的默认行为,这个参数可以忽略。
JVM 判断新创建的对象是否逃逸的依据有:
对象被赋值给堆中对象的字段和类的静态变量;
对象被传进了不确定的代码中去运行。
举个例子,在代码 1 中,虽然 map 是一个局部变量,但是它通过 return 语句返回,其他外部方法可能会使用它,这就是方法逃逸。另外,如果被其他线程引用或者赋值,则成为线程逃逸。
代码 2用完 Map 之后就直接销毁了,我们就可以说 map 对象没有逃逸。
代码1
public Map fig(){
Map map = new HashMap();
...
return map;
}
代码2
public void fig(){
Map map = new HashMap();
...
}
那逃逸分析有什么好处呢?
同步省略,如果一个对象被发现只能从一个线程被访问到,那么对于这个对象的操作可以不考虑同步。
栈上分配,如果一个对象在子程序中被分配,那么指向该对象的指针永远不会逃逸,对象有可能会被优化为栈分配。
分离对象或标量替换,有的对象可能不需要作为一个连续的内存结构存在也可以被访问到,那么对象的部分(或全部)可以不存储在内存,而是存储在 CPU 寄存器中。标量是指无法再分解的数据类型,比如原始数据类型及 reference 类型。
再来看一下 JITWatch 对 synchronized 代码块的分析。根据提示,由于逃逸分析了解到新建的锁对象 Object 并没有逃逸出方法 cal它将会在栈上直接分配。
查看 C2 编译后的机器码,发现并没有同步代码相关的生成。这是因为 JIT 在分析之后,发现针对 new Object() 这个对象并没有发生线程竞争的情况,则会把这部分的同步直接给优化掉。我们在代码层次做了一些无用功,字节码无法发现它,而 JIT 智能地找到了它并进行了优化。
因此,并不是所有的对象或者数组都会在堆上分配。由于 JIT 的存在,如果发现某些对象没有逃逸出方法,那么就有可能被优化成栈分配。
intrinsic
另外一个不得不提的技术点那就是 intrinsic这来源于一道面试题为什么 String 类的 indexOf 方法,比我们使用相同代码实现的方法,执行效率要高得多?
在翻看 JDK 的源码时,能够看到很多地方使用了 HotSpotIntrinsicCandidate 注解。比如 StringBuffer 的 append 方法:
@Override
@HotSpotIntrinsicCandidate
public synchronized StringBuffer append(char c) {
toStringCache = null;
super.append(c);
return this;
}
@HotSpotIntrinsicCandidate 标注的方法,在 HotSpot 中都有一套高效的实现,该高效实现基于 CPU 指令运行时HotSpot 维护的高效实现会替代 JDK 的源码实现,从而获得更高的效率。
上面的问题中,我们往下跟踪实现,可以发现 StringLatin1 类中的 indexOf 方法,同样适用了 HotSpotIntrinsicCandidate 注解,原因也就在于此。
@HotSpotIntrinsicCandidate
public static int indexOf(byte[] value, byte[] str) {
if (str.length == 0) {
return 0;
}
if (value.length == 0) {
return -1;
}
return indexOf(value, value.length, str, str.length, 0);
}
@HotSpotIntrinsicCandidate
public static int indexOf(byte[] value, int valueCount, byte[] str, int strCount, int fromIndex) {
byte first = str[0];
JDK 中这种方法有接近 400 个,可以在 IDEA 中使用 Find Usages 找到它们。
小结
JIT 是现代 JVM 主要的优化点,能够显著地增加程序的执行效率,从解释执行到最高层次的 C2一个数量级的性能提升也是有可能的。但即时编译的过程是非常缓慢的耗时间也费空间所以这些优化操作会和解释执行同时进行。
一般,方法首先会被解释执行,然后被 3 层的 C1 编译,最后被 4 层的 C2 编译,这个过程也不是一蹴而就的。
常用的优化手段,有公共子表达式消除、数组范围检查消除、方法内联、逃逸分析等。
其中,方法内联通过将短小精悍的代码融入到调用方法的执行逻辑里,来减少方法调用上的开支;逃逸分析通过分析变量的引用范围,对象可能会使用栈上分配的方式来减少 GC 的压力,或者使用标量替换来获取更多的优化。
这个过程的执行细节并不是那么“确定”,在不同的 JVM 中,甚至在不同的 HotSpot 版本中,效果也不尽相同。
使用 JITWatch 工具,能够看到字节码和机器码的对应关系,以及执行过程中的一系列优化操作。若想要了解这个工具的更多功能,可以点击这里参考 wiki。

View File

@ -0,0 +1,185 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
24 案例分析:大型项目如何进行性能瓶颈调优?
本课时我们主要分享一个实践案例,即大型项目如何进行性能瓶颈调优,这也是对前面所学的知识进行总结。
性能调优是一个比较大且比较模糊的话题。在大型项目中,既有分布式的交互式调优问题,也有纯粹的单机调优问题。由于我们的课程主要讲解 JVM 相关的知识点,重点关注 JVM 的调优、故障或者性能瓶颈方面的问题排查,所以对于分布式应用中的影响因素,这里不过多介绍。
优化层次
下面是我总结的一张关于优化层次的图,箭头表示优化时需考虑的路径,但也不总是这样。当一个系统出现问题的时候,研发一般不会想要立刻优化 JVM或者优化操作系统会尝试从最高层次上进行问题的解决解决最主要的瓶颈点。
数据库优化: 数据库是最容易成为瓶颈的组件,研发会从 SQL 优化或者数据库本身去提高它的性能。如果瓶颈依然存在,则会考虑分库分表将数据打散,如果这样也没能解决问题,则可能会选择缓存组件进行优化。这个过程与本课时相关的知识点,可以使用 jstack 获取阻塞的执行栈,进行辅助分析。
集群最优:存储节点的问题解决后,计算节点也有可能发生问题。一个集群系统如果获得了水平扩容的能力,就会给下层的优化提供非常大的时间空间,这也是弹性扩容的魅力所在。我接触过一个服务,由最初的 3 个节点,扩容到最后的 200 多个节点,但由于人力问题,服务又没有什么新的需求,下层的优化就一直被搁置着。
硬件升级:水平扩容不总是有效的,原因在于单节点的计算量比较集中,或者 JVM 对内存的使用超出了宿主机的承载范围。在动手进行代码优化之前,我们会对节点的硬件配置进行升级。升级容易,降级难,降级需要依赖代码和调优层面的优化。
代码优化:出于成本的考虑,上面的这些问题,研发团队并不总是坐视不管。代码优化是提高性能最有效的方式,但需要收集一些数据,这个过程可能是服务治理,也有可能是代码流程优化。我在第 21 课时介绍的 JavaAgent 技术,会无侵入的收集一些 profile 信息,供我们进行决策。像 Sonar 这种质量监控工具,也可以在此过程中帮助到我们。
并行优化:并行优化的对象是这样一种接口,它占用的资源不多,计算量也不大,就是速度太慢。所以我们通常使用 ContDownLatch 对需要获取的数据进行并行处理,效果非常不错,比如在 200ms 内返回对 50 个耗时 100ms 的下层接口的调用。
JVM 优化:虽然对 JVM 进行优化,有时候会获得巨大的性能提升,但在 JVM 不发生问题时,我们一般不会想到它。原因就在于,相较于上面 5 层所达到的效果来说它的优化效果有限。但在代码优化、并行优化、JVM 优化的过程中JVM 的知识却起到了关键性的作用,是一些根本性的影响因素。
操作系统优化:操作系统优化是解决问题的杀手锏,比如像 HugePage、Luma、“CPU 亲和性”这种比较底层的优化。但就计算节点来说,对操作系统进行优化并不是很常见。运维在背后会做一些诸如文件句柄的调整、网络参数的修改,这对于我们来说就已经够用了。
虽然本课程是针对比较底层的 JVM但我还是想谈一下一个研发对技术体系的整体演进方向。
首先,掌握了比较底层、基础的东西后,在了解一些比较高层的设计时,就能花更少的时间,这方面的知识有:操作系统、网络、多线程、编译原理,以及一门感兴趣的开发语言。对 Java 体系来说,毫无疑问就是 Java 语言和 JVM。
其次,知识体系还要看实用性,比如你熟知编译原理,虽然 JIT 很容易入门,但如果不做相关的开发,这并没有什么实际作用。
最后,现代分布式系统在技术上总是一个权衡的结果(比如 CAP。在分析一些知识点和面试题的时候也要看一下哪些是权衡的结果哪些务必是准确的。整体上达到次优局部上达到最优就是我们要追寻的结果。
代码优化、JVM 的调优,以及单机的故障排查,就是一种局部上的寻优过程,也是一个合格的程序员必须要掌握的技能。
JVM 调优
由于 JVM 一直处在变化之中,所以一些参数的配置并不总是有效的,有时候你加入一个参数,“感觉上”运行速度加快了,但通过
-XX:+PrintFlagsFinal 来查看,却发现这个参数默认就是这样,比如第 10 课时提到的 UseAdaptiveSizePolicy。所以在不同的 JVM 版本上,不同的垃圾回收器上,要先看一下这个参数默认是什么,不要轻信他人的建议。
java -XX:+PrintFlagsFinal -XX:+UseG1GC 2>&1 | grep UseAdaptiveSizePolicy
内存区域大小
首先要调整的,就是各个分区的大小,不过这也要分垃圾回收器,我们来看一些全局参数及含义。
-XX:+UseG1GC用于指定 JVM 使用的垃圾回收器为 G1尽量不要靠默认值去保证要显式的指定一个。
-Xmx设置堆的最大值一般为操作系统的 23 大小。
-Xms设置堆的初始值一般设置成和 Xmx 一样的大小来避免动态扩容。
-Xmn表示年轻代的大小默认新生代占堆大小的 1/3。高并发、对象快消亡场景可适当加大这个区域对半或者更多都是可以的。但是在 G1 下,就不用再设置这个值了,它会自动调整。
-XX:MaxMetaspaceSize用于限制元空间的大小一般 256M 足够了,这一般和初始大小 -XX:MetaspaceSize 设置成一样的。
-XX:MaxDirectMemorySize用于设置直接内存的最大值限制通过 DirectByteBuffer 申请的内存。
-XX:ReservedCodeCacheSize用于设置 JIT 编译后的代码存放区大小,如果观察到这个值有限制,可以适当调大,一般够用即可。
-Xss用于设置栈的大小默认为 1M已经足够用了。
内存调优
-XX:+AlwaysPreTouch表示在启动时就把参数里指定的内存全部初始化启动时间会慢一些但运行速度会增加。
-XX:SurvivorRatio默认值为 8表示伊甸区和幸存区的比例。
-XX:MaxTenuringThreshold这个值在 CMS 下默认为 6G1 下默认为 15这个值和我们前面提到的对象提升有关改动效果会比较明显。对象的年龄分布可以使用 -XX:+PrintTenuringDistribution 打印,如果后面几代的大小总是差不多,证明过了某个年龄后的对象总能晋升到老生代,就可以把晋升阈值设小。
PretenureSizeThreshold表示超过一定大小的对象将直接在老年代分配不过这个参数用的不是很多。
其他容量的相关参数可以参考其他课时,但不建议随便更改。
垃圾回收器优化
接下来看一下主要的垃圾回收器。
CMS 垃圾回收器
-XX:+UseCMSInitiatingOccupancyOnly这个参数需要加上 -XX:CMSInitiatingOccupancyFraction注意后者需要和前者一块配合才能完成工作它们指定了 MajorGC 的发生时机。
-XX:ExplicitGCInvokesConcurrent当代码里显示调用了 System.gc(),实际上是想让回收器进行 FullGC如果发生这种情况则使用这个参数开始并行 FullGC建议加上这个参数。
-XX:CMSFullGCsBeforeCompaction这个参数的默认值为 0代表每次 FullGC 都对老生代进行碎片整理压缩,建议保持默认。
-XX:CMSScavengeBeforeRemark表示开启或关闭在 CMS 重新标记阶段之前的清除YGC尝试它可以降低 remark 时间,建议加上。
-XX:+ParallelRefProcEnabled可以用来并行处理 Reference以加快处理速度缩短耗时具体用法见第 15 课时。
G1 垃圾回收器
-XX:MaxGCPauseMillis用于设置目标停顿时间G1 会尽力达成。
-XX:G1HeapRegionSize用于设置小堆区大小这个值为 2 的次幂,不要太大,也不要太小,如果实在不知道如何设置,建议保持默认。
-XX:InitiatingHeapOccupancyPercent表示当整个堆内存使用达到一定比例默认是 45%),并发标记阶段 就会被启动。
-XX:ConcGCThreads表示并发垃圾收集器使用的线程数量默认值随 JVM 运行的平台不同而变动,不建议修改。
其他参数优化
-XX:AutoBoxCacheMax用于加大 IntegerCache具体原因可参考第 20 课时。
-Djava.security.egd=file:/dev/./urandom这个参数使用 urandom 随机生成器,在进行随机数获取时,速度会更快。
-XX:-OmitStackTraceInFastThrow用于减少异常栈的输出并进行合并。虽然会对调试有一定的困扰但能在发生异常时显著增加性能。
存疑优化
-XX:-UseBiasedLocking用于取消偏向锁第 19 课时),理论上在高并发下会增加效率,这个需要实际进行观察,在无法判断的情况下,不需要配置。
JIT 参数:这是我们在第 22 课时多次提到的 JIT 编译参数,这部分最好不要乱改,会产生意想不到的问题。
GC 日志
这部分我们在第 9 课时进行了详细的介绍,在此不再重复。
下面来看一个在 G1 垃圾回收器运行的 JVM 启动命令。
java \
-XX:+UseG1GC \
-XX:MaxGCPauseMillis=100 \
-XX:InitiatingHeapOccupancyPercent=45 \
-XX:G1HeapRegionSize=16m \
-XX:+ParallelRefProcEnabled \
-XX:MaxTenuringThreshold=3 \
-XX:+AlwaysPreTouch \
-Xmx5440M \
-Xms5440M \
-XX:MaxMetaspaceSize=256M \
-XX:MetaspaceSize=256M \
-XX:MaxDirectMemorySize=100M \
-XX:ReservedCodeCacheSize=268435456 \
-XX:-OmitStackTraceInFastThrow \
-Djava.security.egd=file:/dev/./urandom \
-verbose:gc \
-XX:+PrintGCDetails \
-XX:+PrintGCDateStamps \
-XX:+PrintGCApplicationStoppedTime \
-XX:+PrintGCApplicationConcurrentTime \
-XX:+PrintTenuringDistribution \
-XX:+PrintClassHistogramBeforeFullGC \
-XX:+PrintClassHistogramAfterFullGC \
-Xloggc:/tmp/logs/gc_%p.log \
-XX:+HeapDumpOnOutOfMemoryError \
-XX:HeapDumpPath=/tmp/logs \
-XX:ErrorFile=/tmp/logs/hs_error_pid%p.log \
-Djava.rmi.server.hostname=127.0.0.1 \
-Dcom.sun.management.jmxremote \
-Dcom.sun.management.jmxremote.port=14000 \
-Dcom.sun.management.jmxremote.ssl=false \
-Dcom.sun.management.jmxremote.authenticate=false \
-javaagent:/opt/test.jar \
MainRun
故障排查
有需求才需要优化,不要为了优化而优化。一般来说,上面提到的这些 JVM 参数,基本能够保证我们的应用安全,如果想要更进一步、更专业的性能提升,就没有什么通用的法则了。
打印详细的 GCLog能够帮助我们了解到底是在哪一步骤发生了问题然后才能对症下药。使用 gceasy.io 这样的线上工具,能够方便的分析到结果,但一些偏门的 JVM 参数修改,还是需要进行详细的验证。
一次或者多次模拟性的压力测试是必要的,能够让我们提前发现这些优化点。
我们花了非常大的篇幅,来讲解 JVM 中故障排查的问题,这也是和我们工作中联系最紧密的话题。
JVM 故障会涉及到内存问题和计算问题其中内存问题占多数。除了程序计数器JVM 内存里划分每一个区域,都有溢出的可能,最常见的就是堆溢出。使用 jmap 可以 dump 一份内存,然后使用 MAT 工具进行具体原因的分析。
对堆外内存的排查需要较高的技术水平,我们在第 13 课时进行了详细的讲解。当你发现进程占用的内存资源比使用 Xmx 设置得要多,那么不要忘了这一环。
使用 jstack 可以获取 JVM 的执行栈,并且能够看到线程的一些阻塞状态,这部分可以使用 arthas 进行瞬时态的获取定位到瞬时故障。另外一个完善的监控系统能够帮我们快速定位问题包括操作系统的监控、JVM 的监控等。
代码、JVM 优化和故障排查是一个持续优化的过程,只有更优、没有最优。如何在有限的项目时间内,最高效的完成工作,才是我们所需要的。
小结
本课时对前面的课程内容做了个简单的总结,从 7 个层面的优化出发,简要的谈了一下可能的优化过程,然后详细地介绍了一些常见的优化参数。
JVM 的优化效果是有限的,但它是理论的基础,代码优化和参数优化都需要它的指导。同时,有非常多的工具能够帮我们定位到问题。
偏门的优化参数可能有效,但不总是有效。实际上,从 CMS 到 G1再到 ZGC关于 GC 优化的配置参数也越来越少但协助排查问题的工具却越来越多。在大多数场景下JVM 已经能够达到开箱即用的高性能效果,这也是一个虚拟机所追求的最终目标。

View File

@ -0,0 +1,256 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
25 未来JVM 的历史与展望
本课时我们主要讲解 JVM 的历史与展望。
我们都知道Java 目前被 Oracle 控制,它是从 Sun 公司手中收购的HotSpot 最初也并非由 Sun 公司开发,是由一家名为 Longview Technologies 的小公司设计的,而且这款虚拟机一开始也不是为 Java 语言开发的。
当时的 HotSpot非常优秀尤其是在 JIT 编译技术上,有一些超前的理念,于是 Sun 公司在 1997 年收购了 Longview Technologies揽美人入怀。
Sun 公司是一家对技术非常专情的公司,他们对 Java 语言进行了发扬光大,尤其是在 JVM 上,做了一些非常大胆的尝试和改进。
9 年后Sun 公司在 2006 年的 JavaOne 大会上,将 Java 语言开源,并在 GPL 协议下公开源码,在此基础上建立了 OpenJDK。你应该听说过GPL 协议的限制,是比较宽松的,这极大的促进了 Java 的发展,同时推动了 JVM 的发展。
Sun 是一家非常有技术情怀的公司,最高市值曾超过 2000 亿美元。但是,最后却以 74 亿美元的价格被 Oracle 收购了,让人感叹不已。
2010 年HotSpot 进入了 Oracle 时代,这也是现在为什么要到 Oracle 官网上下载 J2SE 的原因。
幸运的是,我们有 OpenJDK 这个凝聚了众多开源开发者心血的分支。从目前的情况来看OpenJDK 与 Oracle 版本之间的差别越来越小,甚至一些超前的实验性特性,也会在 OpenJDK 上进行开发。
对于我们使用者来说,这个差别并不大,因为 JVM 已经屏蔽了操作系统上的差异,而我们打交道的,是上层的 JRE 和 JDK。
其他虚拟机
由于 JVM 就是个规范,所以实现的方法也很多,完整的列表请点击这里查看。
JVM 的版本非常之多,比较牛的公司都搞了自己的 JVM但当时谁也没想到话语权竟会到了 Oracle 手里。下面举几个典型的例子。
J9 VM
我在早些年工作的时候,有钱的公司喜欢买大型机,比如会买 WebLogic、WebSphere 等服务器。对于你现在已经用惯了 Tomcat、Undertow 这些轻量级的 Web 服务器来说,这是一些很古老的名词了。
WebSphere 就是这样一个以“巨无霸”的形式存在,当年的中间件指的就是它,和现在的中间件完全不是一个概念。
WebSphere 是 IBM 的产品,开发语言是 Java。但是它运行时的 JVM却是一个叫做 J9 的虚拟机,依稀记得当年,有非常多的 jar 包,由于引用了一些非常偏门的 API却不能运行现在应该好了很多
Zing VM
Zing JVM 是 Azul 公司传统风格的产品,它在 HotSpot 上做了不少的定制及优化,主打低延迟、高实时服务器端 JDK 市场。它代表了一类商业化的定制,比如 JRockit都比较贵。
IKVM
这个以前在写一些游戏的时候,使用过 LibGDX相当于使用了 Java最后却能跑在 .net 环境上,使用的方式是 IKVM 。它包含了一个使用 .net 语言实现的 Java 虚拟机,配合 Mono 能够完成 Java 和 .net 的交互,让人认识到语言之间的鸿沟是那么的渺小。
Dalvik
Android 的 JVM就是让 Google 吃官司的那个,从现在 Android 的流行度上也能看出来Dalvik 优化的很好。
历史
下面我简单讲讲 Java 的发展历史:
1995 年 5 月 23 日Sun 公司正式发布了 Java 语言和 HotJava 浏览器;
1996 年 1 月Sun 公司发布了 Java 的第一个开发工具包JDK 1.0
1996 年 4 月10 个最主要的操作系统供应商申明将在其产品中嵌入 Java 技术,发展可真是迅雷不及掩耳;
1996 年 9 月,约 8.3 万个网页应用了 Java 技术来制作,这就是早年的互联网,即 Java Applet真香
1996 年 10 月Sun 公司发布了 Java 平台第一个即时编译器JIT这一年很不平凡
1997 年 2 月 18 日JDK 1.1 面世,在随后的三周时间里,达到了 22 万次的下载量PHP 甘拜下风;
1999 年 6 月Sun 公司发布了第二代 Java 三大版本,即 J2SE、J2ME、J2EE随之 Java2 版本发布;
2000 年 5 月 8 日JDK 1.3 发布,四年升三版,不算过分哈;
2000 年 5 月 29 日JDK 1.4 发布,获得 Apple 公司 Mac OS 的工业标准支持;
2001 年 9 月 24 日Java EE 1.3 发布,注意是 EE从此开始臃肿无比
2002 年 2 月 26 日J2SE 1.4 发布,自此 Java 的计算能力有了大幅度的提升,与 J2SE 1.3 相比,多了近 62% 的类与接口;
2004 年 9 月 30 日 18:00PMJ2SE 1.5 发布1.5 正式更名为 Java SE 5.0
2005 年 6 月,在 JavaOne 大会上Sun 公司发布了 Java SE 6
2009 年 4 月 20 日Oracle 宣布收购 Sun该交易的总价值约为 74 亿美元;
2010 年 Java 编程语言的创始人 James Gosling 从 Oracle 公司辞职,一朝天子一朝臣,国外也不例外;
2011 年 7 月 28 日Oracle 公司终于发布了 Java 7这次版本升级经过了将近 5 年时间;
2014 年 3 月 18 日Oracle 公司发布了 Java 8这次版本升级为 Java 带来了全新的 Lambda 表达式。
小碎步越来越快,担心很快 2 位数都装不下 Java 的版本号了。目前 Java 的版本已经更新到 14 了,但市场主流使用的还是 JDK 8 版本。
最近更新
有些我们现在认为理所当然的功能,在 Java 的早期版本是没有的。我们从 Java 7 说起,以下内容仅供参考,详细列表见 openjdk JEP 列表。
Java 7
Java 7 增加了以下新特性:
try、catch 能够捕获多个异常
新增 try-with-resources 语法
JSR341 脚本语言新规范
JSR203 更多的 NIO 相关函数
JSR292第 17 课时提到的 InvokeDynamic
支持 JDBC 4.1 规范
文件操作的 Path 接口、DirectoryStream、Files、WatchService
jcmd 命令
多线程 fork/join 框架
Java Mission Control
Java 8
Java 8 也是一个重要的版本,在语法层面上有更大的改动,支持 Lamda 表达式,影响堪比 Java 5 的泛型支持:
支持 Lamda 表达式
支持集合的 stream 操作
提升了 HashMaps 的性能(红黑树)
提供了一系列线程安全的日期处理类
完全去掉了 Perm 区
Java 9
Java 9 增加了以下新特性:
JSR376 Java 平台模块系统
JEP261 模块系统
jlink 精简 JDK 大小
G1 成为默认垃圾回收器
CMS 垃圾回收器进入废弃倒计时
GC Log 参数完全改变,且不兼容
JEP110 支持 HTTP2同时改进 HttpClient 的 API支持异步模式
jshell 支持类似于 Python 的交互式模式
Java 10
Java 10 增加了以下新特性:
JEP304 垃圾回收器接口代码进行整改
JEP307 G1 在 FullGC 时采用并行收集方式
JEP313 移除 javah 命令
JEP317 重磅 JIT 编译器 Graal 进入实验阶段
Java 11
Java 11 增加了以下新特性:
JEP318 引入了 Epsilon 垃圾回收器,这个回收器什么都不干,适合短期任务
JEP320 移除了 JavaEE 和 CORBA Modules应该要走轻量级路线
Flight Recorder 功能,类似 JMC 工具里的功能
JEP321 内置 httpclient 功能java.net.http 包
JEP323 允许 lambda 表达式使用 var 变量
废弃了 -XX+AggressiveOpts 选项
引入了 ZGC依然是实验性质
Java 12
Java 12 增加了以下新特性:
JEP189 先加入 ShenandoahGC
JEP325 switch 可以使用表达式
JEP344 优化 G1 达成预定目标
优化 ZGC
Java 13
Java 13 增加了以下新特性:
JEP354 yield 替代 break
JEP355 加入了 Text Blocks类似 Python 的多行文本
ZGC 的最大 heap 大小增大到 16TB
废弃 rmic Tool 并准备移除
Java 14
Java 14 增加了以下新特性:
JEP343 打包工具引入
JEP345 实现了 NUMA-aware 的内存分配,以提升 G1 在大型机器上的性能
JEP359 引入了 preview 版本的 record 类型,可用于替换 lombok 的部分功能
JEP364 之前的 ZGC 只能在 Linux 上使用,现在 Mac 和 Windows 上也能使用 ZGC 了
JEP363 正式移除 CMS我们课程里提到的一些优化参数在 14 版本普及之后,将不复存在
OpenJDK 64-Bit Server VM warning: Ignoring option UseConcMarkSweepGC; support was removed in 14.0
现状
先看一下 2019 年 JVM 生态系统报告部分图示,部分图示参考了 snyk 这个网站。
生产环境中,主要用哪些 JDK
可以看到 OracleJDK 和 OpenJDK 几乎统治了江湖,如果没有 IBM 那些捆绑销售的产品,份额只会更高。另外,使用 OpenJDK 的越来越多,差异也越来越小,在公有云、私有云等方面的竞争格局,深刻影响着在 OpenJDK 上的竞争格局OpenJDK 很有可能被认为是一种退⽽求其次的选择。
生产环境中,用哪个版本的 Java
以 8 版本为主,当然还有 6 版本以下的,尝鲜的并不是很多,因为服务器环境的稳定性最重要。新版本升级在中国的宣传还是不够,如果很多企业看不到技术升级的红利,势必也会影响升级的积极性。
应用程序的主要 JVM 语言是什么
很多人反应 Kotlin 非常好用,我尝试着推广了一下,被喜欢 Groovy 的朋友鄙视了一番,目前还是以 Java 居多。
展望
有点规模的互联网公司,行事都会有些谨慎,虽然 JVM 做到了向下版本的兼容,但是有些性能问题还是不容忽视,尝鲜吃螃蟹的并不是很多。
现在用的最多的,就是 Java 8 版本。如果你的服务器用的这个,那么用的最多的垃圾回收器就是 CMS或者 G1。随着 ZGC 越来越稳定CMS 终将会成为过去式。
目前,最先进的垃圾回收器,叫做 ZGC它有 3 个 flag
支持 TB 级堆内存(最大 4T
最大 GC 停顿 10ms
对吞吐量影响最大,不超过 15%
每一个版本的发布Java 都会对以下进行改进:
优化垃圾回收器,减少停顿,提高吞吐
语言语法层面的升级,这部分在最近的版本里最为明显
结构调整,减少运行环境的大小,模块化
废弃掉一些承诺要废弃的模块
那么 JVM 将向何处发展呢?以目前来看,比较先进的技术,就是刚才提到的垃圾回收阶段的 ZGC ,能够显著的减少 STW 的问题;另外, GraalVM 是 Oracle 创建的一个研究项目,目标是完全替换 HotSpot它是一个高性能的 JIT 编译器,接受 JVM 字节码,并生成机器代码。未来,会有更多的开发语言运行在 JVM 上,比如 Python、Ruby 等。
Poject Loom 致力于在 JVM 层面,给予 Java 协程 fibers的功能Java 程序的并发性能会上一个档次。
Java 版本大部分是向下兼容的,能够做到这个兼容,是非常不容易的。但 Java 的特性越加越多如果开发人员不能进行平滑的升级会是一个非常严重的问题JVM 也将会在这里花费非常大的精力。
那 JVM 将聚焦在哪些方面呢?又有哪些挑战?我大体总结了几点:
内存管理依然是非常大的挑战,未来会有更厉害的垃圾回收器来支持更大的堆空间
多线程和协程,未来会加大对多核的利用,以及对轻量级线程的支持
性能,增加整个 JVM 的执行效率,这通常是多个模块协作的结果
对象管理和追踪,复杂的对象,有着复杂的生命周期,加上难以预料的内存申请方式,需要更精准的管理优化
可预测性及易用性,更少的优化参数,更高的性能
更多 JVM 监控工具,提供对 JVM 全方面的监控,跟踪对象,在线优化
多语言支持,支持除了 Java 语言之外的其他开发语言,能够运行在 JVM 上
总结
Java 9 之后已经进入了快速发布阶段大约每半年发布一次Java 8 和 Java 11 是目前支持的 LTS 版本,它的功能变动也越来越多、越来越快。让我们把握好 Java 发展的脉搏,一起加油吧。

View File

@ -0,0 +1,363 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
26 福利:常见 JVM 面试题补充
最后一课时我们来分析常见的 JVM 面试题。
市面上关于 JVM 的面试题实在太多了,本课程中的第 02 ~ 06 课时是理论面试题的重灾区,并且是比较深入的题目,而本课时则选取了一些基础且常见的题目。
有些面试题是开放性的,而有些面试题是知识性的,要注意区别。面试题并没有标准答案,尤其是开放性题目,你需要整理成白话文,来尽量的展示自己。如果你在回答的过程中描述了一些自己不是很熟悉的内容,可能会受到追问。所以,根据问题,建议整理一份适合自己的答案,这比拿来主义更让人印象深刻。
勘误
我们来回忆一下课程中曾讲解过的容易出错或模糊的知识点。
不知你是否还记得?我们在每一课时的讲解中,都有聚焦的点,不同的问法可能会有不同的回答,要注意。
对象在哪里分配?
在第 02 课时中,谈到了数组和对象是堆上分配,当学完第 22 课时的逃逸分析后,我们了解到并不完全是这样的。由于 JIT 的存在,如果发现某些对象没有逃逸出方法,那么就有可能被优化成了栈上分配。
CMS 是老年代垃圾回收器?
初步印象是,但实际上不是。根据 CMS 的各个收集过程,它其实是一个涉及年轻代和老年代的综合性垃圾回收器。在很多文章和书籍的划分中,都将 CMS 划分为了老年代垃圾回收器,加上它主要作用于老年代,所以一般误认为是。
常量池问题
常量池的表述有些模糊,在此细化一下,注意我们指的是 Java 7 版本之后。
JVM 中有多个常量池:
字符串常量池,存放在堆上,也就是执行 intern 方法后存的地方class 文件的静态常量池,如果是字符串,则也会被装到字符串常量池中。
运行时常量池,存放在方法区,属于元空间,是类加载后的一些存储区域,大多数是类中 constant_pool 的内容。
类文件常量池,也就是 constant_pool这个是概念性的并没有什么实际存储区域。
在平常的交流过程中,聊的最多的是字符串常量池,具体可参考官网。
ZGC 支持的堆上限?
Java 13 增加到 16TBJava 11 还是 4 TB技术在发展请保持关注。
年轻代提升阈值动态计算的描述
在第 06 课时中对于年轻代“动态对象年龄判定”的表述是错误的。
参考代码 share/gc/shared/ageTable.cpp 中的 compute_tenuring_threshold 函数,重新表述为:程序从年龄最小的对象开始累加,如果累加的对象大小,大于幸存区的一半,则将当前的对象 age 作为新的阈值,年龄大于此阈值的对象则直接进入老年代。
这里说的一半,是通过 TargetSurvivorRatio 参数进行设置的。
永久代
虽然课程一直在强调,是基于 Java 8+ 版本进行讲解的,但还是有读者提到了永久代。这部分知识容易发生混淆,面试频率也很高,建议集中消化一下。
上面是第 02 课时中的一张图,注意左半部分是 Java 8 版本之前的内存区域,右半部分是 Java 8 的内存区域,主要区别就在 Perm 区和 Metaspace 区。
Perm 区属于堆,独立控制大小,在 Java 8 中被移除了JEP122原来的方法区就在这里Metaspace 是非堆,默认空间无上限,方法区移动到了这里。
常见面试题
JVM 有哪些内存区域JVM 的内存布局是什么?)
JVM 包含堆、元空间、Java 虚拟机栈、本地方法栈、程序计数器等内存区域,其中,堆是占用内存最大的一块,如下图所示。
Java 的内存模型是什么JMM 是什么?)
JVM 试图定义一种统一的内存模型,能将各种底层硬件以及操作系统的内存访问差异进行封装,使 Java 程序在不同硬件以及操作系统上都能达到相同的并发效果。它分为工作内存和主内存,线程无法对主存储器直接进行操作,如果一个线程要和另外一个线程通信,那么只能通过主存进行交换,如下图所示。
JVM 垃圾回收时如何确定垃圾?什么是 GC Roots
JVM 采用的是可达性分析算法。JVM 是通过 GC Roots 来判定对象存活的,从 GC Roots 向下追溯、搜索,会产生一个叫做 Reference Chain 的链条。当一个对象不能和任何一个 GC Root 产生关系时,就判定为垃圾,如下图所示。
GC Roots 大体包括:
活动线程相关的各种引用,比如虚拟机栈中 栈帧里的引用;
类的静态变量引用;
JNI 引用等。
注意:要想回答的更详细一些,请参照第 05 课时中的内容。
能够找到 Reference Chain 的对象,就一定会存活么?
不一定,还要看 Reference 类型,弱引用在 GC 时会被回收,软引用在内存不足的时候会被回收,但如果没有 Reference Chain 对象时,就一定会被回收。
强引用、软引用、弱引用、虚引用是什么?
普通的对象引用关系就是强引用。
软引用用于维护一些可有可无的对象。只有在内存不足时,系统则会回收软引用对象,如果回收了软引用对象之后仍然没有足够的内存,才会抛出内存溢出异常。
弱引用对象相比软引用来说,要更加无用一些,它拥有更短的生命周期,当 JVM 进行垃圾回收时,无论内存是否充足,都会回收被弱引用关联的对象。
虚引用是一种形同虚设的引用,在现实场景中用的不是很多,它主要用来跟踪对象被垃圾回收的活动。
你说你做过 JVM 参数调优和参数配置,请问如何查看 JVM 系统默认值
使用 -XX:+PrintFlagsFinal 参数可以看到参数的默认值,这个默认值还和垃圾回收器有关,比如 UseAdaptiveSizePolicy。
你平时工作中用过的 JVM 常用基本配置参数有哪些?
主要有 Xmx、Xms、Xmn、MetaspaceSize 等。
更加详细的可参照第 23 课时的参数总结,你只需要记忆 10 个左右即可,建议记忆 G1 相关的参数。面试时间有限,不会在这上面纠结,除非你表现的太嚣张了。
请你谈谈对 OOM 的认识
OOM 是非常严重的问题,除了程序计数器,其他内存区域都有溢出的风险。和我们平常工作最密切的,就是堆溢出,另外,元空间在加载的类非常多的情况下也会溢出,还有就是栈溢出,这个通常影响比较小。堆外也有溢出的可能,这个就比较难排查了。
你都有哪些手段用来排查内存溢出?
这个话题很大,可以从实践环节中随便摘一个进行总结,下面举一个最普通的例子。
内存溢出包含很多种情况,我在平常工作中遇到最多的就是堆溢出。有一次线上遇到故障,重新启动后,使用 jstat 命令,发现 Old 区一直在增长。我使用 jmap 命令,导出了一份线上堆栈,然后使用 MAT 进行分析,通过对 GC Roots 的分析,发现了一个非常大的 HashMap 对象,这个原本是其他同事做缓存用的,但是一个无界缓存,造成了堆内存占用一直上升,后来,将这个缓存改成 guava 的 Cache并设置了弱引用故障就消失了。
GC 垃圾回收算法与垃圾收集器的关系?
常用的垃圾回收算法有标记清除、标记整理、复制算法等,引用计数器也算是一种,但垃圾回收器不使用这种算法,因为有循环依赖的问题。
很多垃圾回收器都是分代回收的:
对于年轻代,主要有 Serial、ParNew 等垃圾回收器,回收过程主要使用复制算法;
老年代的回收算法有 Serial、CMS 等,主要使用标记清除、标记整理算法等。
我们线上使用较多的是 G1也有年轻代和老年代的概念不过它是一个整堆回收器它的回收对象是小堆区 。
生产上如何配置垃圾收集器?
首先是内存大小问题,基本上每一个内存区域我都会设置一个上限,来避免溢出问题,比如元空间。通常,堆空间我会设置成操作系统的 2/3超过 8GB 的堆,优先选用 G1。
然后我会对 JVM 进行初步优化,比如根据老年代的对象提升速度,来调整年轻代和老年代之间的比例。
接下来是专项优化,判断的主要依据是系统容量、访问延迟、吞吐量等,我们的服务是高并发的,所以对 STW 的时间非常敏感。
我会通过记录详细的 GC 日志,来找到这个瓶颈点,借用 GCeasy 这样的日志分析工具,很容易定位到问题。
怎么查看服务器默认的垃圾回收器是哪一个?
这通常会使用另外一个参数,即 -XX:+PrintCommandLineFlags来打印所有的参数包括使用的垃圾回收器。
假如生产环境 CPU 占用过高,请谈谈你的分析思路和定位。
首先,使用 top -H 命令获取占用 CPU 最高的线程,并将它转化为十六进制。
然后,使用 jstack 命令获取应用的栈信息,搜索这个十六进制,这样就能够方便地找到引起 CPU 占用过高的具体原因。
对于 JDK 自带的监控和性能分析工具用过哪些?
jps用来显示 Java 进程;
jstat用来查看 GC
jmap用来 dump 堆;
jstack用来 dump 栈;
jhsdb用来查看执行中的内存信息。
栈帧都有哪些数据?
栈帧包含:局部变量表、操作数栈、动态连接、返回地址等。
JIT 是什么?
为了提高热点代码的执行效率在运行时虚拟机将会把这些代码编译成与本地平台相关的机器码并进行各种层次的优化完成这个任务的编译器就称为即时编译器Just In Time Compiler简称 JIT 编译器。
Java 的双亲委托机制是什么?
双亲委托的意思是除了顶层的启动类加载器以外其余的类加载器在加载之前都会委派给它的父加载器进行加载这样一层层向上传递直到祖先们都无法胜任它才会真正的加载Java 默认是这种行为。
有哪些打破了双亲委托机制的案例?
Tomcat 可以加载自己目录下的 class 文件,并不会传递给父类的加载器;
Java 的 SPI发起者是 BootstrapClassLoaderBootstrapClassLoader 已经是最上层了,它直接获取了 AppClassLoader 进行驱动加载,和双亲委派是相反的。
简单描述一下(分代)垃圾回收的过程
分代回收器有两个分区:老生代和新生代,新生代默认的空间占总空间的 1/3老生代的默认占比是 2/3。
新生代使用的是复制算法,新生代里有 3 个分区Eden、To Survivor、From Survivor它们的默认占比是 8:1:1。
当年轻代中的 Eden 区分配满的时候,就会触发年轻代的 GCMinor GC具体过程如下
在 Eden 区执行了第一次 GC 之后,存活的对象会被移动到其中一个 Survivor 分区(以下简称 from
Eden 区再次 GC这时会采用复制算法将 Eden 和 from 区一起清理,存活的对象会被复制到 to 区,接下来,只要清空 from 区就可以了。
CMS 分为哪几个阶段?
初始标记
并发标记
并发预清理
并发可取消的预清理
重新标记
并发清理
由于《深入理解 Java 虚拟机》一书的流行,面试时省略并发清理、并发可取消的预清理这两个阶段,一般也是没问题的。
CMS 都有哪些问题?
内存碎片问题Full GC 的整理阶段,会造成较长时间的停顿;
需要预留空间,用来分配收集阶段产生的“浮动垃圾”;
使用更多的 CPU 资源,在应用运行的同时进行堆扫描;
停顿时间是不可预期的。
你使用过 G1 垃圾回收器的哪几个重要参数?
最重要的是 MaxGCPauseMillis可以通过它设定 G1 的目标停顿时间它会尽量去达成这个目标。G1HeapRegionSize 可以设置小堆区的大小,一般是 2 的次幂。InitiatingHeapOccupancyPercent 启动并发 GC 时的堆内存占用百分比G1 用它来触发并发 GC 周期,基于整个堆的使用率,而不只是某一代内存的使用比例,默认是 45%。
GC 日志的 real、user、sys 是什么意思?
real 指的是从开始到结束所花费的时间,比如进程在等待 I/O 完成,这个阻塞时间也会被计算在内。
user 指的是进程在用户态User Mode所花费的时间只统计本进程所使用的时间是指多核。
sys 指的是进程在核心态Kernel Mode所花费的 CPU 时间量,即内核中的系统调用所花费的时间,只统计本进程所使用的时间。
什么情况会造成元空间溢出?
元空间默认是没有上限的,不加限制比较危险。当应用中的 Java 类过多时,比如 Spring 等一些使用动态代理的框架生成了很多类,如果占用空间超出了我们的设定值,就会发生元空间溢出。
什么时候会造成堆外内存溢出?
使用了 Unsafe 类申请内存,或者使用了 JNI 对内存进行操作,这部分内存是不受 JVM 控制的,不加限制使用的话,会很容易发生内存溢出。
SWAP 会影响性能么?
当操作系统内存不足时,会将部分数据写入到 SWAP ,但是 SWAP 的性能是比较低的。如果应用的访问量较大,需要频繁申请和销毁内存,那么很容易发生卡顿。一般在高并发场景下,会禁用 SWAP。
有什么堆外内存的排查思路?
进程占用的内存,可以使用 top 命令,看 RES 段占用的值,如果这个值大大超出我们设定的最大堆内存,则证明堆外内存占用了很大的区域。
使用 gdb 命令可以将物理内存 dump 下来,通常能看到里面的内容。更加复杂的分析可以使用 Perf 工具,或者谷歌开源的 GPerftools。那些申请内存最多的 native 函数,就很容易找到。
HashMap 中的 key可以是普通对象么有什么需要注意的地方
Map 的 key 和 value 可以是任何类型,但要注意的是,一定要重写它的 equals 和 hashCode 方法,否则容易发生内存泄漏。
怎么看死锁的线程?
通过 jstack 命令,可以获得线程的栈信息,死锁信息会在非常明显的位置(一般是最后)进行提示。
如何写一段简单的死锁代码?
详情请见第 15 课时的 DeadLockDemo笔试的话频率也很高。
invokedynamic 指令是干什么的?
invokedynamic 是 Java 7 版本之后新加入的字节码指令,使用它可以实现一些动态类型语言的功能。我们使用的 Lambda 表达式,在字节码上就是 invokedynamic 指令实现的,它的功能有点类似反射,但它是使用方法句柄实现的,执行效率更高。
volatile 关键字的原理是什么?有什么作用?
使用了 volatile 关键字的变量,每当变量的值有变动的时候,都会将更改立即同步到主内存中;而如果某个线程想要使用这个变量,就先要从主存中刷新到工作内存,这样就确保了变量的可见性。
一般使用一个 volatile 修饰的 bool 变量,来控制线程的运行状态。
volatile boolean stop = false;
void stop(){
this.stop = true;
}
void start(){
new Thread(()->{
while (!stop){
//sth
}
}).start();
}
什么是方法内联?
为了减少方法调用的开销,可以把一些短小的方法,比如 getter/setter纳入到目标方法的调用范围之内这样就少了一次方法调用速度就能得到提升这就是方法内联的概念。
对象是怎么从年轻代进入老年代的?
在下面 4 种情况下,对象会从年轻代进入到老年代。
如果对象够老则会通过提升Promotion的方式进入老年代一般根据对象的年龄进行判断。
动态对象年龄判定,有的垃圾回收算法,比如 G1并不要求 age 必须达到 15 才能晋升到老年代,它会使用一些动态的计算方法。
分配担保,当 Survivor 空间不够的时候,则需要依赖其他内存(指老年代)进行分配担保,这个时候,对象也会直接在老年代上分配。
超出某个大小的对象将直接在老年代上分配,不过这个值默认为 0意思是全部首选 Eden 区进行分配。
safepoint 是什么?
当发生 GC 时,用户线程必须全部停下来,才可以进行垃圾回收,这个状态我们可以认为 JVM 是安全的safe整个堆的状态是稳定的。
如果在 GC 前,有线程迟迟进入不了 safepoint那么整个 JVM 都在等待这个阻塞的线程,造成了整体 GC 的时间变长。
MinorGC、MajorGC、FullGC 都什么时候发生?
MinorGC 在年轻代空间不足的时候发生MajorGC 指的是老年代的 GC出现 MajorGC 一般经常伴有 MinorGC。
FullGC 有三种情况:第一,当老年代无法再分配内存的时候;第二,元空间不足的时候;第三,显示调用 System.gc 的时候。另外,像 CMS 一类的垃圾回收器,在 MinorGC 出现 promotion failure 的时候也会发生 FullGC。
类加载有几个过程?
加载、验证、准备、解析、初始化。
什么情况下会发生栈溢出?
栈的大小可以通过 -Xss 参数进行设置,当递归层次太深的时候,则会发生栈溢出。
生产环境服务器变慢,请谈谈诊断思路和性能评估?
希望第 11 课时和第 16 课时中的一些思路,能够祝你一臂之力。下图是第 11 课时的一张影响因素的全景图。
从各个层次分析代码优化的手段,如下图所示:
如果你应聘的是比较高级的职位,那么可以说一下第 23 课时中的最后总结部分。
小结
本课时我们首先修正了一些表述错误的知识点;然后分析了一些常见的面试题,这些面试题的覆盖率是非常有限的,因为很多细节都没有触及到,更多的面试题还需要你自行提取、整理,由于篇幅有限,这里不再重复。
到现在为止我们的课程内容就结束了。本课程的特色主要体现在实践方面全部都是工作中的总结和思考辅之以理论给你一个在工作中JVM 相关知识点的全貌。当然,有些课时的难度是比较高的,需要你真正的实际操作一下。

View File

@ -0,0 +1,109 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
00 开篇词 云计算,这是开发者最好的时代
你好,我是何恺铎,一个云计算的爱好者、践行者、布道者。欢迎你加入这个专栏,和我一起探索云计算的宏大与美妙。
先简单介绍一下我自己。在职业生涯的早期,我曾在摩根士丹利的应用程序基础架构部门工作,在那里从事高性能数据处理组件和类库的研发。
我们部门所构造的框架和“轮子”呢,会作为可复用的组件,用来构建摩根最为关键的各类金融市场实时交易程序。从这个角度看来,我当时的工作内容,与如今云计算的一些中间件服务颇为类似。
大约十年前,我加入了国双科技,历任技术经理、技术总监、技术总经理,工作内容上我从“造轮子”转而开始做应用,尤其是构建面向互联网的各类大数据应用,比如用户行为分析、舆情社交聆听产品等。在漫长的研发岁月中,我和你一样,看遍了编程语言、框架类库的兴衰更迭,在基础设施层面,也经历了从物理机到虚拟化,再到云计算的梦幻旅程。
正是在这一个时期,我开始接触到云计算,并在工作中不断地加以实践和应用。从陌生到熟悉,从喜欢到痴迷,云的强大和高效使我成为了不折不扣的云计算狂热者。在业余时间,我也会在电脑前一遍遍地反复研究各个公有云的新发布特性,并思考它能否为我所用,为正在构建的应用发挥价值。
我也曾发表过一些和云计算相关的文章比如去年在InfoQ发表的万字长文《激荡十年云计算的过去、现在和未来》曾入选虎嗅当月的全行业精选也获得了业内媒体的广泛转载。我还有一个公众号“云间拾遗”点滴记录着我在云计算之旅中的实操和感悟。
现在,终于有一个专栏课程的机会,能让我系统地把自己对于云计算的认知进行梳理,并以面向开发者的方式呈现。我很珍惜这个宝贵的机会,我会尽可能地分享与云有关的知识体系、最佳实践,甚至自己的经验教训。
所以,这会是一个注重实用的云计算专栏,它为我们开发者而生。希望你一起“上车”,共同开启云计算之旅。
开发者为什么要学习云计算?
云计算不仅是一个妇孺皆知的技术热词也早已成长为一个巨大的行业。根据Gartner的报告全球公共云服务市场在2019年已经突破两千亿美元。毫无疑问历经多年的发展和成熟云计算已经成为一种潮流也是现代企业数字化转型中的重要组成部分。
产业的发展必然影响个体。那么,问题来了,这一趋势对于我们开发者而言意味着什么?
这意味着,未来我们的代码,和我们构建的应用,将越来越多地运行在云上;它还意味着,我们的架构模式和思维方式,将更多地与云契合共生。因此,我们必须学习了解云,以适应在云上构建应用的新时代。
另一个你应当学习云计算的原因,在于效率。现代社会的节奏飞快,业务需求多如牛毛,而且瞬息万变。更快更稳定地构建和交付,成为开发者的核心竞争力。云就能够帮助你做到这一点,触手可及的庞大资源规模、高度自动化的各类服务、可重用的基础组件,都会是你提高效率的好帮手。
另外,在日常工作和社区交流中,我发现许多开发者对于语言、框架和类库都比较重视和了解,毕竟这是每天接触的工具。但大家往往对于云的特性却还不够熟悉,导致在资源配置、技术选型和架构设计等环节没有选择最佳的方案,造成稳定性或是成本上的损失。如果对云计算有足够的理解,这些损失是完全可以避免的。
还有一部分开发者在过去尝试过云计算的产品或服务但由于早期云产品不成熟或者使用方式的问题无意识地形成了一些误解甚至偏见比如出于思维惯性会觉得云虚拟机的硬盘性能低下但事实并非如此。要知道云计算的发展并非一蹴而就而是历经多年逐步发展成熟的。所以在2020年你也可能需要来刷新一下认知了解云的最新能力与动态。
以上种种,共同构成了开发者应当认真对待和系统学习云计算的原因。这是云计算最好的时代。而在云的赋能下,这也应该是开发者最好的时代,是新时期优秀开发者的制胜之道。
开发者应该如何学习云计算?
那么,面对云计算这样一个宏大的课题,我们应该怎样入手学习呢?
市面上存在的云计算类书籍与课程,大致可分为这样几种:
一类是面向大众的科普书籍,侧重于云的历史发展和社会价值,技术上讲得不深;
一类是侧重于讲解虚拟化技术等云计算的内部实现,适合底层研发工程师而非应用开发者阅读;
还有一类常见的形式,则是云厂商自行制作的培训材料,其中不乏精品,但和某一个云绑定比较深,也总难免带上一些产品宣传痕迹。
我一直在想,能不能有更加适合开发者的云计算课程呢?毕竟,开发者才是云计算的最终用户啊。
所以,我希望这个专栏有所不同,有它的鲜明特点:
一方面,专栏会立足于开发者和架构师的视角来介绍云计算技术,尽可能多地结合应用场景来解析云的概念和能力,帮助你学习“用云”而非“做云”;
另一方面,专栏也会尽量不倾向于任何一个云,不进行“厂商绑定”,而是同时观察运用多个主流云厂商的服务,帮助你了解云的共性,也体会不同云的各自特点。
所以在我们的后续内容中我们将同时以阿里云、AWS和微软Azure为主要研究对象因为这三家正是全球云计算三甲再结合穿插腾讯云、华为云等优秀云服务作为案例进行讲解。如果你之前只是熟悉一个云希望这样的方式也能够破除单个云的信息茧房让你拥有更广阔的视野。
需要说明:这个专栏将以讲解公有云为主,私有云暂不涉及。不过不用担心,公有云和私有云的许多理念和实现都非常类似,颇有相通之处。所以这个课程对于你了解学习私有云也能有不小的帮助。
在每篇课程有限的篇幅中,我还会非常注意加入一些实操的内容,而非仅仅作概念解释和纸上谈兵。因为接地气的动手实验能帮助形成更直观的认识,和理性的解读一起参照,可以强化和加深对知识的理解。
另外云和传统IT架构方面的一个显著差别在于成本云的成本是非常动态的会更多地由应用架构和技术选型所决定。换一句话说云让我们开发者离钱更近了。因此我认为成本是云端的一个重要话题所以成本意识和成本控制技巧也将贯穿这个课程的始终。毕竟能为公司和项目省下真金白银和赚取利润同样关键。
以上几点,是我觉得开发者在学习云计算时应有的要点,也正是我们这个专栏的定位和撰写思路了。
课程设置
我们知道现代云计算是由大大小小、形态各异的云服务所组成。业界通行的做法是将它们大致划分为IaaS和PaaS两个领域。
IaaSInfrastructure as a Service即“基础设施即服务”一般指云计算所提供的计算、存储、网络等基本底层能力
PaaSPlatform as a Service即“平台即服务”通常指基于云底层能力而构建的面向领域或场景的高层服务如数据库、应用服务等。
广义上的云计算还可包括SaaSSoftware as a Service软件即服务的内容一般指基于云构建可开箱即用的各种业务应用。这是另一个宏大的领域我们这个开发者课程就不予以关注和讨论了。
所以在专栏内容上面我们的课程也会遵循这样的方式来划分分为IaaS篇和PaaS篇各自为你精心挑选了领域内最重要的若干话题。
IaaS篇我会从云上的数据中心入手然后分别讲解在云上如何让计算、存储、网络等基础能力为你所用。小到一台虚拟机的选择大到云上架构的最佳实践和整个数据中心的规划都会有相关的讲解。最后我们还会探讨云端运维有哪些重要的工作不可忽略。
PaaS篇我们首先会探讨PaaS的本质同时教你掌握PaaS最重要的几个观察视角然后分别按篇章介绍那些最炙手可热的PaaS服务像是云存储、云数据库、云容器服务、无服务器架构、云AI平台等等这些你耳熟能详的云服务都会一一专门讨论尤其会着重剖析它们与自建服务相比有何优势以及适合的应用场景。
这个专栏并不会对你的先验知识有很高的要求,只要你有一定的计算机基础和研发经验,对体系结构、操作系统、数据库、编程语言等常见内容有一定了解就可以了。云计算本就是各领域技术的抽象和组合,所以全面系统地学习云计算对提升你的专业综合素养也是大有裨益的。
当然作为一个力求“深入浅出”的课程在覆盖了广度的前提下篇幅所限我无法去深度讲解每一个相关领域的基础知识。比如说云数据库会是专栏的重要章节但显然我不会去教授MySQL或者PostgreSQL你需要订阅其他专栏来学习这些数据库本身。不过我会力求讲清每一个领域在云端的差异化特点和最佳实践。
写在最后
以上就是我对于自己以及这个专栏的介绍了。云计算其实并不困难,也没有那么神秘。只要你跟随专栏认真学习,我相信一定会有所收获。你会对于云计算的产品和能力形成一个清晰的宏观认知;也会了解到一些重要细节和实践经验;最后也是最重要的,回到你自己的生产场景,你将能够判断和决定如何正确运用云的力量。
所以,请坐稳扶好,我们马上就要启航了,方向:广阔云端。
在每篇课程的末尾,我都会给你留下思考题,或是动手操作的实验,欢迎你来参与和交流。这个开篇词也不例外:
如果你尚未拥有一个云账号的话,为便于后续的实验,请自己动手申请一个。现在的注册流程都很方便了,付款也都相当容易。不妨真的自己充值体验,这样你才会对成本和消耗有切身的感受。
各家云厂商经常会进行一些促销活动,尤其非常重视拉新。你所选择的云平台,现在有什么针对新用户的活动吗?哪个是你觉得最诱人的?
欢迎你在留言区和我互动,我会第一时间给你反馈。如果觉得有收获,也欢迎你把这篇文章分享给你的朋友。我是何恺铎,感谢你的阅读,我们第一讲再见。

View File

@ -0,0 +1,198 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
01 区域和可用区:欢迎来到云端数据中心
你好我是何恺铎欢迎来到《深入浅出云计算》专栏。这是课程的第一讲我们就从IaaS来入手开始对云计算的讨论。
IaaS的本质是对云数据中心和各类IT基础设施的抽象是基于软件技术对物理硬件进行的封装和虚拟。这为云计算用户屏蔽了大量底层细节能让我们在较高的层面进行架构设计和资源使用大大提高了工作效率。
说起我们大多数开发者最常用的IaaS服务恐怕要数云上虚拟机了。没错虚拟机肯定会是我们IaaS部分的讲解重点。不过在此之前我想让你对整个云端数据中心先建立起一个高屋建瓴的认识帮助你理解宏观层面的重要概念。这会对我们后续的学习大有裨益。
所以第一讲,我们就先来谈谈云计算中的区域和可用区。
指点江山,云计算中的“区域”
云计算中最顶层的概念就是区域Region了。在大家的日常认知中它当然是一个地理概念。而在云计算行业中区域对应的则是云计算厂商在某个地理位置提供的所有云服务的组合是厂商对外提供云服务的基本单位和容器。
所以绝大多数的云服务,都会按区域进行部署和落地;用户使用的所有云资源,也都会隶属于一个区域,这通常是在创建资源时就确定了的。
常见的区域我们一般以国家或地区命名也经常辅以城市和序号予以区分。比如阿里云的华北1区青岛、华北2区北京以及AWS的美国西部1区加利福尼亚北部、美国西部2区俄勒冈州等。
与此同时每个区域还会有个字母数字构成的区域代号Region ID或Region Code。比方说阿里云华北1区代号为cn-qingdaoAWS美国西部1区的代号为us-west-1等。这些代号方便我们在程序或脚本中对区域进行唯一指定有时也会出现在门户控制台URL中。
阿里云的全球区域
当然,想要开设一个新区域,绝非一件容易的事情。它有点像网络游戏中的“开服”,包含了云计算服务商在某个地区建立数据中心,安置大量的计算、存储、网络等硬件资源,以及部署虚拟化、服务组件、资源调度等各种复杂软件,最后与外界互联网相连,获得批准对外提供云服务的全过程。
所以区域的设立和分布,相当程度地体现了云厂商的业务重点和地区倾向。小型云厂商一般会着重在个别国家或地区深耕;而大型云厂商实力雄厚,会在全球范围内拥有众多开放区域,以便用户能够在全球范围内管理和部署自己的应用。
考虑到经济效益和地理冗余,在典型情况下,云厂商设置的不同区域之间的距离,一般为数百公里或以上,这也对应了单个区域能够辐射和服务的范围。
云厂商在选址时一般会有两种思路:
一种是考虑放在人口稠密的中心城市,离用户和商业更近,以提供较快的接入体验;
另一种则是在相对偏远的地区,当地往往能够提供良好的气候条件、充足的建设空间,以及较低的电力、带宽等运营维护成本。
AWS在中国开设的两个区域就是典型的例子由光环新网运营的北京区域位处繁华都市由宁夏西云运营的宁夏区域则地广人稀。有时这样的搭配被称为“前店后厂”模式。
如何选择云上“区域”?
区域是如此的重要,所以不仅是云厂商,我们的应用和架构,同样需要先挑选最合适的落脚点。那么,当我们作为用户时,应该如何选择合适的区域呢?
首要的考量因素,当然在于区域的地理位置本身。这很好理解,我们需要让它尽可能地靠近我们应用所面向的最终用户,来保证更快的接入速度。
比如,当我们主要面向中国大陆用户服务,那自然不需要考虑其他国家的区域。
而如果我们的应用是具有鲜明地域性特征的服务像是搭建一个面向华东地区的本地生活服务那就应该更细致地就近选择区域了比如说我可以选择阿里云的“华东1杭州”或者“华东2上海”等区域。
另外,如果你的场景中需要本地数据中心与云端进行互联,也就是混合云架构,那么同样也需要事先注意云区域的地理位置选择。混合云的专线接入,一般以同城或短距离接入为主,这样你也能够较好地控制费用,同时提高线路的稳定性。
第二个考量因素,非常重要而又容易被忽视,那就是区域之间云服务的差别。
前面我们提到区域是云计算物理上存在的一个基本单位所以从IaaS到PaaS各项云服务的落地也是按照区域进行的。换句话说同一个云在不同的区域所能提供的服务和规模可能是不同的。
小提示:厂商通常会大力宣传新机型或新服务的推出,但关于这个新服务在哪些区域可用的信息时常会淡化处理,放在不那么显眼的位置。我们需要特别注意这些信息。
因此,你就非常有必要在选择区域之前,先摸清楚相关区域的具体某项服务的可用性。
比如生产环境需要一些GPU机型来运行深度学习工作那你就一定要通过官方网站查询最好是进行实操验证来确认理想的GPU机型真的存在于你所准备选择的区域或者你看到云厂商发布了全新数据库服务那么在技术选型时也千万不要忘记验证一下该服务在你选择的区域是确实可用的。
另外,区域的“开服时间”,也往往会与区域内云服务的可用性有比较大的关联。
一般来说,新开服的区域通常会落地最新一代的硬件和云端服务,也有非常充沛的资源可供调用,但它未必能迅速覆盖该云的所有服务,相关支持团队可能也需要进行磨合。
而历经时间考验的老区域,则通常会拥有更为丰富的产品选择和成熟的技术支持,但有时对新特性的部署和落地,可能会因为原有条件的限制而进展得缓慢一些。如果早期规划过于保守,极端情况下还可能出现局部“满服”而无法扩展某类资源的尴尬局面。
小提示:不同云不同区域的实际情况千差万别。我上面说的这些,只是给了你一定的判断思路,仅供参考。必要时,你应当咨询云销售或客服来获取更细致的信息。
总而言之,新旧区域哪个更好,并不能一概而论,需要根据你的服务需求和待选区域的实际情况来综合衡量。
第三个区域选择的考量因素,则是成本因素。即便是同一种服务的价格,在不同区域也往往是不相同的。
当你的应用需要大批量地采购同一种型号的虚拟机时或者是你想利用云存储设立一个大规模的云端备份中心我都建议你仔细比对一下不同区域相关服务的价格也许你会有惊喜的发现。个别区域会具有明显的价格优势比如阿里云的华北5区呼和浩特和AWS中国的宁夏区域以此来吸引用户的入驻。
谈到成本,这里我还想补充说明一下区域的流量费用,是你需要注意的。如果把区域作为一个有边界范围的实体圈起来,这个流量可以分为三类:入站流量、出站流量和内部流量。在现代云计算的计费框架下,一般会倾向于让入站流量和内部流量免费或接近免费,而出站流量则单独收费。
多区域架构浅谈
接下来我们谈谈多区域架构,它指的是部分关键应用,为了追求最佳的用户体验和高可用性,需要把多个区域的资源和能力结合起来进行构建。
你首先需要了解,主流云厂商在跨区域方面也进行了大量建设和投资,主要体现为:
物理上各区域之间建设有网络互联专线一般称为骨干网Backbone。骨干网的存在使得同一个云在不同区域间的通信能够有较高的带宽和较低的延时。
软件层面,允许位于不同区域的虚拟网络跨区域进行互联,使得多区域的私有内网能够借助自有骨干网无缝高速打通。
DNS解析层面通常会提供就近解析和智能路由能力将分布广泛的C端流量引流到最近的数据中心以获得最快的响应速度。
AWS全球骨干网来自AWS官网
由此可见,公有云的基础设施(尤其是骨干网的存在)能够极大地方便我们构建多区域的应用程序。为了让你对云的骨干网有一个感性的认识,我们来进行一个动手小实验,实际感受一下区域间互联的吞吐能力。
我们就以AWS中国的北京区域和宁夏区域作为例子。
首先我在两边都各自创建了一台虚拟机在获取IP并开放相应端口后使用iperf3工具进行网络吞吐能力测试。先让一端作为服务器在某个端口监听
[ec2-user@ip-172-31-xx-yy ~]$ iperf3 -s -p 3390
-----------------------------------------------------------
Server listening on 3390
-----------------------------------------------------------
然后让另一端作为客户端,发送数据进行测试:
[ec2-user@ip-10-0-1-101 ~]$ iperf3 -c aa.bb.cc.dd -p 3390
Connecting to host aa.bb.cc.dd, port 3390
[ 4] local 10.0.1.101 port 43640 connected to aa.bb.cc.dd port 3389
[ ID] Interval Transfer Bandwidth Retr Cwnd
[ 4] 0.00-1.00 sec 53.6 MBytes 450 Mbits/sec 0 3.11 MBytes
[ 4] 1.00-2.00 sec 61.2 MBytes 514 Mbits/sec 0 3.11 MBytes
[ 4] 2.00-3.00 sec 61.2 MBytes 514 Mbits/sec 0 3.11 MBytes
[ 4] 3.00-4.00 sec 61.2 MBytes 514 Mbits/sec 0 3.11 MBytes
[ 4] 4.00-5.00 sec 60.0 MBytes 503 Mbits/sec 0 3.11 MBytes
[ 4] 5.00-6.00 sec 60.0 MBytes 503 Mbits/sec 0 3.11 MBytes
[ 4] 6.00-7.00 sec 61.2 MBytes 514 Mbits/sec 0 3.11 MBytes
[ 4] 7.00-8.00 sec 61.2 MBytes 514 Mbits/sec 0 3.11 MBytes
[ 4] 8.00-9.00 sec 61.2 MBytes 514 Mbits/sec 0 3.11 MBytes
[ 4] 9.00-10.00 sec 60.0 MBytes 503 Mbits/sec 0 3.11 MBytes
- - - - - - - - - - - - - - - - - - - - - - - - -
[ ID] Interval Transfer Bandwidth Retr
[ 4] 0.00-10.00 sec 601 MBytes 504 Mbits/sec 0 sender
[ 4] 0.00-10.00 sec 599 MBytes 502 Mbits/sec receiver
iperf Done.
可以看到在并发度默认为1且未做任何调优的情况下距离上千公里的双机点对点传输就达到了500Mbps以上效果相当不错。
小提示网络传输速度受到许多因素的影响。此处的测试结果数字仅供参考不代表所测专线的实际带宽能力。事实上通过增加并发度如iperf3的-P选项并加强机器配置等你还可以成倍地提升测试效果。建议你一定要结合自己的云环境和需求场景进行实际的测试。
所以,在骨干网的加持下,通过合理架构完全可以让多个区域的云服务融为一体。借助云的力量,小厂也能轻松拥有巨头的分布式部署能力。
在应用架构层面,多区域并不意味着,我们需要把某区域的资源依葫芦画瓢复制到其他区域,而是可以根据实际情况各司其职,让不同区域担任不同的角色,联动起来达到业务目的。
比如我们可以将面向消费者服务的触点部署到多个区域就近服务各地区的互联网流量而偏后台的数据分析和BI服务则可以安置在性价比较高的非一线城市区域业务数据可通过骨干网不断回传。这是一种经典的分工模式。
当然,多区域架构固然诱人,我们也不应当走向另一个极端:轻率、随意地拓展区域。因为每一个区域的增加,都会相应增加应用架构的复杂性和流量费用,也给我们的维护工作带来负担,这些额外的成本可能会抵消多区域架构带来的好处。
什么是“可用区”?
除了“区域”之外很可能你还听说过“可用区”Availability Zone这个术语它同样是非常重要的概念。因为看上去和区域有点相似有些同学会把它们等同看待。事实并非如此。
可用区是区域的下级概念,是指一个具备完整而独立的电力供应、冷却系统、网络设施的数据中心单元。一个区域通常由多个可用区高速互联组成。区域内的可用区一般位于同一个城市,之间相距往往在一百公里以内。
所以物理上的“数据中心”和“机房”概念,若要严谨地对应到云端,其实是在可用区这个层面。
你可能会问,一个区域看上去拥有一个数据中心就足够了,为什么还要建造多个可用区呢?
首要的原因,当然是为了解决区域内高可用性问题,这也正是“可用区”名字的由来。尽管数据中心内部有着非常精密的运作系统和冗余机制,但地震、火灾、雷击等极端情况下,仍有可能造成数据中心级别的故障。
为了避免单个数据中心故障让整个区域不可用那自然就有必要建设多个相对独立的数据中心也就是多个可用区了。它能让区域中的服务达到相当高的可用性。许多云上的PaaS服务正是依赖多可用区来建设架构并保证冗余的。
所以你在设计IaaS层面架构时也可以利用可用区来实现自己的业务效果。比如在创建云虚拟机时我们是可以指定可用区的
阿里云华东1区可用区选择界面
多可用区架构的选择,与前面探讨的多区域架构类似,同样是一个在网络性能、成本、可用性之间权衡的问题。我们可以将资源安排在同一个可用区,以便获得较高的网络互访性能;也可以安排在不同的可用区,以实现故障隔离和服务冗余。
区域需要多个可用区的另一个原因,在于区域本身有扩展的需求。一些区域由于早期的容量规划和成本控制原因,很可能在若干年的运营后就会变得资源紧张、后劲不足。
这时得益于可用区的机制,区域可以通过新建可用区,不断扩展自身容量,补充新鲜血液;而老旧的可用区,则可不对新用户开放,逐步封存甚至淘汰,这让区域形成了良好的新陈代谢机制。
所以反过来讲,可用区的数量也可以成为一个衡量区域规模的重要指标。数量越多,意味着这个区域规模越大。在选择区域的时候,这个指标也可以作为重要参考。
课堂总结与思考
今天是我们《深入浅出云计算》的第一讲,主要讨论了区域和可用区这两个核心概念。我把这一讲的要点简单总结如下:
区域是云计算的顶层概念,云服务以区域为单位对外开放;
区域选择需要考虑多种因素,包括但不限于地理位置、服务丰富性、开服时间、资源成本、可用区数量等;
可用区是区域之下的重要层级,代表独立的数据中心,一个区域内往往有多个可用区;
妥善将资源分布到不同可用区,可实现故障隔离,提升架构的可用性。
在讲解这些内容的同时,今天我们也触碰到了网络和高可用架构等相当硬核的话题。如果你觉得还不过瘾,也没有关系,后续会有相关的专题章节作进一步的探讨。
最后,我想给你留下两个思考题,欢迎你在留言区和我互动:
你日常接触的云计算区域是哪些?你有察觉到区域之间的一些差别吗?
在2019年AWS re:Invent大会上亚马逊还推出了全新的“本地区域”Local Zone概念。这又是为什么场景所设计的层级上它更接近“区域”还是“可用区”呢
如果你觉得有收获,也欢迎把这篇文章分享给你的朋友。感谢阅读,我们下期再见。

View File

@ -0,0 +1,179 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
02 云虚拟机(一):云端“攒机”,有哪些容易忽视的要点?
你好,我是何恺铎。
前一讲我先从数据中心的角度入手和你讲解了云计算中“区域”和“可用区”的概念帮助你建立起了大局观。今天我们就开始进入微观层面来介绍和讨论IaaS中最重要的核心服务云虚拟机。
我想,你可能对虚拟机并不陌生,现在虚拟机的应用已经很普遍了。传统的物理服务器上通过安装虚拟化软件,就可以虚拟出多个互相隔离的虚拟机,来帮助我们提高资源的使用效率。云计算中的虚拟机,本质上也是如此,也是底层计算存储能力的抽象和开放。
所以你也许会问那么云虚拟机到底有什么值得讨论的呢看上去也就是选取CPU、内存、硬盘几大件然后启动后登录使用似乎没有什么新鲜的东西
没错,云虚拟机粗看起来和传统服务器较为类似。但当你对它的应用逐渐深入、规模不断加大时,就非常有必要去深入了解云虚拟机的特点了,因为你开始需要针对不同的场景进行选型,也要在性能和成本间找到最佳的平衡,让你的应用效益最大化。
因此,我接下来就会用三讲课程,为你详细讲解下云端虚拟机的“门道”。
云虚拟机到底是什么?
云虚拟机,顾名思义,是在云端虚拟出的服务器。这个服务器你可以完全地控制它,从底层操作系统到安装上层应用。
站在技术实现的角度来讲虚拟化技术是云虚拟机服务的核心它本身是一个非常宏大的技术领域。比如你可能听说过Xen、KVM、VMWare、HyperV等等虚拟化产品和技术。云计算中所使用的虚拟化技术也大都是从这些虚拟化实现方式演化而来的。
作为开发者,我们当然不需要成为虚拟化技术专家。我们只需要知道,云端的虚拟化技术在不断进步和发展,使得云端虚拟化的性能损耗在不断减少、资源利用率不断提升就可以了。但你很有必要去了解云计算中虚拟机的体系结构,这也是云虚拟机与传统虚拟机的最大不同。
云虚拟机的体系结构,用一句话来概括一下,就是全面解耦的计算存储分离的设计思想。
小提示:计算存储分离是云计算设计理念中最重要的思想之一,不仅仅体现在虚拟机上,也体现在其他的云服务架构中。我们今后还会不断涉及。
传统的虚拟化,往往是对单一物理机器资源的纵向切割,计算、存储、网络等各方面的能力都是一台物理机的子集。因此,从可伸缩性的角度来说,传统虚拟机存在较大的局限,当物理机的局部出现故障时,也很容易影响到里面的虚拟机。
得益于云端大规模的专属硬件以及高速的内部网络云虚拟机的组成则有所不同。除了核心的CPU与内存部分仍属于一台宿主机外它的网络、硬盘等其他部分则可以超脱于宿主机之外享受云端其他基础设施的能力。大致架构如下图所示
你要注意的是,这里我所给出的仅仅是一个简化加工之后的示意图。实际的云计算内部实现,会远比这个要复杂和精妙。不同的云的内部,也会有许多不同的专用硬件各显神通。
所以,云虚拟机,与其说是由一台宿主机虚拟而成的,不如说是云数据中心中的不同部分一起协作,“拼凑”而成的一台机器。这样虚拟出来的机器,我们在使用感受上其实与传统服务器并无不同,但在可扩展性和故障隔离方面,它就具有很大的优势了。
举个例子来说一台云虚拟机它可以同时挂载很多硬盘还能够插上很多“网卡”拥有多个不同的外部IP。这就是充分解耦带来的好处。
各家厂商的云虚拟机服务的名称会略有不同阿里云称为云服务器ECSElastic Compute ServiceAWS称为EC2Elastic Compute CloudAzure就叫Virtual Machine腾讯云则叫做云服务器CVMCloud Virtual Machine等等。
这里,你需要注意将虚拟机服务和一些建站类服务区分开来,因为它们有时在名称上可能比较类似。比如“云主机”这个叫法,很多云上就是指云虚拟机,在个别云上对应的却是简单建站服务,请你注意不要混淆。
扩展建站类服务主要是提供一些网站的托管运行环境如PHP。它是一个相对受限的环境严格来说属于PaaS服务的范畴比较注重易用性。而虚拟机呢则提供了一台真正意义上的服务器从操作系统到上层应用都可以自己控制比起建站类服务来说要开放、通用得多。
虽然各个云厂商对云虚拟机有不同的叫法但它们的产品形态是比较一致的。当你来到虚拟机服务的门户一般会有一个列表界面能够列出当前你拥有的所有虚拟机你可以按照不同字段过滤、删选、排序。你还可以点击某个VM查看详情界面一般会展示出VM的常用运行指标。
AWS EC2自带的指标监控
云端“攒机”实战
讲到这里,你已经基本了解云虚拟机的概念了。接下来,让我们进入云虚拟机的实际操作环节。
所有的云上创建虚拟机时一般都会有相当贴心的向导你可以在虚拟机门户上点击“创建”然后按照步骤一步步进行即可。今天我们就以在阿里云上创建Linux虚拟机为例帮你把“攒机”时最主要的环节串一串同时顺便给你介绍一下那些在“攒机”时容易被忽视但又非常关键的要点。
小提示在本次实验中建议你选择“按量付费”的付费模式这也是云计算的经典付费模式。这种模式是按虚拟机的使用时间付费比较适合短期实验。当然更多付费模式都各有特点后面的第4讲中我们会进行比较和探讨。
第一步,当然是选择和确认虚拟机的所在区域。区域的概念,我在上一讲中已经提到过,它决定了虚拟机的地理位置。
小提示在部分云中区域是顶级概念指定新建虚拟机的区域需要你事先在门户的右上角进行选择和切换如AWS。
这样,新建的虚拟机就会处于你当前选择的区域。你还可以指定区域内的特定可用区。
随后就是虚拟机的配置确认环节也就是我们通常所说的什么型号、几个核、几G内存的选择。配置的选择无疑非常重要我会在下一讲着重介绍这里我们先不妨选择默认的2核8G配置。
接着就有你需要注意的一个要点选择操作系统镜像。在这里你可以选择虚拟机所要安装和使用的操作系统比如常见的CentOS和Ubuntu同时你也需要选择这个系统具体的版本号。
在操作系统的列表中你往往会看到厂商的自有操作系统比如阿里云的Aliyun Linux、AWS的Amazon Linux等。这是一个很有意思的事情。既然已经有诸多流行的Linux发行版了为什么云厂商还要推出自己的Linux版本呢我们什么时候才应该考虑使用它们呢
你可以这样理解:
首先厂商的Linux版本在理论上会和自己云上的硬件有更好的适配这样能够更充分地发挥相关硬件的性能。一般来说厂商也会在自己的云上进行充分的测试和验证。
其次,在内核和基础组件的选择上,厂商专有操作系统往往会根据自己的需求判断,来进行一些取舍和裁剪,所以一般会有一个相对苗条的身材,占用比较小的磁盘空间,同时启动速度更快。这是一种更适合云环境的选择,尤其是当你的虚拟机集群规模较大时,就能够显出规模经济效应了。
再次厂商操作系统会预装和云的使用操作方面的一些软件包和SDK能够为你提供便利。比如说厂商一般会预装该云的命令行工具CLICommand Line Interface像是AWS CLI等。
另外当然也有云厂商出于“自主可控”方面的考虑想拥有自己能完全控制的操作系统不但技术上可以自主演化还能防范一些商务合作上的风险。厂商自家的PaaS服务它的底层也一般是使用自己的操作系统。
所以如果你希望操作系统有更好的软件“兼容性”或是公司有统一的标准就可以选择熟悉的老牌Linux系统而如果你有一些大规模、注重性能的业务不妨考虑尝试下厂商的Linux操作系统。
接下来在系统盘方面我们选择默认给出的40G“高效云盘”即可。云硬盘的故事非常精彩我们第5讲中会专项讨论这里你只需要保持这个默认选项就可以了。
点击“下一步”我们来到网络和安全组的配置页面。在这里你可以配置私有网络、IP、带宽等重要的网络选项。虚拟私有网络VPC同样是一个很大的话题我们会在第6讲展开学习。
这里我们简单起见请勾选“分配公网IP地址”的选项。这样创建的虚拟机会自动被分配一个公开IP地址便于我们稍后从自己的电脑直接发起连接。
今天我想着重讨论的另一个重点是接下来选项中的网络安全组Network Security Group, 简称NSG。如果这里配置不当就会直接影响虚拟机的使用。很多新同学由于不太了解这个概念常常会造成无法远程连接登录的情况。
你可以把网络安全组理解为一层覆盖在虚拟机之外的网络防火墙。它能够控制虚拟机入站、出站的流量,并能根据协议、端口、流向等所设定的规则,来决定是否允许流量通过。
所以某种程度上网络安全组和操作系统中我们熟知的防火墙如Linux的iptables和Windows防火墙一样都起到网络安全防护的作用。
但你需要注意的是它们的区别网络安全组并不工作在操作系统层面而是在操作系统层之外是额外的一层防护。非法流量在尚未到达OS的网络堆栈之前就已经被它阻断了。所以NSG的一个优点在于它不会影响VM的性能。
另外,网络安全组是一种可复用的配置。如果你有大量虚拟机适用于同样的网络控制规则,那么,你就能够很方便地让它们使用同一个网络安全组,这样你管理起来会非常方便。
网络安全组是绝大多数云都支持和实现了重要特性,它体现了云计算中软件定义网络的特点。网络安全组非常灵活,你可以随时更改,规则也会动态生效。
小提示当你在排查虚拟机的连通性相关问题时比如假设你的网站或API无法被访问那你一定要记得检查网络安全组中的设置查看它相关的端口和协议是否已经开放。
OK回到我们虚拟机创建的流程所以我们要创建或使用一个至少开放了22端口的网络安全组以便我们能够通过SSH连接上去。阿里云中就提供了方便的“默认安全组”我们只需要勾选需要开放的端口就会帮助我们生成一个安全组实例并对这台机器启用。你也可以事先手工创建一个安全组并在此处选择。
再点击下一步,我们就进入了“系统配置”阶段,在这里,你可以为实例命名,指定用于登录的用户名密码或密钥对等,这里比较简单我就不再赘述了。
然后,暂时跳过一些可选的高级设置,确认订单后,按下“创建实例”,就可以等待虚拟机的生成了。一般数十秒至数分钟之内,一台崭新的云服务器就会就绪,进入运行状态。
此时你可以通过SSH连接上虚拟机的公开IP使用hostnamectl命令查看一下虚拟机的信息一切正常。
client@clientVM:~$ ssh -i ./geektime-ali-sh.pem [email protected]
Welcome to Alibaba Cloud Elastic Compute Service !
[root@my-ecs-vm1 ~]# hostnamectl
Static hostname: my-ecs-vm1
Icon name: computer-vm
Chassis: vm
Machine ID: 201908292149004344218446xxxxxxxx
Boot ID: 2228122a7f3c4b4eb5756824xxxxxxxx
Virtualization: kvm
Operating System: Aliyun Linux 2.1903 (Hunting Beagle)
Kernel: Linux 4.19.57-15.1.al7.x86_64
Architecture: x86-6
成功地登录上去之后你就可以正常使用这台机器了。比如通常我们会使用yum或apt等包管理器进行一些应用软件的安装。
到这里我就带你初步体验完了云虚拟机的创建过程。VM类服务的本质就是租用我们通过在门户上的简单操作就能够完成一台定制服务器的“租用”过程。
从原理上说,这和租户从房东那租房子其实没有什么两样。而且云上的租用相当便捷,动动手指你就能轻松完成。唯一不同的是,云厂商一般不会把租客“扫地出门”,只要你按时付费,一般不会出现不允许续租的情况。
课堂总结与思考
在今天这一讲中,我先帮助你了解了云虚拟机的一些理论知识,尤其是一些体系结构方面的特点。然后,我们进入了创建云虚拟机的实操环节,了解了相关的流程和步骤,也讨论了其中所牵涉的一些注意事项。我强烈建议你自己也动手操作一下,完成从创建到连接的全过程,形成一个直观的感受。
我们把这一讲的要点总结如下:
云虚拟机是最重要的IaaS服务之一它基于计算存储分离的架构进行构建
云虚拟机的创建过程,由地域、机型、操作系统、存储、网络等多方面选项共同构成;
云虚拟机可使用云厂商自有操作系统,与云有较好的适配;
网络安全组是保护云虚拟机的网络防火墙,可以同时应用于多个虚拟机。
在今天我们实践的过程中,也引出了若干重要的概念和选项,如机型配置、云硬盘、云网络等等。后续我们会逐个地展开讨论,敬请期待。
最后,给你留下两个思考题:
在上面的实验当中为了便于连接我们给机器自动分配了公网IP。在生产环境中为了安全性考虑应该尽可能避免给虚拟机分配公网IP那么这时你如何连接到这些机器呢
暂时不再使用的云虚拟机,和传统服务器一样可以“关机”。关机状态的云虚拟机仍然会存在于虚拟机列表中,随时可以再启动。那么,关机之后它还会继续收费吗?
欢迎你在留言区和我互动,我会第一时间给你反馈。如果觉得有收获,也欢迎你把这篇文章分享给你的朋友。感谢阅读,我们下期再见。

View File

@ -0,0 +1,151 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
03 云虚拟机(二):眼花缭乱的虚拟机型号,我该如何选择?
你好,我是何恺铎。
在上一讲中,我带你了解了云虚拟机的大致构架和组成,实际体验了在云上建立第一台虚拟服务器的完整流程,还介绍了在创建过程中,你所需要注意的若干重要选项及其含义。
而在这些选项之中,最重要的恐怕就是虚拟机的规格了,因为它直接决定了虚拟机的计算能力和特点,同时,也会深刻地影响使用成本,是你在选型时需要考虑的重点问题。
很多同学在实际工作中,都会遇到这样的困惑:公司要上云,或者因为业务发展需要采购新的云服务器,但是在查看某云厂商的官网时,发现可选择的虚拟机型号列表很长,有点儿眼花缭乱。
那么,不同种类的虚拟机到底有什么区别呢?在选择时又应该从哪儿入手呢?
今天,我们就来详细聊聊这个话题。
建立对虚拟机配置的多维认知
完整形容一个虚拟机的核心配置和能力需要从多个角度来入手和描述。弄懂了这些重要维度的含义你才能够准确理解一个虚拟机的性能预期和使用场景从而作出正确的型号选择。这里并非只有决定CPU核数和内存大小这么简单。那么主要是哪几个维度呢
第一个维度,就是虚拟机的“类型”,或者说“系列”。
这是一个非常重要的概念,它是指具有同一类设计目的或性能特点的虚拟机类别。
一般来讲,云厂商会提供通用均衡型、计算密集型、内存优化型、图形计算型等常见的虚拟机类型。这些类型对应着硬件资源的某种合理配比或针对性强化,方便你在面向不同场景时,选择最合适的那个型号。
而vCPU数和内存大小按GB计算的比例是决定和区分虚拟机类型的重要指征之一。
通用均衡型的比例通常是1:4如2核8G这是一个经典的搭配可用于建站、应用服务等各种常见负载比如作为官网和企业应用程序的后端服务器等。如果你对未来工作负载的特征还没有经验和把握那你也可以先使用通用型实例等程序运行一段时间后再根据资源占用情况按需调整。
如果vCPU和内存比是1:2甚至1:1那就是计算密集型的范畴它可以用于进行科学计算、视频编码、代码编译等计算密集型负载。
比例为1:8及以上一般就会被归入内存优化型了比如8核64G的搭配它在数据库、缓存服务、大数据分析等应用场景较为常见。
图形计算型很好理解就是带有GPU能力的虚拟机一般用于机器学习和深度学习模型的训练和推理。随着AI的火热这类机器也越来越多地出现在各种研发和生产环境中。
在主流云计算平台上常常使用字母缩写来表达虚拟机系列。比如AWS的通用型是M系列阿里云的内存优化型为R系列Azure的计算优化型为F系列等。
不同云平台之间使用的字母可能相同也可能大相径庭你在记忆时需要小心不要混淆。在这里我根据各家2020年的最新情况简单整理了一个表格供你参考
需要注意的是,上表中还提到了本地存储型,它是指带有高性能或大容量的本地存储的机型。我们在后续讨论云盘的课程中还会提到,这里你先了解一下就可以了。
第二个重要的维度,是虚拟机的“代”(Generation),用来标识这是该系列下第几代的机型。
我们知道,数据中心硬件和虚拟化技术是在不断发展的,云厂商需要不断地将最新的技术和能力推向市场,让你享受到时代进步带来的技术提升。这和我们个人用的笔记本电脑是非常类似的,笔记本厂商也总是在不断地更新设计和配置,以赢得消费者的青睐。所以即便是同一系列的机型,不同的代别之间也会有不小的区别。
具体来讲呢同类型虚拟机的更新换代往往首先会带来相应硬件CPU的换代提升。随着一代新机型的推出云厂商一般都会详细说明背后支撑的硬件详细信息。
比如说AWS在2017年末在全球发布的新一代EC2实例M5/C5/R5它们的背后是升级到了Skylake架构的Intel至强铂金系列处理器相比前一代采用的Broadwell或Haswell架构处理器进步了不小还支持了可大幅提升矢量和浮点运算能力的AVX-512指令集。
再比如阿里云在2019年的云栖大会上也盛大发布了第六代ECS它全线采用了更新一代的Intel至强Cascade Lake处理器相较前一代的Skylake实例又在性能、价格优势等各方面有了进一步提升。你可以参考下面给出的截图
阿里云第六代ECS内存型型号选择界面
这里你需要特别注意正是由于虚拟机所采用的物理CPU在不断更新所以云上虚拟机的单核性能未必相同。有时虽然两个虚拟机的核数一致但由于底层芯片的架构和频率原因性能上可能有较大的差别我们需要注意在不同机型间做好比较和区分。
像微软Azure就引入了Azure Compute UnitACU的概念来帮助量化不同CPU的单核性能。比如其历史较久的通用型A系列它的单核性能基准为100单位而计算型的F系列的单核算力则高达210~250是A系列的两倍还多。
另外你还应当看到云虚拟机的换代更新并不仅仅只在CPU等硬件配置层面很多时候也伴随着底层软硬件架构的更新和提升尤其是虚拟化技术的改进。
前面我提到的AWS第5代EC2实例正是全面地构建在AWS引以为傲的Nitro System新一代虚拟化技术栈之上。
Nitro System的本质是将许多原来占用宿主机资源的虚拟化管理工作进行了剥离并将这部分工作负载通过Nitro Card这样的专用硬件进行了硬件化达到了最大化计算资源利用率的效果。在这一点上阿里云的神龙架构也采用了类似的做法与AWS Nitro可谓一时瑜亮有异曲同工之妙。
总的来说,我们消费电子产品时的“买新不买旧”,在云端同样适用。新一代的型号,往往对应着全新的特制底层物理服务器和虚拟化设施,能够给我们提供更高的性能价格比。
所以,有些云平台在选择虚拟机型号时,会贴心地默认隐藏相对过时的型号。当然在个别情况下,比如数据中心的新机型容量不足,或者老型号有促销活动时,你也可以酌情选用之前的型号。
第三个重要的维度就到了我们所熟知的实例大小Size也就是硬件计算资源的规模。
在选定的机器类型和代别下,我们能够自由选择不同的实例大小,以应对不同的计算负载。
如果你只是个人用来实验那么也许单核或者双核的机器就足够了如果是要放在大规模的生产环境当中则可以按需选取高得多的配置现代云计算已经能够提供多达128vCPU的机型了。
在描述实例大小时业界常常使用medium、large、xlarge等字眼来进行命名区分这样的描述基本已经成为事实标准包括AWS、阿里云、腾讯云在内的多家主流厂商都在使用。
我们可以大致这样记忆标准large对应的是2vCPU的配备xlarge则代表4个vCPU而更高的配置一般用 _n_xlarge来表达其中 n 与xlarge代表的4vCPU是乘法关系。比如8xlarge就说明这是一台8*4=32vCPU的机器。
注意这里在进入更严谨的配置表达时我们更多倾向于使用vCPU而非核数Core来描述虚拟机处理器的数量。因为超线程HyperThreading技术的普遍存在常常一个核心能够虚拟出两个vCPU的算力但也有些处理器不支持超线程所以vCPU是更合适的表达方式不容易引起混淆和误解。
在某些场景下你可能还会看到“metal”或者“bare metal”这样的描述规格的字眼中文称为“裸金属”。它们就是云服务商尽最大可能将物理裸机以云产品方式暴露出来的实例主要用于一些追求极致性能或是需要在非虚拟化环境下运行软件的场景。
理解虚拟机命名规则
经过前面的介绍我们已经了解了决定虚拟机配置的最重要的三个要素即类型、代别和实例大小。这样一个完整的虚拟机型号命名就已经呼之欲出了。我们来看最具代表性的AWS命名规则阿里云采用的也是非常类似的格式-
这其实就是利用上述的各维度,按照某种顺序排列的一个组合。理解了这一点,当你今后看到某个具体型号的时候,就能够很快地明白该型号命名背后的含义了。
比如对于r5.4xlarge这个型号我们会很快想到这首先是一个R类型的第5代的内存型机器它应该有4×4=16个vCPU内存大小则是16×8=128G内存型机器的CPU内存比一般为1:8。这样分解下来原来看上去比较陌生晦涩的一个字符串是不是就立刻变得清晰起来了
当然并非所有的云都一定是采用类似AWS的命名规则像是微软Azure就用了一个略有不同的命名体系大致可以总结为-
比如“E4 v3”就代表了微软Azure上4核32G的第三代内存型机器。掌握了Azure的格式特征后你同样能够很快地解读标识的具体含义。
不知道你有没有注意到,在前面的命名公式中,还有一个我们称之为“后缀”的可选部分,在许多的型号命名中都能看到它。这个可选部分呢,它一般是作为型号硬件信息的一个重要补充,这种型号与不带此后缀的标准版本相比,会有一些显著的区别或特点,这也是你需要重点关注的地方。
这里我给你举一些型号后缀的例子吧。
比如AMD现在凭借EPYC霄龙芯片也开始在服务器硬件市场攻城拔寨许多云厂商就专门推出了使用AMD CPU的云虚拟机这些虚拟机往往会使用字母a作为后缀。AWS上的m5a型号就是使用AMD EPYC 7000系列服务器CPU构建的通用型虚拟机。
再比如AWS的C5n计算型虚拟机其中“n”这个后缀表达的是该规格在网络层面进行了增强会比同型号标准机型拥有更大的带宽和网络吞吐能力。在阿里云上表达相同“网络增强”含义的后缀则是“ne”。
有时为了验证机型配置是否与我们的期望相符在Linux环境下我们可以使用lscpu命令来了解手中虚拟机的CPU信息并与机器的具体型号名称进行对照。下面的信息是我在一台AWS的m5a.xlarge机型上运行的结果你可以看到芯片提供商AMD及双核四线程等关键信息与机型命名的含义相符
[ec2-user@ip-xx-yy-zz ~]$ lscpu
Architecture: x86_64
CPU op-mode(s): 32-bit, 64-bit
Byte Order: Little Endian
CPU(s): 4
On-line CPU(s) list: 0-3
Thread(s) per core: 2
Core(s) per socket: 2
Socket(s): 1
NUMA node(s): 1
Vendor ID: AuthenticAMD
CPU family: 23
Model: 1
Model name: AMD EPYC 7571
Stepping: 2
CPU MHz: 2379.224
BogoMIPS: 4399.39
Hypervisor vendor: KVM
Virtualization type: full
...
课堂总结与思考
今天,我们主要探讨了云上虚拟机的类型与规格,相关要点可总结如下:
云虚拟机的配置规格主要取决于类型、代别、实例大小三个最重要的维度。
实例所属的类型,决定了虚拟机相应的硬件资源配比与专项能力,分别为不同的场景优化设计。你可以根据实际场景来酌情选用,这样既能满足需求又好控制成本。
云虚拟机的型号名称一般由类型、代别、实例大小这几项的缩写组合而成,有时还会带有补充后缀。了解了某个云的型号格式后,通过拆分对应,你很容易理解具体型号的含义。
最后,作为今天的交流讨论题,你可以回忆一下,在生产或测试环境中,使用过的最强劲的云端机型。你注意过它是什么系列、什么型号的吗?它主要被用于什么业务场景呢?
欢迎在留言区和我互动,我会第一时间给你反馈。如果觉得有收获,也欢迎你把这篇文章分享给你的朋友。我是何恺铎,感谢阅读,我们下期再见。

View File

@ -0,0 +1,177 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
04 云虚拟机(三):老板要求省省省,有哪些妙招?
你好,我是何恺铎。让我们继续云虚拟机的话题。今天这一讲,我想从一个不一样的视角,也是你会很感兴趣的一个角度来进行讨论,那就是成本。
的确,很多时候,我们上云的障碍是在于价格。
打个比方吧,假设我们要为公司的业务上云进行虚拟机采购,这时如果你只是简单地将物理服务器的报价,与按量付费模式下的“通用型”云虚拟机进行对比,那你很容易就会得出云上机器太贵的结论。
但其实呢,在云上,我们有很多实用的招数来控制虚拟机的成本,是可以“少花钱、多办事”的。
那么,都有哪些省钱的妙招呢?今天我就来“偷偷”告诉你。
省钱妙招之一:使用包年包月机型
包年包月,可能是我们最先会想到的降低成本的办法了。
顾名思义,包年包月就是我们要提前预估好自己虚拟机的使用时间,比如半年、一年甚至三年,并提前支付相关款项的一种购买方式。这样的购买方式,通常能够给你带来较大幅度的折扣,帮你显著地节约成本。
云厂商其实是鼓励和欢迎虚拟机包年的,因为这样降低了云端动态租用的不确定性,减少了服务器空置的情况,也为厂商做中长期的数据中心容量规划提供了便利。另外一方面,包年包月一般都是先付费的模式,所以从财务层面上看,也有利于厂商的现金流。这些都是采用包年包月方式能够获得让利的原因。
在许多国内云厂商的虚拟机创建界面上,包年包月甚至成为了默认的选项,你需要注意在界面下方选择购买的时长。时长越长,你能获得的折扣越大。
那么包年包月具体能帮我们省多少呢这没有一个唯一确定的答案。因为不同的云、不同的区域以不同的时长购买折扣力度都可能有所不同。通常来讲一般常见的机型在3~7折不等。
不过,在包年包月的模式下,也有一些你需要注意的问题。
首先,这个模式意味着我们牺牲了一些资源安排上的灵活性。因为在它到期之前,你一般是无法取消的,或者在某些云上,即便是允许你取消,也需要扣除一部分费用,这就像我们买了保险后中途退保一样,就要承担一些损失。
另外,包年机给我们带来了一个后续维护工作:续费管理。尤其是当包年虚拟机的数量陆陆续续变多时,由于创建时间不同,到期时间也就比较分散,那么续费的工作就变得更加复杂和重要起来。如果忘记续费,过了缓冲期后,机器会被自动关闭甚至删除,那就会影响业务的连续性。这是你需要小心的一个地方,千万不要错过云上的续费提醒。
省钱妙招之二:使用竞价实例
相比包年包月的广为人知竞价实例Spot Instances的知名度似乎小一些。但如果运用得当竞价实例其实威力巨大这也是我十分推荐你去尝试和使用的一种省钱的办法因为它往往能够提供相当大幅度的折扣。
竞价实例是AWS所首创的产品形式其他的云厂商近几年也在纷纷跟进。它的基本原理是把云数据中心上闲置的机器资源拿出来进行公开的拍卖价高者得。让“市场机制”也就是各个用户来主导这些闲置资源的定价。
因为是闲置资源所以大家的出价都会比较低颇有一点共同来“薅羊毛”的意思。所以在很多时候你甚至能够拿到相对标准按时计费价格1~2折的折扣力度这无疑是非常有诱惑力的。而对于厂商而言这也不是什么坏事因为这些资源本就闲置还不如顺水推舟、对外开放以获得一些回报。所以说竞价实例是一个伟大的发明是一种双赢的机制。
但也因为是闲置资源,所以它主要的限制在于可能会被随时回收。
当数据中心的闲置资源不足时,比如说,有人要创建大批更高优先级的、“正牌”的非竞价实例,或者当竞价市场涌入大量土豪,推动市场价格高于你的最高出价时,你的虚拟机就会被停止运行,并自动回收(一般会有一个提前数分钟通知的机制)。
因此,竞价实例能够有较低折扣的本质,是在于牺牲了稳定性。所以,你在使用竞价实例时,还需要注意选择场合。生为竞价实例,就要时刻有“退位让贤”的觉悟和准备。
如果你要搭建一个对外服务的网站或者是数据库的话这些需要24小时不间断运行的生产负载就并不适合跑在竞价实例上。竞价实例非常适合的应用场景包括一些后台批量计算、爬虫、性能测试等等。这些无持久化状态、可打断的工作今后你可以第一时间想到用竞价实例来支撑。
竞价实例也是按照运行时间来付费的,你可以随时主动关闭和终止。所以,这种方式的动态性还是不错的,你可以随时按需启停。
小提示:在实操时,你需要注意一下在创建界面上选择竞价的策略。常见的竞价方式有两种,一种是手动设定你所能接受的最高价格,一种则是选择跟随市场价格的变动,也即自动出价。这和我们买卖股票时的操作选择很类似。
省钱妙招之三:使用突发性能类型
对于一台固定配置的服务器来说总是会或多或少地存在资源闲置的情况。比如说我们为了潜在的工作负载申请了比较强劲的CPU资源但也就是在业务高峰到来的时候服务器才能够发挥出全部实力。而在相对长得多的业务低谷期机器的CPU资源利用率其实会比较低。
因此我们常常可以见到一些服务器CPU平均使用率非常低下这显然是一种巨大的成本浪费。
而云端的架构,天生就善于解决资源闲置问题。
一种解决方法是我们可以使用可动态调整规模的集群来应对弹性计算场景这样可以灵活设定动态扩缩容的机制以达到减少低谷期资源占用的目的我会在后面的架构部分进行专门讨论而另一种方法则更加简单且适用于单机那就是采用突发性能类型Burstable Performance Instances。这是一种非常实用而又有趣的虚拟机类型有时它也被称为“可突增性能实例”。
突发性能类型同样拥有指定的vCPU数量、内存大小等配置但其成本显著小于类似配置的其他类型机器。它的主要区别在于这种类型的虚拟机的CPU性能表现采用的是积分制其积分会随着时间的推移匀速累加也会随着算力的输出而被不断消耗。
当积分充裕时CPU可按需跑满达到CPU性能的100%同时会较快地消耗积分当积分不足或耗尽时则CPU只能发挥出标称值的一小部分性能。这个小部分的比例值我们称它为性能基准它与积分匀速累加的速度相一致。
小提示突发性能实例的性能基准通常在峰值的5%~40%不等,具体比例按不同云厂商不同实例而定,你可以查询官方文档进行确认。
我们可以把突发性能类型,理解为性能有一定折扣和弹性的机型。
当重型计算负载来临时,积分的存在和积累,使得这些机器具备自动消耗积分,并获得临时“突增”性能的能力。就像是汽车的发动机,可以通过“涡轮增压”获得短时动力,来增强汽车的输出功率一样。
积分的积累虽然会有一个上限但一般也足够它全速计算数个小时了。下图中我给出了一个实际场景中某突发性能VM实例的积分曲线你可以看到积分额度在匀速积累、到达上限以及开始消耗的全过程
AWS突增性能类型的CPU积分曲线示例
再回到我前面所说的波峰波谷计算场景很显然突发性能实例的积分制特性恰好可以大显身手。比如对于符合流量自然特征的互联网业务来说在负载较低的深夜和清晨性能突增实例处于较低的CPU占用率状态同时积攒积分当白天流量高峰到来时CPU则可以消耗积分发挥其全部性能保障业务稳定运行。
性能突增类型目前在各大云上已经比较常见了在AWS和阿里云上对应的是T系列虚拟机在微软Azure上则对应B系列。
从成本上来看,突发性能实例和相同配置的通用机型相比,典型情况下,其折扣大约可以达到六折或更低。所以说,性能突增类型虚拟机的引入,非常有助于提高资源利用效率,推荐你在负载具有时效性的情况下酌情选用。
省钱妙招之四使用ARM实例
说到ARM处理器相信你并不陌生。随着移动互联网的高速发展和智能手机的普及ARM早已走进千家万户。而且在庞大的手机市场的催化下ARM芯片的性能也在不断地取得突破开始接近甚至达到x86处理器的水平。
在移动端取得了统治性的地位后踌躇满志的ARM开始进军服务器端。低功耗、高性价比成为它开拓市场的法宝。而极具规模效应的云计算又可以说是ARM服务器芯片的最佳试验田。
所以使用ARM架构芯片的虚拟机实例已经成为云计算IaaS层不容忽视的新潮流。
同时因为ARM是一个相对开放的架构具备芯片设计和制造能力的大厂商就纷纷开始自建芯片。厂商通过自行定制就可以针对云上场景和需求进行优化进一步降低单位算力的成本巩固自己的竞争优势。
举个例子AWS近些年就在大手笔地投入它自家基于ARM的Graviton处理器。在re:Invent 2018大会上推出了第一款基于Graviton的A1类型EC2实例而在re:Invent 2019大会上AWS更是再接再厉发布了基于第二代7纳米Graviton芯片的M6g、R6g、C6g全系列的虚拟机服务。这里的后缀g就代表Graviton。
在国内也有像阿里、华为这样具备端到端硬件研发能力的巨头在进行芯片自研并在云端开始落地商业化。比如华为在2019年发布了基于ARM的鲲鹏920处理器性能十分强大也达到了世界领先水平。与之匹配华为云也推出了搭载鲲鹏处理器的KC1系列的虚拟机。
那么使用ARM处理器的机型对用户来说有什么吸引力呢
答案同样是成本。根据厂商的测算输出相同性能的ARM机型能够帮助用户节省30%~40%的成本这当然也是得益于ARM处理器的高性价比特点。所以说它是我们节约成本的又一个有力手段。
今天我们的实操部分,就来尝试一下风头正劲的鲲鹏云虚拟机。
我在华为云的北京四区创建了一台kc1.large.2的双核4G机型操作系统选择了Ubuntu 18.04的ARM版。创建的过程和普通x86虚拟机类似这里就略去不表了。
机器启动后我们通过SSH登录上去查看系统信息
root@ecs-kc1-large-2-linux-20200115174501:~# uname -a
Linux ecs-kc1-large-2-linux-20200115174501 4.15.0-70-generic #79-Ubuntu SMP Tue Nov 12 10:36:10 UTC 2019 aarch64 aarch64 aarch64 GNU/Linux
这是如假包换的ARM架构用Linux自带的bc命令来简单算个Pi值跑个分
root@ecs-kc1-large-2-linux-20200115174501:~# time echo "scale=5000; 4*a(1)" | bc -l -q
3.141592653589793238462643383279502884197169399375105820974944592307\
81640628620899862803482534211706798214808651328230664709384460955058\
...
...
74351362222477158915049530984448933309634087807693259939780541934144\
73774418426312986080998886874132604720
real 0m22.325s
user 0m22.316s
sys 0m0.009s
我们可以看到机器仅用了22秒就完成了精确到小数点后5000位的PI值成绩还是相当不错的。
注意这里使用bc命令以及其中的三角函数来计算Pi值只是直观展示CPU能力的简便方法。结果仅供参考不推荐这个方法作为严肃性能测试的依据。
不过你可能会有点担心ARM在服务器端的软件生态。诚然ARM体系结构下的软件的确比不上x86架构那样丰富但在近年相关厂商的大力推动下其实已经取得了长足的进展。比如在我们这台KC1服务器的Linux操作系统中已经默认安装了Java、 Python等语言和运行环境。你甚至可以使用apt包管理器来安装Docker并在ARM服务器内运行Docker容器你可以参考下面给出的示例
root@ecs-kc1-large-2-linux-20200115174501:~# docker run hello-world
Hello from Docker!
This message shows that your installation appears to be working correctly.
To generate this message, Docker took the following steps:
...
...
For more examples and ideas, visit:
https://docs.docker.com/get-starte
这简直太棒了这意味着ARM服务器同样可以支撑容器我们可以在上面跑微服务这会为各种应用在ARM上的部署打开方便之门。
所以说云计算让ARM服务器这个看起来比较遥远的事情变成了触手可及的现实。随着华为鲲鹏等相关计算生态的不断成熟基于ARM的虚拟机系列也会越来越成为我们在注重成本控制时的一个有力选择。
课堂总结与思考
今天,我们详细讨论了在云上使用虚拟机时,可以运用的一些节省成本的思路和方法。它们原理不同,各有利弊。
包年包月的付费方式是最常见的降低虚拟机使用成本的方法,它通过牺牲采购的灵活性来换取折扣。
竞价实例的机制让云端的闲置资源对外开放,基于市场竞拍的定价方式,常常能够让我们获得很大的折扣。这种方法主要是通过牺牲稳定性,来换取成本上的节约。
突发性能实例是一种特殊的使用CPU积分制的机型相对标准机型成本较低适合工作负载存在较大波动的场景。它主要牺牲的是性能。
基于ARM的虚拟机实例已陆续走向市场随着生态的不断成熟也将成为低成本机型中非常具有竞争力的选择。这种方法主要在生态和兼容性方面存在一些限制。
结合起来不难看到第一、二种方法是在购买模式层面的调整和创新而第三、第四种方法是在机型选择方面拓宽了我们的思路。有时这两个层面的方法是可以组合起来使用的。比如我就曾经在AWS云上使用Spot Instance的竞价方式启动了一批T系列的突发性能实例取得了很好的业务效果。
好了,这一讲就到这里。今天我留给你的思考题是:
与包年包月类似的预付费折扣还有一种叫做“预留实例”Reserved Instance的模式。你能说说它和包年包月的不同之处以及独特的优势是什么吗
在有些云上创建突发性能实例时,还会有一个“无性能约束模式”的高级选项。你知道这个高级选项勾选后有什么作用,能解决什么问题吗?
欢迎你给我留言,我会尽快给你反馈。如果觉得有收获,也欢迎把这篇文章分享给你的朋友。感谢阅读,我们下期再见。

View File

@ -0,0 +1,268 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
05 云硬盘云上IO到底给不给力
你好,我是何恺铎。
通过前几讲的学习,我想你对云虚拟机应该有了不少的了解,也对如何根据实际情况来选择和运用虚拟机,有了一定的认识。在前面的学习过程中,我也留下了许多伏笔。其中之一,就是云虚拟机的重要组件:云硬盘。
那么今天这一讲,我们就来深入讨论一下这个话题,来帮助你了解不同云硬盘的差别,以及如何在实际场景中挑选最合适你的硬盘型号。
云硬盘是什么?
云硬盘,又叫做“云盘”或者“云磁盘”,就是云虚拟机上可以挂载和使用的硬盘。这里,它既包含了用于承载操作系统的系统盘,也包括了承载数据的数据盘。
在云计算的领域有时我们还会把云端磁盘服务叫做块存储Block Storage因为它们与Linux操作系统中的块设备相对应是云上提供的“裸盘”可以格式化并且施加文件系统。
既然是硬盘那么它就与我们通常的认知相一致当然是带有数据持久化功能的。这在专业上被称为“非易失性存储”Non-ephemeral Storage也就是说写入的数据不会丢失。即便所在虚拟机重启、关机甚至下线删除这块云硬盘只要还存在其中的数据也并不会被擦除。
事实上,云厂商对于云盘,不仅仅会保障数据的顺利写入,一般还会帮你在存储端同步和保留至少三份副本的数据。所以说,云硬盘的冗余度和可用性是非常之高的,一般极少发生云硬盘数据丢失的情况,你大可放心地使用。
重要提示尽管云硬盘有良好的存储冗余但你不能仅仅依赖它的可靠性。从数据的层面来看你必须进行额外的备份。2018年7月曾有创业公司因为云厂商故障丢失了在云硬盘上的所有重要数据一时成为业界的热点新闻这个教训是非常深刻的。所以你应当通过定期为云磁盘创建快照、异地备份数据文件等方式来保护你的关键数据。
云硬盘与传统磁盘的真正差异在于绝大多数的云硬盘都是远程的。我们都知道在经典计算机的体系结构中硬盘是通过本地机器内部主板的高速总线与CPU、内存等部件相连接而在云端你的硬盘则很可能并不在宿主机上而是在专用的磁盘服务器阵列中两者是通过数据中心内部的特有IO线路进行连接。没错这也正是计算存储分离架构的一种体现。
理解了这样的一个结构你就能明白有些云上的“IO优化实例”AWS上称为EBS-Optimized是指什么了。它就是指云虚拟机与云硬盘之间的网络传输进行了软硬件层面的优化这样可以充分地发挥所挂载磁盘的性能。现在较新型号、较强性能的云虚拟机一般都自动启用了这个优化。
云硬盘的性能等级
你可能听说过一些,网上对于云硬盘性能方面的质疑。这在云计算发展的早期尤其多见,甚至成为了很多人反对上云的主要原因之一。
不错,云硬盘的确有多副本写入的开销,同时也比较依赖于远程传输。所以,早期云硬盘的确存在一些性能上的短板。不过,那都是老黄历了。
当下的云硬盘经过了多次的软硬件迭代尤其是SSD的迅速发展吞吐量和随机读写能力等各项性能指标都已经不再是问题了。在现代云计算中已经发展出了基于不同存储介质的、丰富的性能等级选择你已经能够找到单盘IOPS在数十万量级甚至达到百万的云硬盘产品了。
所以,现在的云硬盘,性能上已经非常“给力”了。你更多的是要考虑如何根据应用场景,选择合适介质的硬盘等级,同时权衡好相应的成本。
那么下面,我们就分别来看一看主流云硬盘的不同性能等级,以及它们对应的磁盘类型和存储介质。
第一个等级的云硬盘是基于传统HDD硬盘构建而成的。这类云盘的性能一般最高IOPS大概在数百左右。在很多的云上已经不把它作为推荐的选择了。但它并非一无是处成本低就是它的最大优势在不注重性能的测试环境或者是个人自用的服务器它就是一个很好的选择。
第二个等级往往是基于混合硬盘也就是结合HDD和SSD硬盘构建的云硬盘。它会综合发挥SSD的性能优势和HDD的容量优势。比如它可以用SSD部分来承载热点区域数据或是作为缓存来提高响应性能。在这个等级下典型的IOPS为数千左右是很多云上创建硬盘的默认选项比较适合像是操作系统启动盘这样的常规负载。
第三个等级的云硬盘它的存储介质就是纯SSD硬盘了。虽然贵一些但一分价钱一分货这个等级下的云硬盘能够提供非常稳定的IO能力IOPS通常能够上万也有相当不俗的吞吐量和较低的访问延时。你可以用它来承载生产环境中重要的关键业务应用或是各类数据库等IO密集型应用。
第四个等级也是当下业界的最高等级就是进一步优化增强的最新SSD云盘。它一般会采用更新一代的企业级闪存硬件配合自研或改进后的底层传输协议和虚拟化技术栈的优化来提供服务。因此它能够达到惊人的性能水平满足我们最为苛刻的性能场景需求比如承载SAP HANASAP的高性能计算平台、高并发OLTP数据库等等。这类SSD云盘的IOPS通常能够突破十万以上。
各个云对于不同等级云硬盘的命名方法各有不同,我把相应的产品类型和名称整理成了一个表格,方便你去了解和查询:
当然这个表格只是一个大致的划分仅供你作为参考。在具体的情况中云和云必然存在一些差异也会有一些各自的产品特点建议你在使用时针对性地确认。比如说AWS的gp2通用型SSD类型它具有比较宽广的性能指标范围还具备I/O积分和性能突增机制与性能突增VM实例的CPU类似可以提供比较高的峰值性能应用场景是相当广泛的。
除了云盘性能等级之外,还有一个影响云盘性能的重要因素,就是这块云硬盘的容量。不论是哪种磁盘类型,它的容量大小几乎都与性能正向相关。同等的性能等级下,云硬盘的容量越大,一般来说它的性能就越高,直到达到这个等级的上限。这是由云上磁盘能力共享的底层设计所决定的。
所以在某些时候,你可能需要刻意地增大所申请的云硬盘的容量,以获取更高的性能,即便这些额外的空间不一定能被用上。
好了对于云盘性能的讨论就先到这里。在上面的性能讨论当中我们主要通过IOPS来进行衡量。事实上衡量IO性能还有吞吐量、访问延时等其他的重要指标。这些指标同样会由磁盘的类型和大小所决定你可以查询云厂商文档来确认。这里限于篇幅我就不详细展开了。
云硬盘实战
接下来,让我们进入实战环节,一起学习一下云硬盘的使用。在这个过程中,你也能真实地感受一下不同性能等级的区别。
这里可以继续沿用我们在第2讲中创建的阿里云虚拟机目前它是默认挂载了一个40G的高效云盘作为系统盘。
我们可以先用lsblk和df命令查看一下磁盘的情况
[root@my-ecs-vm1 ~]# lsblk
NAME MAJ:MIN RM SIZE RO TYPE MOUNTPOINT
vda 253:0 0 40G 0 disk
└─vda1 253:1 0 40G 0 part /
[root@my-ecs-vm1 ~]# df -hT -x tmpfs -x devtmpfs
Filesystem Type Size Used Avail Use% Mounted on
/dev/vda1 ext4 40G 1.6G 36G 5% /
通过命令的输出可以清晰地看到这台机器有一块40G的系统盘挂载在根目录下。
然后我们可以使用fio工具来测试一下这块系统盘的性能表现。我们通过fio在系统盘上创建一个1GB的文件接着进行4K大小的随机读取实验。
[root@my-ecs-vm1 ~]# fio --name=mytest1 --filename=~/testfile1 --rw=randread --refill_buffers --bs=4k --size=1G -runtime=10 -direct=1 -iodepth=128 -ioengine=libaio
mytest1: (g=0): rw=randread, bs=(R) 4096B-4096B, (W) 4096B-4096B, (T) 4096B-4096B, ioengine=l
ibaio, iodepth=128
fio-3.7
Starting 1 process
mytest1: Laying out IO file (1 file / 1024MiB)
Jobs: 1 (f=1): [r(1)][100.0%][r=8560KiB/s,w=0KiB/s][r=2140,w=0 IOPS][eta 00m:00s]
mytest1: (groupid=0, jobs=1): err= 0: pid=1324: Sat Jan 25 17:03:53 2020
read: IOPS=2154, BW=8619KiB/s (8826kB/s)(84.9MiB/10090msec)
slat (nsec): min=2529, max=38138, avg=3080.22, stdev=575.39
clat (usec): min=444, max=102701, avg=59394.84, stdev=46276.36
lat (usec): min=448, max=102705, avg=59398.39, stdev=46276.34
clat percentiles (msec):
| 1.00th=[ 3], 5.00th=[ 3], 10.00th=[ 4], 20.00th=[ 4],
| 30.00th=[ 4], 40.00th=[ 5], 50.00th=[ 96], 60.00th=[ 97],
| 70.00th=[ 99], 80.00th=[ 99], 90.00th=[ 100], 95.00th=[ 100],
| 99.00th=[ 101], 99.50th=[ 102], 99.90th=[ 102], 99.95th=[ 102],
| 99.99th=[ 103]
bw ( KiB/s): min= 8552, max=10280, per=100.00%, avg=8645.20, stdev=384.80, samples=20
iops : min= 2138, max= 2570, avg=2161.30, stdev=96.20, samples=20
lat (usec) : 500=0.01%, 1000=0.03%
lat (msec) : 2=0.50%, 4=36.26%, 10=3.74%, 100=57.13%, 250=2.34%
cpu : usr=0.50%, sys=1.19%, ctx=20986, majf=0, minf=161
IO depths : 1=0.1%, 2=0.1%, 4=0.1%, 8=0.1%, 16=0.1%, 32=0.1%, >=64=99.7%
submit : 0=0.0%, 4=100.0%, 8=0.0%, 16=0.0%, 32=0.0%, 64=0.0%, >=64=0.0%
complete : 0=0.0%, 4=100.0%, 8=0.0%, 16=0.0%, 32=0.0%, 64=0.0%, >=64=0.1%
issued rwts: total=21741,0,0,0 short=0,0,0,0 dropped=0,0,0,0
latency : target=0, window=0, percentile=100.00%, depth=128
Run status group 0 (all jobs):
READ: bw=8619KiB/s (8826kB/s), 8619KiB/s-8619KiB/s (8826kB/s-8826kB/s), io=84.9MiB (89.1MB
), run=10090-10090msec
Disk stats (read/write):
vda: ios=21399/2, merge=0/1, ticks=1266052/242, in_queue=1039418, util=81.1
实际命令输出的结果比较长这里我们主要关注下IOPS的部分。你可以看到平均IOPS的数值都在2100左右这个跑分的成绩和我们当初建立这块高效云盘时提示的性能目标值“2120”相当一致。
如果高效云盘还不够满足你的业务要求,你可以随时为机器添加更高规格的硬盘,这也是云硬盘的灵活性所在。
接下来,我们就来试一下动态挂载新硬盘的过程。
首先来到这个虚拟机的“本实例磁盘”管理界面选择“创建云盘”这里我们选择一块300G的SSD云盘按照提示这样我们就能够拥有1万的IOPS。
之后按照提示确认创建即可。OK阿里云很快地为我们创建好了磁盘但此时这块SSD磁盘的状态为“未挂载”我们可以通过界面操作把它挂载到正在运行中的目标虚拟机里。
挂载完成后磁盘的状态开始变为“使用中”说明磁盘已经“上线”。这时我们再在Linux操作系统中用lsblk命令查看
[root@my-ecs-vm1 ~]# lsblk
NAME MAJ:MIN RM SIZE RO TYPE MOUNTPOINT
vda 253:0 0 40G 0 disk
└─vda1 253:1 0 40G 0 part /
vdb 253:16 0 300G 0 disk
你可以看到磁盘中已经出现了一个新的块设备vdb。
这时我们需要将这块磁盘进行格式化并创建ext4文件系统
[root@my-ecs-vm1 ~]# mkfs.ext4 /dev/vdb
mke2fs 1.42.9 (28-Dec-2013)
Filesystem label=
OS type: Linux
Block size=4096 (log=2)
Fragment size=4096 (log=2)
Stride=0 blocks, Stripe width=0 blocks
19660800 inodes, 78643200 blocks
3932160 blocks (5.00%) reserved for the super user
First data block=0
Maximum filesystem blocks=2227175424
2400 block groups
32768 blocks per group, 32768 fragments per group
8192 inodes per group
Superblock backups stored on blocks:
32768, 98304, 163840, 229376, 294912, 819200, 884736, 1605632, 2654208,
4096000, 7962624, 11239424, 20480000, 23887872, 71663616
Allocating group tables: done
Writing inode tables: done
Creating journal (32768 blocks): done
Writing superblocks and filesystem accounting information: done
好,还差最后一步,我们要在/mnt下创建一个data目录并将这个新的块设备挂载到该目录。
[root@my-ecs-vm1 ~]# mkdir /mnt/data
[root@my-ecs-vm1 ~]# mount /dev/vdb /mnt/data/
终于大功告成。我们再次使用fio工具来测试下这块SSD盘4K随机读方面的能力。和前面不同的是这回我们要把测试文件路径定位到“/mnt/data”目录因为这个目录指向的是刚刚创建的新硬盘
[root@my-ecs-vm1 ~]# fio --name=mytest2 --filename=/mnt/data/testfile2 --rw=randread --refill_buffers --bs=4k --size=1G -runtime=10 -direct=1 -iodepth=128 -ioengine=libaio
mytest2: (g=0): rw=randread, bs=(R) 4096B-4096B, (W) 4096B-4096B, (T) 4096B-4096B, ioengine=l
ibaio, iodepth=128
fio-3.7
Starting 1 process
Jobs: 1 (f=1): [r(1)][100.0%][r=41.1MiB/s,w=0KiB/s][r=10.5k,w=0 IOPS][eta 00m:00s]
mytest2: (groupid=0, jobs=1): err= 0: pid=1302: Sat Jan 25 16:59:30 2020
read: IOPS=10.6k, BW=41.2MiB/s (43.2MB/s)(415MiB/10067msec)
slat (usec): min=2, max=445, avg= 3.10, stdev= 1.49
clat (usec): min=828, max=77219, avg=12115.14, stdev=20941.23
lat (usec): min=841, max=77222, avg=12118.74, stdev=20941.22
clat percentiles (usec):
| 1.00th=[ 2737], 5.00th=[ 3326], 10.00th=[ 3523], 20.00th=[ 3687],
| 30.00th=[ 3785], 40.00th=[ 3884], 50.00th=[ 3949], 60.00th=[ 4047],
| 70.00th=[ 4146], 80.00th=[ 4359], 90.00th=[56361], 95.00th=[71828],
| 99.00th=[73925], 99.50th=[73925], 99.90th=[74974], 99.95th=[76022],
| 99.99th=[76022]
bw ( KiB/s): min=41916, max=43600, per=100.00%, avg=42464.60, stdev=724.17, samples=20
iops : min=10479, max=10900, avg=10616.15, stdev=181.04, samples=20
lat (usec) : 1000=0.02%
lat (msec) : 2=0.17%, 4=55.50%, 10=29.30%, 20=0.83%, 50=3.47%
lat (msec) : 100=10.71%
cpu : usr=3.24%, sys=5.58%, ctx=96090, majf=0, minf=163
IO depths : 1=0.1%, 2=0.1%, 4=0.1%, 8=0.1%, 16=0.1%, 32=0.1%, >=64=99.9%
submit : 0=0.0%, 4=100.0%, 8=0.0%, 16=0.0%, 32=0.0%, 64=0.0%, >=64=0.0%
complete : 0=0.0%, 4=100.0%, 8=0.0%, 16=0.0%, 32=0.0%, 64=0.0%, >=64=0.1%
issued rwts: total=106300,0,0,0 short=0,0,0,0 dropped=0,0,0,0
latency : target=0, window=0, percentile=100.00%, depth=128
Run status group 0 (all jobs):
READ: bw=41.2MiB/s (43.2MB/s), 41.2MiB/s-41.2MiB/s (43.2MB/s-43.2MB/s), io=415MiB (435MB),
run=10067-10067msec
Disk stats (read/write):
vdb: ios=105123/3, merge=0/1, ticks=1265938/41, in_queue=1266532, util
上面的测试结果表明这块SSD盘对/mnt/data目录中文件的4K随机读成功地达到了1万IOPS的标称水准。也就是说新创建的SSD磁盘性能还是相当给力的。
在实际的使用场景中你就可以把一些读写较为密集的负载比如数据库的数据目录配置到这个SSD盘对应的目录下。
好了,通过上面的实验,相信你对云盘的挂载和使用有了比较直观的认识。云盘的热挂载特性让它使用起来特别灵活方便,而且大小性能任你调度。挂载后的云硬盘真正使用起来,和你熟悉的硬盘操作也并没有什么两样。
认识和使用本地磁盘
前面我们对于云虚拟机的硬盘作了许多讨论,都是围绕着“远程硬盘”这个产品形态来展开的。的确,远程云硬盘的好处很多,是计算存储分离架构的体现,也是云虚拟机硬盘的主流方式。
不过,有时我们还是会有点怀念“本地磁盘”,也就是直接位于宿主机上的硬盘。因为看似传统的本地硬盘,与远程硬盘相比起来,还是会有它自己的优点。毕竟它和计算单元离得近,而且没有三副本的负担,所以往往性能不俗,价格又相对便宜。
那么,云上能否使用本地磁盘呢?
答案是肯定的。而且本地磁盘一般不需要自行创建,只要你选择了带有本地磁盘的机型,启动后,该型号对应大小和规格的本地磁盘就会自动被挂载。
你应该还记得我在介绍虚拟机型号的第3讲中提到了“本地存储”系列的虚拟机吧那些正是自带有大容量、高性能本地磁盘的虚拟机型号。它们或是配备了高性能的本地NVMe SSD磁盘或是装备有高吞吐的先进HDD数量可能还不止一块。妥善使用这些本地磁盘在合适的场景下能够帮你发挥很大的作用。
比如你要在云上用虚拟机自己搭建一个经典的Hadoop集群要用虚拟机的磁盘组合成HDFSHadoop的分布式文件系统并希望使用MapReduce或Spark等支持数据本地性Data Locality的计算框架。这时你就应该考虑使用带有本地磁盘的机型了。
所以,当一些应用软件系统本身考虑到了硬件的不可靠性,设计了上层的存储冗余机制时,你就可以考虑采用本地磁盘。因为这种情况下,本地磁盘的可靠性缺陷得到了弥补,它的相对高性能和低成本就成为了优势。这时如果选用三副本的远程云硬盘,反倒显得有些笨重了。
还有一类对数据丢失不敏感的临时性存储的场景也是本地磁盘可以发挥的舞台。这些场景包括操作系统的pagefile或swap分区以及数据库的硬盘缓存区如SQL Server的Buffer Pool Extension等等。
不过我还是要提醒你本地磁盘的缺点它在本质上还是易失性Ephemeral的存储当机器关机或删除以及出现硬件故障时本地磁盘上的数据就可能损坏或丢失。这一点我们必须牢记不适用的场合必须使用更可靠的远程云硬盘。
课堂总结与思考
今天我们围绕云硬盘,进行了一系列的讲解,可以简单总结如下:
云硬盘是云虚拟机的主要持久化存储,与宿主机往往是分离的;
云硬盘支持动态添加和删除,使用起来灵活方便;
云硬盘一般提供多种性能等级,最终性能会受存储介质和容量大小的共同影响;
部分虚拟机型号会自带高性能的本地磁盘,在可以容忍数据丢失风险时,是你值得考虑的一个选择。
最后,我还想补充一点,云硬盘的付费模式,同样有按量付费和包年包月之分。在很多的云上,你能够为一块云盘启用包年,长期租用的确定性也能够给你带来折扣,这和虚拟机资源的包年包月是一样的。
今天,我想和你讨论的问题如下:
我们说云硬盘可以动态地挂载和卸载,使用起来十分方便。那么更进一步的问题是,已经挂载的云硬盘能够支持在线扩容吗?
还有一种云端常见的存储类产品如阿里云的文件存储NAS、AWS的EFS等也可以挂载到云虚拟机。那么你知道这种产品形态和云硬盘有什么区别主要用于什么场景吗
你可以在留言区和我互动。如果觉得有收获,也欢迎你把这篇文章分享给你的朋友。感谢阅读,我们下期再见。

View File

@ -0,0 +1,206 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
06 云上虚拟网络:开合有度,编织无形之网
你好,我是何恺铎。
我们对于IaaS的介绍已经渐入佳境了。前面我们主要涉及了与云虚拟机相关的计算和存储方面的内容。今天这一讲我想要和你讨论的则是“计算/存储/网络”三要素中的最后一项:网络。
在互联网时代,网络的重要性不言而喻,我们必须好好掌握。通过合理的网络拓扑设置,既能够帮助我们实现架构上的隔离性和安全性,又能够让各组件互联互通、有序配合。
不过网络对于许多开发者而言,有时会让人感觉是一个挺困难的话题。它复杂的设置、晦涩的术语,尤其是各种连而不通的场景,可能让你望而生畏。
请你不要担心,云上的网络经过了一定程度的抽象以后,已经为我们屏蔽了一定的复杂度。只要宏观的思路梳理得足够清晰,你很快就能够理解云上的网络组件,并让它们听你指挥、投入使用。
什么是虚拟私有网络?
虚拟私有网络Virtual Private Cloud简称VPC是云计算网络端最重要的概念之一它是指构建在云上的、相互隔离的、用户可以自主控制的私有网络环境。虚拟私有网络有时也称为专有网络阿里云或虚拟网络Virtual Network或VNetAzure的叫法
上面的概念解释也许不太好理解,其实用通俗的话来讲,私有网络就是一张属于你自己的内网。内网之内的服务器和设备,可以比较自由地互相通信,与外界默认是隔离的。如果外部互联网,或者其他虚拟网络需要连接,则需要额外的配置。
所以说,虚拟私有网络,就是你在云上的保护网,能够有效地保护网内的各种设施。有的时候,你可能还要同时创建多个虚拟网络,让它们各司其职,实现更精细的隔离。
小提示:在一些云上,除了私有网络,你可能还会看到“经典网络”的选项。这是上一代的云上内网基础设施,虽然它配置起来相对简单,但在隔离性、可配置性上有许多局限。现在已不推荐使用了。
虚拟私有网络麻雀虽小,但五脏俱全。在传统数据中心里,经典网络架构中的概念和组件,在虚拟网络中你几乎都能找到对应。这里比较重要的一些概念包括:
网段私有网络的内部IP区段通常用CIDR形式来表达如192.168.0.0/16。
子网,私有网络的下级网络结构,一个私有网络可以划分多个子网,这和通常意义上的子网也是对应和一致的。阿里云中把子网形象地称为“交换机”。
路由表,用于定义私有网络内流量的路由规则,决定着数据包的“下一跳”去向何方。每个子网都必须有一张关联的路由表,通常情况下,系统会自动帮你创建一个默认的路由表。
网关,是对进出私有网络的流量进行把守和分发的重要节点,根据用途的不同,有多种类型,后面我们还会讲到。
安全组私有网络里虚拟机进出流量的通行或拦截规则可以起到虚拟机网络防火墙的作用我们曾经在第2讲中提到过它。
所以在创建虚拟网络时,你就需要对上面这些重要属性进行按需设定。
下面我就以阿里云VPC为例来带你实际操作体验一下。
首先我们来到阿里云的专有网络管理控制台选择新建一个VPC这里的网段我们选择192.168.0.0/16
注意VPC属于局域网按照RFC规范能够使用的IPv4区段必须为192.168.0.0/16、172.16.0.0/12、10.0.0.0/8这三个或它们的子集。
同时我们还至少要创建一个子网也就是交换机。我们选择一个子IP段192.168.0.0/24并且设置所属可用区为“可用区D”
我们再来创建另外一个交换机网段设置为192.168.1.0/24。这里的关键在于我们可以让第二个交换机位于另外一个可用区E
这就说明我们可以建立跨可用区也就是跨同区域内不同数据中心的私有网络。这是VPC的一个强大的特性能够为我们私有网络的高可用性提供保障。比如你可以让主力集群在一个可用区工作备用集群在另一个可用区随时待命需要时迅速切换你也可以把流量同时分发到不同的可用区动态控制分发策略。
就这样我们收获了一个包含两个交换机的VPC。
查看一下它的路由表,你可以发现,它自动为我们包含了两个子网的路由信息:
你看创建VPC其实并不困难。这里的关键还是要规划好VPC和各子网的网段需要让它们既有足够的地址空间以供资源拓展又不要安排得范围过大以免和其他VPC或公司内部网络产生地址冲突为后续的网间互联带来不必要的麻烦。
如果你在没有VPC的情况下直接创建虚拟机公有云一般都会为你自动生成VPC。在生产环境中我强烈地建议你不要让系统自动建立VPC而是像我们上面的做法先自行建立好VPC配置好子网和网段等重要参数然后再创建云虚拟机“入住”。因为这样你会事先让自己有一个明确的网络规划对整个VPC的把控和理解也会更强。
私有网络中的虚拟机
让我们回到虚拟机的视角。当一个虚拟网络已经存在时,我们就可以将新创建的虚拟机放置在这个虚拟网络中。
那么,这个所谓的“放置”是怎么真正产生的呢?虚拟机和专有网络的连接点是哪里呢?
答案就在于虚拟机的网卡又称弹性网卡Elastic Network Interface 简称ENI。虚拟机的网卡一方面是和虚拟机的本体进行绑定另一方面则嵌入某个私有网络的子网也会拥有至少一个私网IP。
云上的网卡,之所以被称为“弹性”网卡,是因为它具备以下特征:
一个虚拟机可以绑定多块网卡,有主网卡和辅助网卡之分;
一块网卡隶属于一个子网可以配置同一子网内的多个私有IP
辅助网卡可以动态解绑,还能够绑定到另一台虚拟机上。
这再次体现了云计算的解耦特征,在某些场景下是非常有用的。比如,有一台服务线上流量的机器,而且线上流量导向的是它的辅助网卡,那么当这台机器因故无法正常工作时,你在排查问题的同时可以考虑这样一个应急的办法:将这台机器的辅助网卡迅速解绑,并重新绑定到待命的备用机上。这样就能够比较快地先恢复对外服务。
当你在创建虚拟机的时候向导会询问你这台虚拟机属于哪个VPC以及VPC下的哪个子网现在你就理解了这个选项的实质性结果就是新虚拟机自动生成的主网卡接入了所选VPC的所选子网。
好了网卡和私有IP的部分你应该已经比较清楚了。那么你可能会问公有IP呢这正是我想说的另一个比较关键的部分。
在绝大多数的云上创建虚拟机时都会有一个选项问你“是否同时为虚拟机分配一个公网IP地址”。如果你选择“是”这样机器启动后就会拥有一个自动分配的公网地址便于你从自己的电脑连接到这个实例。这在很多时候都是最方便的选择。
但对于生产环境我的推荐是尽量不要使用和依赖这个自动生成的公有IP。因为它本质上是一个从公有云的IP池中临时租用给你的IP。如果你的机器关闭或重启下次获得的IP可能就完全不同了。
这时我们真正应该用到的是弹性IPElastic IP有些云称为eIP。弹性IP一旦生成它所对应的IP是固定、不会变化的而且完全属于你所有。这非常适合需要稳定IP的生产环境。
请不要被它的名字迷惑它所谓的弹性其实是指可以非常自由地解绑和再次绑定到任意目标。你本质上是买下了这个IP的所有权将这个IP赋予谁是你的权利而且你还可以动态按需切换。
所以当你有一个域名需要让DNS服务解析到某个外部IP你就应该建立一个弹性IP绑定到相关资源后让域名解析到这个弹性IP而不应该使用虚拟机自动匹配的公有IP。因为后者是不稳定的。
让我们继续进入实验的部分。我们在刚才的VPC内来建立一台虚拟机起名为vm1-in-vpc1把它放置到位于可用区E的第二个交换机中并且选择不自动生成公有IP。
注意这时它只有私有IP我们怎么连接它呢我们可以创建一个弹性IP然后绑定到这台实例
绑定之后就自然可以连上刚才的这台虚拟机了。注意VM列表界面会有相应的显示
尝试SSH连接一下一切正常
client@clientVM:~$ ssh [email protected]
[email protected]'s password:
Welcome to Ubuntu 18.04.3 LTS (GNU/Linux 4.15.0-72-generic x86_64)
* Documentation: https://help.ubuntu.com
* Management: https://landscape.canonical.com
* Support: https://ubuntu.com/advantage
Welcome to Alibaba Cloud Elastic Compute Service !
root@vm1-in-vpc1:~#
如何让私有网络对外“开口子”?
在阿里云上如果一台云虚拟机没有被赋予公有IP默认情况下它就失去了访问外网的能力只能进行内网通信这在很多时候的确是我们想要的安全控制。但也有一些情况我们希望内网的机器和外界并不完全隔离一些互联网流量需要有序地引进来一些内网机器也需要访问外网。
这就是一个如何在VPC上“开口子”的问题了。
当然你可以使用前面提到的弹性IP绑定到相关虚拟机上。不过如果我们需要访问外网的虚拟机数量有很多这种办法就需要很多弹性IP管理上就太麻烦了成本也不划算。还有一个问题是弹性IP带来的是双向的开放有时我们只想允许单向的连接。
这就是网关可以大显身手的场景了,它正是用来统一协调管理私有网络与外部通信的组件。随着各个公有云的发展,云上也延伸出了许多不同形式、解决不同目的的网关产品。
我们这里讨论一个常见的场景即如何允许多台没有公有IP的虚拟机访问外网。这时需要使用到的网关叫做NATNetwork Address Translation网关是一种常见的用来给VPC开口的手段。
我们继续以阿里云为例来看下如何通过NAT网关让虚拟机访问外网。
我们可以事先把弹性IP从刚才那台虚拟机解绑这下它现在又无法访问外网了
root@vm1-in-vpc1:~# curl myip.ipip.net
curl: (7) Failed to connect to myip.ipip.net port 80: Connection timed out
接着我们创建一个NAT网关实例并选择它对应的VPC然后把刚才解绑的弹性IP(47.102.139.39)绑定到NAT网关上
这里的关键之处在于接下来我们要添加的SNAT条目。
SNAT是“源地址转换”的意思它非常适合让私有网络的主机共享某个公网IP地址接入Internet。注意这是一种从内向外的、单向的连通形式。
上面我们添加了一个SNAT条目让整个交换机“test-vpc1-vsw2”下的网段都共享一个出口公网IP。你要注意我们的虚拟机是位于这个网段内的。
接着再回到这台虚拟机内我们通过curl命令尝试对外访问
root@vm1-in-vpc1:~# curl myip.ipip.net
当前 IP47.102.139.39 来自于:中国 上海 上海 阿里云/电信/联通/移动/铁通/教育网
很棒这回成功地连通了。而且外部网站也显示我们正在使用的外网IP正是那个弹性IP(47.102.139.39)。这就是对于NAT网关的一个小小实验了。
还有一种网关被称为VPN网关也可以帮助外界连接到VPC它本质上是基于你所熟知的VPN技术。由于VPN能够基于互联网提供私有加密的通信因此非常适合用来从任意其他私有设施安全地连接到VPC。这些私有设施可以小到一台个人电脑或手机终端也可以大到是你本地的数据中心还可以是另一个VPC。
多网连接有哪些方式?
前面我们主要是从单个VPC的角度来进行讨论的那么最后我们再来讨论一下多VPC的场景。公有云上是允许你同时使用多个VPC的这样你可以构建更加复杂的网络架构实现模块隔离和跨区域扩展等高级需求。
如果是云端VPC和VPC的互联我首先推荐的就是对等连接VPC Peering的方式。它能够在不添加额外设备的情况下让两个VPC无缝地互联起来而且操作非常简单对等连接甚至还能够支持跨区域的私有网络互联。当然对等连接的实施前提是这两个VPC的网段没有交集不存在冲突。
这里你需要注意对等连接的一个特点就是它不具备传递性。也就是说如果A和B建立了对等连接B和C建立了对等连接那么A和C是不相通的。这是对等连接的一个局限。
如果你真的需要多个VPC间任意路径的互联互通那么可以考虑使用比对等连接更为复杂和强大的专用网络设施比如AWS的Transit Gateway和阿里云的云企业网它们能够帮助搭建更为复杂的多VPC网络拓扑结构也允许进行更精细的路由设置。如有需要建议你仔细阅读厂商的文档进行学习和研究。
公有云中的私有网络还可以和企业本地数据中心进行互联形成混合云架构。你可以先考虑使用VPN这种轻量的方式通过公网线路为两边建立连接渠道。但如果应用场景要求保证延迟和带宽一般就需要专线进行连接了。绝大多数的云厂商都提供了云端区域和本地数据中心进行高速互联的服务和解决方案比如AWS的Direct Connect、Azure的ExpressRoute和阿里云的“高速通道”云下IDC专线接入等等。一般专线还会和VPN一起组合使用来保证通道的高可用性。
小提示与较为易用的VPC互联相比混合云的构建是一项较为复杂的工程通常需要由本地机房、云厂商、电信运营商三方配合进行也牵涉到本地数据中心端的网络规划和路由设备适配。这超出了我们开发者课程的范畴。如需实施建议你仔细咨询云厂商工作人员。
课堂总结与思考
今天,我主要为你介绍了云上虚拟网络,包括它的具体组成、使用场景和连接性问题。我还给你推荐了一些在生产环境下的最佳实践。
从某种程度上来说虚拟私有网络的“仿真度”非常高在软件定义网络SDN技术的加持下甚至比物理网络还要更加灵活高效更易于扩展。所以通过合理的规划和设置云端的网络基础设施能够让我们拥有一个健壮而强大的网络拓扑结构对于流量的引导和控制也完全能够做到因势利导、开合有度。
需要特别说明的是在主体理念保持一致的情况下各个云厂商在具体实现上其实是各显神通的会有一些细节存在差异。这是正常的现象请你在实践时注意。比如说和阿里云不同AWS的VPC中访问外网需要经由专门的Internet Gateway来通行流量路由表中也需要进行相应的设置。
好了,今天我给你留下的思考题是:
在虚拟私有网络的内部,两机互联的带宽有多大呢?可能受到哪些因素的影响?
在今天的实验中我们通过NAT网关实现了流量“出网”的目的。那么如果是反过来需要引导外界流量进入VPC应该使用什么方式呢
欢迎你在留言区和我互动,我会一起参与讨论。如果觉得有收获,也欢迎你把这篇文章分享给你的朋友。感谢阅读,我们下期再见。

View File

@ -0,0 +1,195 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
07 云端架构最佳实践:与故障同舞,与伸缩共生
你好,我是何恺铎。这一讲,我们来谈谈云上架构的注意事项和最佳实践。
云上架构最需要注意什么呢?就像我在标题所描述的那样,云端架构一方面需要处理和应对可能出现的故障,保证架构和服务的可用性;另一方面则是需要充分利用好云端的弹性,要能够根据负载进行灵活的伸缩。
面对故障,提升冗余
故障是IT业界的永恒话题。故障的原因多种多样无论是由于硬件的自然寿命造成的还是数据中心的极端天气捣鬼或是人工运维操作上的失误不论我们多么讨厌它故障似乎总是不可避免。
你也许会问,那么,云计算会有故障吗?比如说,云上创建的虚拟机,是否百分之百会工作正常呢?
很遗憾虽然公有云们为了避免故障在许多层面上做了冗余和封装但云也不是可以让你永远无忧无虑的伊甸园。我们需要牢记云端的服务仍然是有可能出故障的只是概率上的不同而已。这也是云供应商们为云服务引入服务等级协议Service Level Agreement简称SLA的原因它主要是用来对服务的可靠性作出一个预期和保证。
SLA的可用性等级可能是99.9%也可能是99.99%它能够表明某项云服务在一段时间内正常工作的时间不低于这个比例也代表了厂商对于某项服务的信心。不过你要知道再好的服务即便是SLA里有再多的9也不可能达到理论上的100%。
小提示当实际产生的故障未达到SLA的要求时云厂商一般会给予受到影响的客户以消费金额一定比例金额的赔付。不过很多时候赔付的金额不足以覆盖业务上的经济损失你不应该依赖它。
所以从架构思维的角度上来说我们需要假定故障就是可能会发生对于它的影响事先就要做好准备事先就进行推演并设置相关的冗余和预案。AWS有一个非常著名的架构原则叫做Design For Failure讲的也就是这个意思。
好在云上做高可用架构同样有自己的特点和优势,我们可以轻松地调用各个层面的云端基础设施来构建冗余,规避单点的风险。
那么,云上可能出现哪些不同层面的故障?相应的故障范围和应对措施又会是怎样的呢?我们不妨从小到大,依次来看我们可能遇到的问题和解决办法。
第一种故障是在宿主机的级别,这也是从概率上来说最常见的一种故障。当宿主机出现硬件故障等问题后,毫无疑问将影响位于同一宿主机上的多个虚拟机。为了避免产生这样的影响,当我们承载重要业务时,就需要创建多台虚拟机组成的集群,共同来进行支撑。这样,当一台虚拟机出现故障时,还有其他几台机器能够保证在线。
这里需要注意的是,我们需要保证多个虚拟机不在同一台宿主机上,甚至不处于同一个机架上,以免这些虚拟机一起受到局部事故的影响。那么,要怎么做到这一点呢?
虚拟机的排布看似是一个黑盒但其实在公有云上是有办法来对虚拟机的物理分配施加干预让它们实现分散分布隔开一段距离的。这一特性在AWS称为置放群组Placement GroupAzure称为可用性集Availability Set阿里云对应的服务则是部署集。比如说我们对阿里云同一个可用区内的虚拟机在创建时选择同一个部署集就可以保证相当程度的物理分散部署从而最大限度地保证它们不同时出现故障了。
第二种规模更大的故障,是在数据中心,也就是可用区的层面。比如火灾、雷击等意外,就可能会导致数据中心级别的全部或者部分服务类型的停摆。有时一些施工导致的物理破坏,也会挖断光纤,影响可用区的骨干网络。
要应对这类故障,我们就需要多可用区的实例部署,这也是云抽象出可用区概念的意义所在。你的实例需要分散在多个可用区中,这样,可用区之间既可以互为主备,也可以同时对外服务,分担压力。另外,也不要忘记我在上一讲中所提到的,虚拟私有网络可以跨越可用区,这会大大方便我们多可用区架构的搭建。
第三种更严重的故障,就是整个区域级别的事故了。当然这种一般非常少见,只有地震等不可抗力因素,或者人为过失引发出的一系列连锁反应,才有可能造成这么大的影响。
区域级别的事故一般都难免会对业务造成影响了。这时能够进行补救的主要看多区域架构层面是否有相关的预案。如果是互联网类的服务这时最佳的做法就是在DNS层面进行导流把域名解析到另外的一个区域的备用服务上底层的数据则需要我们日常进行着跨区域的实时同步。
再更进一步的万全之策,就需要考虑多云了,也就是同时选用多家云厂商的公有云,一起来服务业务。虽然集成多个异构的云会带来额外的成本,但这能够最大限度地降低服务风险,因为两家云厂商同时出问题的概率实在是太低了。更何况,多云还能带来避免厂商锁定的好处,现在其实也越来越多见了。
综上所述,不论是哪种级别的故障,我们应对的基本思想其实没有变化,都是化单点为多点,形成不同层面、不同粒度的冗余。当故障发生时,要能迅速地发现和切换,平滑地过渡到备用的服务和算力上。
当然,盲目地追求可用性也不可取。根据业务需求,在成本投入与可用性之间获得一个最佳的平衡,才是你应该追求的目标。试想一下,构建一个个人博客网站,和建立一个金融级系统,两者在可用性架构方面的要求显然天差地别,所以我们最后的架构选择也会大相径庭。
随机应变,弹性伸缩
弹性伸缩,这是云上架构的另一个原则,也是云端的重要优势。
由于云的本质是租用而且它便捷的操作界面、丰富的SDK和自动控制选项使得云上“租用”和“退租”的成本很低可以是一个很高频的操作这就为弹性伸缩在云上的出现和兴起提供了土壤。在妥善应用之下弹性伸缩既可以提高工作负载洪峰来临时的吞吐和消化能力提高业务稳定性又能够在低谷期帮我们显著地节约成本。
在IaaS端能够弹性伸缩的最实用的产品形态莫过于虚拟机编组了也就是功能相同的多个虚拟机的集合。把它们作为一个单位来创建、管理和伸缩是一种普遍应用的最佳实践。AWS中相关的产品命名是 EC2自动伸缩Auto ScalingAzure中是虚拟机规模集VM Scale Set阿里云则叫做弹性伸缩。
我们把多个虚拟机以弹性伸缩组的方式进行统一管理,能够极大地提高效率,减轻负担。因为弹性伸缩服务,会帮我们动态地创建和销毁虚拟机实例,自动根据我们指定的数量和扩缩容规则,来协调虚拟机的生命周期。我们只需要从高层进行指挥就可以了。
弹性伸缩服务,在云端还有一个最佳拍档,就是负载均衡器。它特别适合将流量均匀地,或者按照一定权重或规则,分发到多台虚拟机上,正好可以和提供计算资源的弹性伸缩服务形成配合。当负载增大、虚拟机增加时,负载均衡也能够自动动态识别,将流量分发到新创建的虚拟机上。
所以,你可以尝试使用弹性伸缩服务来实现云端弹性架构,用它来管理一组虚拟机,并与负载均衡一起配合。这特别适合处理无状态类的计算需求,因为它会为你代劳底层计算资源的管理。
高可用的弹性架构实战
结合上面的介绍,让我们进入这一讲的实战环节。
我们来模拟一个线上高可用服务的场景,来看下如何用阿里云进行服务的搭建。我会在上一讲搭建的虚拟私有网络的基础上来提供服务,并做到一定程度的故障隔离和弹性扩展。
我们先用Node.js来搭建一个简单的Web服务用来计算著名的“斐波那契数列”。相关的源码如下供你参考
const express = require('express');
const ip = require('ip');
const os = require('os');
const app = express();
//使用递归计算斐波那契数列
function fibo (n) {
return n > 1 ? fibo(n-1) + fibo(n-2) : 1;
}
app.get('/', function(req,res) {res.write('I am healthy'); res.end();} );
app.get('/fibo/:n', function(req, res) {
var n = parseInt(req.params['n']);
var f = fibo(n);
res.write(`Fibo(${n}) = ${f} \n`);
res.write(`Computed by ${os.hostname()} with private ip ${ip.address()} \n`);
res.end();
});
app.listen(80);
我们在上一讲创建的虚拟机“vm1-in-vpc1”中安装好Node环境将上述代码放入一个起名为“app.js”的文件中用npm安装express等相关依赖后就可以用命令“node app.js”直接运行了。然后我们需要把这个服务设置为开机自动启动你可以通过npm安装pm2组件来帮助实现开机自动启动这样一个简单的Web服务就搭建好了。
为了让之后的外部流量能够进入到内部网络的多台虚拟机中我们来建立对外的负载均衡实例。要注意负载均衡器本身也需要是高可用的我们这里主要选择华东2区域下的可用区D让可用区E作为备可用区和我们的VPC保持一致。
然后在负载均衡器上配置一个HTTP协议80端口的监听后端服务器可以先指向我们的测试机vm1-in-vpc1然后从外部测试负载均衡器的连通性。
[client@clientVM ~]$ curl http://47.101.77.110/fibo/35
Fibo(35) = 14930352
Computed by vm1-in-vpc1 with private ip 192.168.1.80
可以看到curl命令的响应中成功地返回了斐波那契数列第35项的结果值以及相关服务器的名称、IP等信息说明负载均衡已经初步正常工作了。
接下来,我们要创建一个能够弹性伸缩的虚拟机集群,来大规模地对外输出这个计算服务。
作为准备工作我们要先为vm1-in-vpc1创建一个镜像作为新建虚拟机的“种子”
-
然后我们就可以创建弹性伸缩实例了。我们来建立一个最小数量为2最大数量为10的伸缩组。在这个过程中你尤其需要注意要选取上一讲中建立的VPC作为目标网络同时选择两个分属不同可用区的交换机并设置为均匀分布策略。如下图所示
同时在这里,我们还为伸缩组和刚才建立的负载均衡器建立了关联,这样弹性伸缩实例中的机器,会自动地进入到负载均衡后端服务器的列表中。
下一步是建立伸缩配置,这里主要是指定虚拟机模板,记得选取我们刚才创建好的自定义镜像:
启用伸缩配置后,很快就能看到弹性伸缩服务为我们建立了两台虚拟机了:
在ECS控制台你也可以清楚地看到这两台机器被自动分配到了不同的可用区中分属不同的交换机
我们再设置一下非常重要的伸缩规则这会告诉伸缩组何时进行自动扩缩容。这里我们选择监控平均CPU指标我们希望理想状态下控制在50%左右。换句话说如果平均CPU偏离50%太远,系统就会自动地为我们增加或减少机器。
回到最佳拍档负载均衡的管理界面我们也看到弹性伸缩组中的两台机器已经位于后端服务器列表中了这时可以将测试机vm1-in-vpc1从后端服务中删去
我们试着来反复地访问负载均衡端的同一个入口URL会获得来自不同可用区中不同机器的响应这说明负载均衡的随机分发起到作用了
[client@clientVM ~]$ curl http://47.101.77.110/fibo/35
Fibo(35) = 14930352
Computed by iZuf68viqv1vrqntkpyihaZ with private ip 192.168.0.234
[client@clientVM ~]$ curl http://47.101.77.110/fibo/35
Fibo(35) = 14930352
Computed by iZuf67wyymbgnnd69wkf31Z with private ip 192.168.1.89
最后也是最精彩的部分我们来使用siege命令来持续冲击这个负载均衡使集群的平均CPU升高看看它是否会自动扩容。
[client@clientVM ~]$ siege -c 15 -t 20m http://47.101.77.110/fibo/35
** SIEGE 4.0.2
** Preparing 15 concurrent users for battle.
The server is now under siege...
HTTP/1.1 200 0.14 secs: 88 bytes ==> GET /fibo/35
HTTP/1.1 200 0.16 secs: 87 bytes ==> GET /fibo/35
HTTP/1.1 200 0.28 secs: 88 bytes ==> GET /fibo/35
HTTP/1.1 200 0.29 secs: 87 bytes ==> GET /fibo/35
HTTP/1.1 200 0.41 secs: 88 bytes ==> GET /fibo/35
...
果然流量到来后虚拟机的CPU飙升伸缩组就自动地进行了新实例的创建一直达到了我们设定的十台上限以满足汹涌到达的计算请求。
伸缩组的峰值状态
伸缩活动历史记录
当siege命令停止后平均CPU大幅降低伸缩组还能自动地缩容减少实例数量。上面的伸缩活动的截图也体现了这个过程。
至此,我们的跨可用区负载均衡的实验就大功告成了。
你也可以结合你实际的场景来进一步地实验和拓展这个范例。比如在生产环境中你通常需要为负载均衡的外部IP绑定正式的域名或者你的Web服务很可能不是完全无状态的需要依赖后端数据库再比如你可以尝试在别的区域再建立一个VPC让两个VPC互相连接新VPC可以作为冷备或者承担日志数据分析的工作这样能够形成一个类似“两地三中心”的强壮架构。
课堂总结与思考
今天涉及的点比较多,我们谈到了故障范围和故障处理,也谈到了云端的弹性优势。这次的实验也相对大一些,比较完整地构造了一个负载均衡加弹性伸缩的架构。不知道你掌握得怎样,有没有相关的问题,欢迎你在这里留言,和我一起探讨。
今天我留给你的思考题是:
大多数云上负载均衡产品都有一个重要特性,叫做“会话保持”,你知道它是用来做什么的吗?它的原理又是什么呢?
默认情况下,弹性伸缩服务会使用按量计费的虚拟机。那么成本上更有优势的包年包月虚拟机,或者竞价实例的虚拟机,能够融入弹性伸缩的体系吗?
好了,今天我们就到这里。如果你觉得有收获,欢迎把这篇文章分享给你的朋友。感谢阅读,我们下期再见。

View File

@ -0,0 +1,203 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
08 云上运维:云端究竟需不需要运维?需要怎样的运维?
你好,我是何恺铎。
谢谢你的努力和坚持我们已经学习了IaaS篇中的大多数内容。今天是IaaS部分的最后一讲我们来谈谈云上的运维工作。
云端需要运维吗?
既然要谈运维,我们得先回答这个必要性的问题。许多人都觉得,因为云服务大多都具有了非常高的可靠性和自动化程度,所以在云时代,运维就不那么重要了,甚至是可以省略的事情了。
这种观点有意无意地散播,其实会造成一些负面的影响。开发者会容易轻视运维工作的重要性,忽略架构设计中运维友好性问题;而从事运维方向的工程师们,可能更会有点儿焦虑,甚至于担心未来的职业生涯。
但很显然,这是一种误解。云端当然需要运维,而且云上运维很重要。因为不管在什么样的运行环境下,运维的本质和需求都没有消失,一样要为业务保驾护航,要保证系统的正常运作、应对突发情况等等。
云时代的运维,正确的理解应该是这样的:云不但没有消灭运维,反而是助推了运维的发展。
这是因为,云的引入能够让我们在更高的层面去思考和解决问题。比如说,云端基础设施的存在,可以让运维从偏硬件服务器、偏物理机房的日常繁琐工作中解脱出来,更多地基于云在软件的层面,进行部署、监控、调整。而云上的高质量、高可用的服务,也能避免我们重复建设,不用自己造轮子,也大大减轻了运维负担。
注意:底层的机房运维、基础架构运维仍然会继续存在,但会向头部的云供应商大规模集中。这属于云厂商的运维视角,是另一个宏大的话题,我们这里不多做讨论。
所以,云其实是提高了运维的效率,改变了运维的形态。
与此同时由于云上运维的软件属性显著增强了它就自然地和研发会有更强的融合。近期DevOps理念和云原生热潮的兴起就说明了这一点。许多工作你慢慢地会分不清它究竟是属于运维还是研发因为两者的界限正在模糊。
另外,由于云独有的一些特点,它也会带来一些新的运维工作。比如我们课程中一直在涉及的成本控制,这也是云时代新运维所应当关注和包含的重要事项。因为云的成本消耗是动态、时刻发生着的,这和传统运维中的各类实时监控的对象,在形态上非常接近。
所以,云端需要运维吗?答案已经不言而喻了。
云时代的运维利器
工欲善其事必先利其器。为了做好扎实的云上运维首先我给你的一个建议是你需要掌握云的命令行工具。现在几乎每个云都推出了自己的命令行工具比如AWS CLI、Azure CLI、阿里云CLI等等。
在前面各讲的例子中,为了便于你学习和理解,我都使用了公有云的网站门户来进行操作。但如果是在生产环境,你需要对很大规模的资源池逐个进行调整,或者同一件事情,你需要在不同时间反复地操作很多遍,那你就很可能需要将这些操作脚本化、程序化,这就需要用到云的命令行工具了。
虽然命令行工具有一定的学习曲线但如果你熟悉了以后其实是可以干脆利落地表达一个操作的。比如说如果你要创建在第6讲的实验中使用的虚拟机“vm1-in-vpc1”你就可以使用下面的aliyun ecs命令来轻松表达
[client@clientVM ~]$ aliyun ecs CreateInstance --ImageId ubuntu_18_04_x64_20G_alibase_20191225.vhd --InstanceType ecs.g6.large --ZoneId cn-shanghai-e --VSwitchId vsw-uf6ls7t8l8lpt35xxxxxx
{
"InstanceId": "i-uf6hn8z47kqve3xxxxxx",
"RequestId": "222DA83B-0269-44BF-A303-00CB98E4AB07"
}
[client@clientVM ~]$ aliyun ecs StartInstance --InstanceId i-uf6hn8z47kqve3xxxxxx
{
"RequestId": "8E4C43CA-8F36-422C-AEF1-14ED5023856D"
}
现在各个云的CLI基本上都进化到了第二代相比第一代CLI在易用性和表达能力上都有了很大的提升你不妨学习尝试一下。而且这些CLI都能和Shell编程进行比较好的融合你可以通过脚本组合多个关联的操作。
小提示除了命令行工具各云还都提供了开发者工具包SDK。如果你的资源调度逻辑相当复杂或者需要与你自己的程序集成那么你可以考虑使用相应语言的SDK来进行云上的一些资源管理操作。
如果你要频繁地在云上部署一套包含众多资源项的复杂系统你还有另外一个得力的帮手资源编排类云服务。属于这个领域的服务包括有AWS CloudFormation、 Azure的ARM Template、阿里云资源编排服务ROS等等它们都可以通过使用一个JSON格式的文本文件来描述和定义一个系统中所有的组件以及它们互相之间的关系。
这个JSON文件就是一个可以自动部署、可复用的单元了。这其实就是“基础设施即代码”Infrastructure as Code理念在云端的实现。
下面我给出了一个Azure的ARM Template的配置文件局部示例可以让你有一个直观的感受
{
"$schema": "https://schema.management.azure.com/schemas/2015-01-01/deploymentTemplate.json#",
"contentVersion": "1.0.0.0",
"parameters": {
"adminUsername": {
"type": "string",
"metadata": { "description": "This is the username you wish to assign to your VMs admin account" }
},
...
},
"variables": {
"nicName": "VMNic",
"addressPrefix": "10.0.0.0/16",
"imagePublisher": "Canonical",
...
},
"resources": [
{
"apiVersion": "2015-05-01-preview",
"type": "Microsoft.Network/publicIPAddresses",
"name": "[variables('publicIPAddressName')]",
"location": "[parameters('location')]",
"properties": { "publicIPAllocationMethod": "[variables('publicIPAddressType')]" }
},
{
"apiVersion": "2015-05-01-preview",
"type": "Microsoft.Network/virtualNetworks",
"name": "[variables('virtualNetworkName')]",
"location": "[parameters('location')]",
"dependsOn": [
"[resourceId('Microsoft.Network/networkSecurityGroups', variables('networkSecurityGroupName'))]"
],
"properties": { ... }
},
{
"apiVersion": "2017-03-30",
"type": "Microsoft.Compute/virtualMachines",
"name": "[variables('vmName')]",
"location": "[parameters('location')]",
"dependsOn": [ "[concat('Microsoft.Network/networkInterfaces/', variables('nicName'))]" ],
"properties": {
"hardwareProfile": { "vmSize": "[parameters('vmSize')]" },
"networkProfile": {
"networkInterfaces": [
{ "id": "[resourceId('Microsoft.Network/networkInterfaces',variables('nicName'))]" }
]
},
...
}
},
...
]
}
这个文件是用于配置单机WordPress网站的模板这里略去了许多内容其全貌可以参见这个链接。
这类资源编排服务,理论上能够支持云上所有服务的组合,而且配置节点互相能够引用,功能十分强大。它还具有一定的灵活性,一般都有输入参数字段,允许你在部署时动态决定一些选项和参数值,还可以自定义结果输出字段,方便部署完成后告诉你一些结果信息。
在这类资源编排部署系统的帮助下,我们云端部署类的工作可以得到极大的自动化。
云运维由哪些工作组成?
有了趁手的工具之后,我们下一个需要讨论的问题就是,云时代的运维具体有哪些重要的工作呢?哪些是和传统运维一脉相承的事情,哪些又是在云环境下所特有的内容呢?
现在,我就和你一起来简单梳理一下。
首先,在云端,传统的运维工作仍然存在,其中包括你所熟知的监控、部署、升级、备份等等。只是操作手段会有所不同,比如在云上,我们可以利用前面说到的命令行工具和资源模板来进行部署。
监控一直是运维最核心的工作之一。几乎所有的云端服务都自带有一定的监控功能,默认提供了不少内置的维度指标和可视化图表,这些开箱即用的图表你要充分利用好,它们能够很好地帮助你了解相关服务的状态。
那么如果自带的监控不够用怎么办其实这些默认的统计监控的背后往往都是由云的一个大型统一监控服务来支撑的如AWS的CloudWatch和Azure的Monitor等等。你可以好好研究一下这类统一监控服务通过它可以满足你更深度的自定义监控需求。
另外,这些你精心选择和设置的监控项,还能够和云上的仪表盘服务,以及报警服务联动,轻松实现运营监控的“大屏”和问题的实时报警。
Azure上的自定义监控仪表盘示例
这里我还想再多谈一谈备份。
备份是一个简单但又很容易被我们忽视的事项。即便是在云端,尽管云厂商已经做了许多如三副本之类的防护措施,但还是会存在出故障的可能,所以我们仍然需要做好备份,尤其是重要数据的备份。总之,我们在云上需要创造多层次的冗余,而备份在创造冗余方面也承担着重要的角色,有的时候,它会是我们的最后保障。
在IaaS的虚拟机层面做备份你的得力助手会是镜像和快照。
镜像我们在上一讲中已经接触过了,它可以用来恢复虚拟机;快照则是云磁盘级别对应的备份概念,它可以帮助你将某块磁盘某一时刻的状态进行封存和恢复,你还可以定期定时为一些重要磁盘自动生成快照。
注意不要小看镜像和快照这样简单基础的操作像在第5讲中提到过的创业公司严重事故就完全可以通过简单的磁盘快照进行避免。因为快照的存储本身不依赖于云盘这就是额外的冗余。
除了虚拟机和磁盘层面文件层面的备份同样重要而有效。而且文件的备份最好还能以异地的方式来存储。云上的对象存储可以在这方面肩负重任我在PaaS篇中会做专门讲解。
其次,你的运维工作中很可能包含迁移。
这是带有云端特色的运维任务,因为只要不是在云上创建的全新业务,传统业务在逐步上云的过程中一定会面临迁移工作。
迁移显然是非常大的一个话题,有些复杂的迁移项目,持续的时间可能长达几个月。这里我想告诉你两点最核心的建议:
第一在生产业务切换过来之前一定要对云上的新架构、新方案进行充分而深入的POC测试不可操之过急。对于复杂场景可能要通过不断地实践才能够逐步进化出完善的云上解决方案。
第二对于一些虚拟机、数据库等独立的软硬件单元许多云厂商都提供了官方的迁移服务或工具支持离线甚至在线迁移妥善使用可以事半功倍。比如AWS的主机迁移服务SMSServer Migration Service、数据库迁移服务DMSDatabase Migration Service和阿里云的数据传输服务DTSData Transmission Service等。
所以,当你遇到一些迁移场景时,不妨先查一查云厂商是否有官方的支持。由于迁移类服务能够直接为厂商导流获客,所以云厂商一般都会比较重视,往往能给你提供相当好的用户体验。
再次,云上的运维会包含和云厂商进行对接的工作。
毕竟我们的大厦是建立在云厂商所提供的基础设施之上的。云虽然已经高度成熟但作为一个高度复杂的系统也总难免会有不按你所期望进行工作的时候或者极为偶尔也会出些小Bug这时和云厂商的对接渠道就显得尤为重要了。
所以,我们的运维团队中需要有相应的角色对云的工单机制,以及技术支持侧的对接方式了然于胸,以备不时之需。你也要熟读文档,要吃透云计算的许多特性,这样才能更准确地与客服沟通,更快地寻求到对口的帮助,最后解决好问题。
最后,云上运维会具有很强的管理属性。
这里的管理,指的不仅仅是对云上资源的管理,更要深入到流程和制度的管理层面。比如对于云资源的命名、开通、清理等日常操作的规范,各类云上安全的控制和最佳实践,所有云资源的负责人、所属资源组和权限体系等等。这些都需要有效的管理手段,才能避免资源在云上的野蛮生长。
所以,高明的云上运维,既要为应用开发赋能,要足够高效,也要有适当的管理和约束。我们团队的组织架构和分工,最好也能够配合和适应这个需要。
好在云厂商也在不断推出和完善与云上管理相关的配套服务比如说Azure Policy能够限定只有某类型号的资源可以被创建还可以扫描和检查各种最佳实践是否得到了应用再比如AWS CloudTrail能够对账户内的操作进行监控和审计。如果你的组织内用户团队成员较多就值得好好探索研究一下这一类的云服务。
当然,管理层面还有一项重要事务,就是我们多次提到的成本管理。公司或团队中,应当有专人对成本进行监控和分析,以此提升每一位用户的成本意识。我自己曾使用的实践,是按月来组织资源的使用方进行成本消耗的回顾,分析资源使用的上升、下降趋势及其主要原因,同时还会检查月度账单明细,以杜绝成本浪费。
课堂总结与思考
今天这一讲,与其说是教程,不如说是和你一起探讨云上运维的相关要点。因为篇幅所限,今天我主要总结介绍了那些最重要的,和你最需要了解的内容,没有办法深入探究每一个与运维相关的细节。但你必须知道这些事务的存在,明白云上运维需要做哪些事情,这样在你需要的时候,才能有针对性地去查找资料,找到怎么做这些事情的方法。
当前业界的一个重要趋势是,运维和开发的边界正在模糊。所以我在前面提到的诸多运维工作,可能是由开发者来负责,也可能是运维人员来承担。这要根据你们公司和部门的具体情况来决定。但至少,这些工作很重要,无论由什么角色来完成,总是需要有人来扎实落地的。
所以从个人视角来看,作为开发者,你应该学习和掌握一些运维的知识和技巧,让自己变得更加全面和综合;如果作为运维人员,你也应该学习了解现代软件构建和系统架构方面的知识,尤其是学习云、掌握云,为云端架构的全面到来做好准备。
今天留给你的思考题是:
如果要执行一些云上的CLI命令你当然可以在自己的机器上安装命令行工具包但其实你还可以使用不少云都提供的非常方便的“Cloud Shell”。那你知道什么是Cloud Shell以及要如何使用它吗
前面讲到云上资源管理时,我提到了“资源组”的概念。你知道资源组是什么吗?它起到什么作用呢?
至此我们课程IaaS部分的8篇内容就全部结束了希望你有所收获。下一讲我们将进入精彩的PaaS世界。欢迎你留言与我交流咱们下期再见。

View File

@ -0,0 +1,131 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
09 什么是PaaS怎样深入理解和评估PaaS
你好,我是何恺铎。
欢迎你来到我们《深入浅出云计算》课程的第9讲这也是我们PaaS篇的第1讲。让我们继续精彩的云计算之旅。
PaaS对你来说也许不是一个陌生的词汇你可能早已从业界大咖或身边同事的高谈阔论中屡次听到这个字眼。不过很多人对于PaaS服务的评价可是既有“真香快来”的赞赏也不乏“大坑勿入”的批评面对如此两极分化的评价你估计也有点拿不定主意。这些如雷贯耳的PaaS服务们究竟靠不靠谱、好不好用呢
作为极客时间的一名“极客”咱们人云亦云可不行必须要建立起对PaaS的系统认知。从今天开始我们就来好好地研究一下PaaS。
让我们先从它的定义说起。
什么是PaaS
在IaaS篇中我们主要是侧重于基础设施类的云服务尤其是虚拟机、云磁盘、云网络等服务。它们的特点是和传统IT基础设施往往有一个对应关系所以被称为基础设施即服务Infrastructure-as-a-Service
今天我们的主角PaaS Platform-as-a-Service则是指云计算提供的平台类服务在这些平台的基础上用户可以直接开发、运行、管理应用程序而无需构建和维护底层的基础设施。
用更通俗的话来说PaaS是在IaaS的基础上又做了许多工作构建了很多关键抽象和可复用的单元让我们用户能够在更上层进行应用的构建把更多精力放在业务逻辑上。
拿房子装修来打个比方的话IaaS就好像空空如也的毛坯房我们还需要操心墙面、地板等基础性工作而PaaS就好比精装修的房子我们只要搬入自己喜欢的家具业务逻辑再适当装饰就可以“拎包入住”开始美好生活了。
小提示PaaS本身也是基于底层IaaS构建出来的使用了云上的各种基础设施。只是这个步骤云服务提供商代替我们用户完成了还进行了一定程度的封装。
当然随着PaaS服务形态种类的增多、边界的不断扩展除了那些包含语言运行环境、可编程和可扩展的经典PaaS服务之外还有更多的在云上用来辅助应用构建或帮助运维的服务也归入了广义上PaaS的范畴。这也是有道理的因为它们同样是完整的现代应用程序生态的一部分。
PaaS服务的核心优势是什么
如果你去回顾云计算的历史可能会惊奇地发现PaaS并不是在IaaS已经非常丰富和完善之后才出现的它们甚至可以说是“同龄人”。因为在云计算发展的初期不同公司选取了不同的发展路线有的侧重IaaS有的则先押宝了PaaS路线。
拓展不论是IaaS还是PaaS想要做好都不容易需要云厂商很大的投入。如果你对于相关的早期历史有兴趣可以参考我在InfoQ上发表的文章《激荡十年云计算的过去、现在与未来》。
从某种角度讲PaaS其实更符合云的初衷它代表了一种完全托管的理想主义也更能代表人们对于研发生产力的极致追求。
所以PaaS服务的优势就在于生产力在于效率尤其是在搭建和运维层面。比如我们课程后面会讲到大数据类的PaaS服务你可以很方便地一键启动规模庞大的大数据集群即刻开始运行分布式计算任务。想一想如果是由你自己基于虚拟机来进行搭建的话肯定得花上不少功夫。
进一步地来说云上的各种PaaS服务是可以互相配合叠加的。运用得当的话它们联合起来爆发出来的能力会非常强效率优势会更加凸显出来。
这里我给你举一个例子来说明一下PaaS服务的优势。
日志服务是我们应用程序后端不可或缺的一个组件通常我们会组合使用ELKElasticsearch+Logstash+Kibana技术栈来自行搭建一个日志存储和分析系统。
而在云上你可以轻松地找到PaaS服务来为你代劳。比如阿里云日志服务就提供了一个端到端的日志收集、查询、分析和可视化的解决方案。在这个过程中你不需要搭建和维护任何基础设施只要按照产品提示进行设置就可以了。
利用阿里云的日志服务我大概花了1分钟的时间就建立了一个日志服务实例并让它收集某个虚拟机/ data目录下的日志文件。随后我在目录中放置了一本小说《双城记》很快这个文本文件就被自动传送到了日志服务并索引起来。然后我就可以利用PaaS的功能来进行各种查询分析了。
下图为我搜索单词“happiness”的效果示例
阿里云日志服务的简单示例
怎样入手学习研究PaaS
由于软件构造的复杂性用户对于可复用组件的需求是非常多的。所以经过多年的发展下来云上的PaaS已经是琳琅满目、种类繁多。我们后面的课程也会陆续地讲解各种不同形式、服务不同目的的PaaS服务。
但在那之前我想告诉你观察和认知PaaS服务的方法。这里有几个重要的维度值得你探寻和了解让你能在清楚了它本身的业务用途之外还可以洞察这个服务在产品设计和内部实现方面的一些信息。
第一个维度,就是服务是否带有内生的运行环境。
我个人把它称为“承载性”即服务有没有运行时或执行环境来承载我们具体业务逻辑的代码或配置。如果有那么你需要去熟悉它的运行环境了解它支持的语法探寻各种参数设置。比如说Web服务可能带有Java、.NET等的运行时数据库服务可能会包含SQL的执行引擎。
如果没有内含的运行环境那就说明这个PaaS属于“开箱即用”的工具类型也就是直接依靠自身内置功能来向你提供支持或帮助。这时它功能的完善程度以及和你需求的匹配程度就比较关键了。
第二个维度是PaaS服务存在的位置和范围以及给予你的控制粒度。
这个怎么理解呢其实就是当你新建一个PaaS服务的实例你一般会需要告诉系统部署的目标位置在哪里。请你注意这个目标位置的选项是值得玩味的。比如你要仔细看看这个服务是只能粗放地允许你指定区域还是可以细化到可用区以及是否能够设置为部署在具体某个私有网络之内等等。
这个维度的信息一方面潜在地体现了PaaS服务的规模和可用性。比如云存储类服务一般只能让你选择区域因为它本身冗余性方面的多可用区架构要求决定了它无法支持指定更精细的位置。
另一方面这个维度也反映了你对这个服务的掌控程度你会知道它是否能够和你现有的架构进行深度集成。比如说你很可能要求数据库PaaS服务必须位于你指定的VPC内这样查询流量就能走内网通信避免对公网暴露数据库。
第三个维度,在于服务是否是“有状态”的,也就是指服务是否具有较强的数据属性。
有些PaaS服务本身是无状态的比如无服务器函数这意味着它们比较容易扩展和提升规模有些PaaS服务则会保存状态或者说建立的初衷就是为了维护各种复杂的状态和数据。这对应着PaaS在计算存储能力输出上的不同角色和分工。
第四个维度体现为支撑PaaS的虚拟机是否对外暴露也就是会不会显示在ECS、EC2等虚拟机服务的门户列表中。
这是一个很有趣的视角。因为作为PaaS实现者云厂商既可以选择开放也可以不开放。有时针对同一类的服务不同的云也可能采用不同的做法这体现了云厂商在规划产品上的不同思路也和它们各自的实现原理有关。
通常来说暴露虚拟机的PaaS服务拥有更高的开放程度和IaaS的结合也更加紧密甚至能够和其他IaaS服务配合联动。在成本方面这种形式还可以和预付费的虚拟机兼容让我们享受折扣。
而不暴露虚拟机的PaaS服务呢往往意味着更好的独立性和封装性说明它不希望你绕开机制来访问虚拟机比如大多数的数据库服务。还有一种常见的可能是这个服务需要专用硬件的配合并非纯粹依赖虚拟机。
好了有了上面的这些视角相信你即便是对于一个新的PaaS服务在快速研究之后也能迅速地把握好要点并进行归类同时形成清晰的高层次认识。对于它是否适合在你的架构中担任角色你也会有一个大致的判断。
衡量评估PaaS的局限
我们都知道软件工程的领域没有银弹。强大的PaaS也不例外也有自己的局限。
PaaS的核心理念在于封装封装既带来了效率的优势也同时带来了灵活性上的牺牲。我们需要在内置的设定和选项中开展工作不能天马行空、随心所欲。PaaS的应变能力也会差一些比如当它出现一些Bug或者运营事故时你无法自己动手去解决它而是需要等待厂商进行修复。
这是PaaS诞生以来就伴随着质疑的原因你的身边可能就有PaaS的反对者。有些以前只做PaaS的公有云公司也不得不向市场妥协陆续开始了IaaS产品的研发。这和早期云市场的接受程度有关也和当时PaaS自身的成熟度有关。
当然这里我讲的局限性不是为了奉劝你远离PaaS而是让你能更加客观地看待PaaS这个产品形态更好地评估某项PaaS服务是否适用于你的场景。因为PaaS在带来巨大效率提升的同时也的确要牺牲一点“自由”。
这里我要给你介绍一些检查PaaS限制的方法也是考察评估PaaS服务成熟度的重要思路你需要好好参考和把握。
功能屏蔽和自建服务相比你需要研究PaaS的封装是否带来了某项功能、部分选项还有扩展机制的屏蔽或者缺失以及这些功能对你而言是否重要。
版本选择你需要检查PaaS所提供的软件或运行环境的版本是否丰富最早和最新的版本各是什么还有版本粒度是否足够细致等等。我就曾经遇到过因为所需数据库版本在PaaS上不存在只能选择虚拟机进行部署的情况。
性能极限确认PaaS服务所能够提供的性能极值包括算力和存储的上限。你要和自己的需求量预测结合起来避免“上车”后骑虎难下。
更新频率查看PaaS服务的更新日志了解云厂商和相应团队在这个PaaS服务上是否还在继续做投入是否在跟进一些最新的技术趋势。
成本陷阱实际地通过POC实验对PaaS服务进行试运行注意要达到一定的量级然后仔细查看它对应的账单看看相关支出是否合理你能否长期承受。
所以对于PaaS来说其实设置界面选项越多往往越好这也不失为一个甄别产品成熟度的简单办法。你不应该担心产品学习曲线陡峭的问题这些不起眼的选项很可能在某个时刻被派上用场发挥关键的作用。
我还是要再次强调你应当理性地看待PaaS。它肯定不是无所不能但也绝非一无是处。更客观地学习了解它有助于建立你对PaaS的理解和信任在合适场景下最大化地发挥它的优势和价值。
我个人对于PaaS还是非常看好的它近年来日新月异的发展已经极大地提升了竞争力。随着大量用户的不断实践和反馈这些产品也越来越开放突破了过去的很多限制。有时即便PaaS相对自建会稍微贵一些我也会优先选择PaaS因为它带来的效率提升和时间人力的节省远远超出了贵出的那点价格。
最后我想再补充一点当云上官方的PaaS不足以满足你的需求时还有第三方PaaS是值得考虑的选择你通常能够在云厂商的各种云应用市场中找到它们。比如说大数据领域中炙手可热的Databricks公司就分别在AWS和Azure云都上架了自家的PaaS服务比起内置大数据的云服务来说也毫不逊色。
课堂总结与思考
作为PaaS篇的第一讲我就先和你讨论到这里了。希望通过今天对PaaS的讲解能够给你建立起一个对PaaS宏观层面的正确认识。同时我今天介绍的几个观察评估要点的确是你研究PaaS时值得参考的良好视角。后面在跟随课程讲到具体的各个PaaS服务的时候也请你记得时不时地回看这一讲的内容相互印证。
我自己是一个PaaS的乐观主义者。如果把你要构建的应用比作高楼大厦那么PaaS作为大厦的基石和支柱它是当之无愧、值得信赖的。在充分客观了解PaaS局限的前提下你不妨积极大胆地拥抱PaaS吧。
好了今天我留给你的思考题是你目前接触使用最多的PaaS服务是哪个它给你带来了怎样的效率提升同时它有没有什么局限让你伤脑筋呢
欢迎你在下方留言。如果你觉得这篇文章有帮助,欢迎你把它分享给你的朋友。我是何恺铎,感谢阅读,我们下期再见。

Some files were not shown because too many files have changed in this diff Show More