Dunwu Blog

大道至简,知易行难

Shiro 快速入门

Shiro 是一个安全框架,具有认证、授权、加密、会话管理功能。

一、Shiro 简介

Shiro 特性

核心功能:

  • Authentication - 认证。验证用户是不是拥有相应的身份。
  • Authorization - 授权。验证某个已认证的用户是否拥有某个权限;即判断用户是否能做事情,常见的如:验证某个用户是否拥有某个角色。或者细粒度的验证某个用户对某个资源是否具有某个权限。
  • Session Manager - 会话管理。即用户登录后就是一次会话,在没有退出之前,它的所有信息都在会话中。会话可以是普通 JavaSE 环境的,也可以是如 Web 环境的。
  • Cryptography - 加密。保护数据的安全性,如密码加密存储到数据库,而不是明文存储。

辅助功能:

  • Web Support - Web 支持。可以非常容易的集成到 Web 环境;
  • Caching - 缓存。比如用户登录后,其用户信息、拥有的角色 / 权限不必每次去查,这样可以提高效率;
  • Concurrency - 并发。Shiro 支持多线程应用的并发验证,即如在一个线程中开启另一个线程,能把权限自动传播过去;
  • Testing - 测试。提供测试支持;
  • Run As - 运行方式。允许一个用户假装为另一个用户(如果他们允许)的身份进行访问;
  • Remember Me - 记住我。即一次登录后,下次再访问免登录。

:bell: 注意:Shiro 不会去维护用户、维护权限;这些需要我们自己去提供;然后通过相应的接口注入给 Shiro 即可。

Shiro 架构概述

  • Subject - 主题。它代表当前用户,Subject 可以是一个人,但也可以是第三方服务、守护进程帐户、时钟守护任务或者其它——当前和软件交互的任何事件。Subject 是 Shiro 的入口。

    • PrincipalsSubject 的“识别属性”。Principals 可以是任何可以识别 Subject 的东西,例如名字(姓氏),姓氏(姓氏或姓氏),用户名,社会保险号等。当然,Principals 在应用程序中最好是惟一的。
    • Credentials 通常是仅由 Subject 知道的秘密值,用作他们实际上“拥有”所主张身份的佐证 凭据的一些常见示例是密码,生物特征数据(例如指纹和视网膜扫描)以及 X.509 证书。
  • SecurityManager - 安全管理。它是 Shiro 的核心,所有与安全有关的操作(认证、授权、及会话、缓存的管理)都与 SecurityManager 交互,且它管理着所有 Subject

  • Realm - 。用于访问安全相关数据,可以视为应用自身的数据源,需要开发者自己实现。Shiro 会通过 Realm 获取安全数据(如用户、角色、权限),就是说 SecurityManager 要验证用户身份,那么它需要从 Realm 获取相应的用户进行比较以确定用户身份是否合法;也需要从 Realm 得到用户相应的角色/权限进行验证用户是否能进行操作;可以把 Realm 看成 DataSource,即安全数据源。

SecurityManager

SecurityManager 是 Shiro 框架核心中的核心,它相当于 Shiro 的总指挥,负责调度所有行为,包括:认证、授权、获取安全数据(调用 Realm)、会话管理等。

img

SecurityManager 聚合了以下组件:

  • Authenticator - 认证器,负责认证。如果用户需要定制认证策略,可以实现此接口。
  • Authorizer - 授权器,负责权限控制。用来决定主体是否有权限进行相应的操作;即控制着用户能访问应用中的哪些功能;
  • SessionManager - 会话管理器。Shiro 抽象了一个自己的 Session 来管理主体与应用之间交互的数据。
  • SessionDAO - 会话 DAO 用于存储会话,需要用户自己实现。
  • CacheManager - 缓存控制器。用于管理如用户、角色、权限等信息的缓存。
  • Cryptography - 密码器。用于对数据加密、解密。

二、Shiro 认证

认证 Subject

验证 Subject 的过程可以有效地分为三个不同的步骤:

(1)收集 Subject 提交的 PrincipalsCredentials

1
2
3
4
5
//Example using most common scenario of username/password pair:
UsernamePasswordToken token = new UsernamePasswordToken(username, password);

//"Remember Me" built-in:
token.setRememberMe(true);

(2)提交 PrincipalsCredentials 以进行身份验证。

1
2
3
Subject currentUser = SecurityUtils.getSubject();

currentUser.login(token);

(3)如果提交成功,则允许访问,否则重试身份验证或阻止访问。

1
2
3
4
5
6
7
8
9
10
try {
currentUser.login(token);
} catch ( UnknownAccountException uae ) { ...
} catch ( IncorrectCredentialsException ice ) { ...
} catch ( LockedAccountException lae ) { ...
} catch ( ExcessiveAttemptsException eae ) { ...
} ... catch your own ...
} catch ( AuthenticationException ae ) {
//unexpected error?
}

Remembered 和 Authenticated

  • Remembered - 记住我。被记住的 Subject 不是匿名的,并且具有已知的身份(即 subject.getPrincipals() 是非空的)。 但是,在先前的会话期间,通过先前的身份验证会记住此身份。 如果 subject.isRemembered() 返回 true,则认为该主题已被记住。
  • Authenticated - 已认证。已认证的 Subject 是在当前会话期间已成功认证的 Subject。 如果 subject.isAuthenticated() 返回 true,则认为该 Subject 已通过身份验证。

登出

当 Subject 与应用程序完成交互后,可以调用 subject.logout() 登出,即放弃所有标识信息。

1
currentUser.logout();

认证流程

img

  1. 应用程序代码调用 Subject.login 方法,传入构造的 AuthenticationToken 实例,该实例代表最终用户的 PrincipalsCredentials

  2. Subject 实例(通常是 DelegatingSubject(或子类))通过调用 securityManager.login(token)委托应用程序的 SecurityManager,在此处开始实际的身份验证工作。

  3. SecurityManager 接收令牌,并通过调用 authenticator.authenticate(token)来简单地委派给其内部 Authenticator 实例。这几乎总是一个 ModularRealmAuthenticator 实例,它支持在身份验证期间协调一个或多个 Realm 实例。

  4. 如果为该应用程序配置了多个 Realm,则 ModularRealmAuthenticator 实例将利用其配置的 AuthenticationStrategy 发起多域验证尝试。在调用领域进行身份验证之前,期间和之后,将调用 AuthenticationStrategy 以使其对每个领域的结果做出反应。

  5. 请咨询每个已配置的 Realm,以查看其是否支持提交的 AuthenticationToken。 如果是这样,将使用提交的令牌调用支持 RealmgetAuthenticationInfo 方法。 getAuthenticationInfo 方法有效地表示对该特定 Realm 的单个身份验证尝试。

认证策略

当为一个应用程序配置两个或多个领域时,ModularRealmAuthenticator 依赖于内部 AuthenticationStrategy 组件来确定认证尝试成功或失败的条件。

例如,如果只有一个 Realm 成功地对 AuthenticationToken 进行身份验证,而所有其他 Realm 都失败了,那么该身份验证尝试是否被视为成功?还是必须所有领域都成功进行身份验证才能将整体尝试视为成功?或者,如果某个领域成功通过身份验证,是否有必要进一步咨询其他领域? AuthenticationStrategy 根据应用程序的需求做出适当的决定。

AuthenticationStrategy 是无状态组件,在尝试进行身份验证时会被查询 4 次(这 4 种交互所需的任何必要状态都将作为方法参数给出):

  • 在任何领域被调用之前
  • 在调用单个 RealmgetAuthenticationInfo 方法之前
  • 在调用单个 RealmgetAuthenticationInfo 方法之后
  • 在所有领域都被调用之后

AuthenticationStrategy 还负责汇总每个成功 Realm 的结果,并将它们“捆绑”成单个 AuthenticationInfo 表示形式。最终的聚合 AuthenticationInfo 实例是 Authenticator 实例返回的结果,也是 Shiro 用来表示主体的最终身份(也称为委托人)的东西。

AuthenticationStrategy 描述
AtLeastOneSuccessfulStrategy 只要有一个 Realm 成功认证,则整个尝试都被视为成功。
FirstSuccessfulStrategy 仅使用从第一个成功通过身份验证的 Realm 返回的信息,所有其他 Realm 将被忽略。
AllSuccessfulStrategy 只有所有 Realm 成功认证,则整个尝试才被视为成功。

:link: 更多认证细节可以参考:Apache Shiro Authentication

三、Shiro 授权

授权,也称为访问控制,是管理对资源的访问的过程。 换句话说,控制谁有权访问应用程序中的内容。

授权元素

授权有三个核心要素:权限、角色和用户。

权限

权限示例:

  • 打开一个文件
  • 查看 /user/list web 页面
  • 查询记录
  • 删除一条记录

大多数资源都支持一般的 CRUD 操作。除此以外,对于一些特定的资源,任何有意义的行为都是可以的。基本的设计思路是:权限控制,至少是基于资源和行为。

角色

角色是一个命名实体,通常代表一组行为或职责。这些行为会转化为:谁可以在应用程序中执行哪些行为?谁不可以在程序中执行哪些行为?

角色通常是分配给用户帐户的,因此通过关联,用户可以获得自身角色所赋予的权限。

用户

用户本质上是应用程序的“用户”。

用户(即 Shiro 的 Subject)通过与角色或直接权限的关联在应用程序中执行某些行为。

基于角色的授权

如果授权是基于角色赋予权限的数据模型,编程模式如下:

【示例一】

1
2
3
4
5
6
7
Subject currentUser = SecurityUtils.getSubject();

if (currentUser.hasRole("administrator")) {
//show the admin button
} else {
//don't show the button? Grey it out?
}

【示例二】

1
2
3
4
5
6
Subject currentUser = SecurityUtils.getSubject();

// 检查当前 Subject 是否有某种权限
// 如果有,直接跳过;如果没有,Shiro 会抛出 AuthorizationException
currentUser.checkRole("bankTeller");
openBankAccount();

提示:方式二相比方式一,代码更简洁

基于权限的授权

更好的授权策略通常是基于权限的授权。基于权限的授权,由于它和应用程序的原始功能(针对具体资源上的行为)紧密相关,所以基于权限的授权源代码会在功能更改时同步更改(而不是在安全策略发生更改时)。 这意味着与类似的基于角色的授权代码相比,修改代码的影响面要小得多。

【示例】基于对象的权限检查

1
2
3
4
5
6
7
8
9
Permission printPermission = new PrinterPermission("laserjet4400n", "print");

Subject currentUser = SecurityUtils.getSubject();

if (currentUser.isPermitted(printPermission)) {
//show the Print button
} else {
//don't show the button? Grey it out?
}

在对象中存储权限控制信息,但这种方式较为繁重

【示例】字符串定义权限控制信息

1
2
3
4
5
6
7
Subject currentUser = SecurityUtils.getSubject();

if (currentUser.isPermitted("printer:print:laserjet4400n")) {
//show the Print button
} else {
//don't show the button? Grey it out?
}

使用 : 分隔,表示资源类型、行为、资源 ID,Shiro 提供了默认实现: org.apache.shiro.authz.permission.WildcardPermission

这种权限控制方式的好处在于:轻量、灵活。

基于注解的授权

Shiro 提供了一些用于授权的注解,来进一步简化授权代码。

@RequiresAuthentication

@RequiresAuthentication 注解要求当前 Subject 必须是已认证用户才可以访问被修饰的方法。

【示例】

1
2
3
4
5
6
@RequiresAuthentication
public void updateAccount(Account userAccount) {
//this method will only be invoked by a
//Subject that is guaranteed authenticated
...
}

@RequiresGuest

@RequiresGuest 注解要求当前 Subject 的角色是 guest 才可以访问被修饰的方法。

授权流程

img

  1. 应用程序或框架代码调用任何 SubjecthasRole*checkRole*isPermitted*checkPermission* 方法,并传入所需的权限或角色。

  2. Subject 实例,通常是 DelegatingSubject(或子类),通过调用 securityManager 几乎相同的各自 hasRole*checkRole*isPermitted*checkPermission* 方法来委托 SecurityManager (实现了 org.apache.shiro.authz.Authorizer 接口)处理授权。

  3. SecurityManager 通过调用授权者各自的 hasRole*checkRole*isPermitted*checkPermission* 方法来中继/委托其内部的 org.apache.shiro.authz.Authorizer 实例。默认情况下,authorizer 实例是 ModularRealmAuthorizer 实例,该实例支持在任何授权操作期间协调一个或多个 Realm 实例。

  4. 检查每个已配置的 Realm,以查看其是否实现相同的 Authorizer 接口。如果是这样,则将调用 Realm 各自的 hasRole*checkRole*isPermitted*checkPermission* 方法。

:link: 更多授权细节可以参考:Apache Shiro Authorization

四、Shiro 会话管理

Shiro 提供了一套独特的会话管理方案:其 Session 可以使用 Java SE 程序,也可以使用于 Java Web 程序。

在 Shiro 中,SessionManager 负责管理应用所有 Subject 的会话,如:创建、删除、失效、验证等。

【示例】会话使用示例

1
2
3
4
Subject currentUser = SecurityUtils.getSubject();

Session session = currentUser.getSession();
session.setAttribute( "someKey", someValue);

会话超时

默认情况下,Shiro 中的会话有效期为 30 分钟,超时后,该会话将被 Shiro 视为无效。

可以通过 globalSessionTimeout 方法设置 Shiro 会话超时时间。

会话监听

Shiro 提供了 SessionListener 接口(或 SessionListenerAdapter 接口),用于监听重要的会话事件,并允许使用者在事件触发时做定制化处理。

【示例】

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class ShiroSessionListener implements SessionListener {

private final Logger log = LoggerFactory.getLogger(this.getClass());

private final AtomicInteger sessionCount = new AtomicInteger(0);

@Override
public void onStart(Session session) {
sessionCount.incrementAndGet();
}

@Override
public void onStop(Session session) {
sessionCount.decrementAndGet();
}

@Override
public void onExpiration(Session session) {
sessionCount.decrementAndGet();
}
}

会话存储

大多数情况下,应用需要保存会话信息,以便在稍后可以使用它。

Shiro 提供了 SessionManager 接口,负责将针对会话的 CRUD 操作委派给内部组件 SessionDAO,该组件反映了数据访问对象(DAO)设计模式。

:bell: 注意:由于会话通常具有时效性,所以一般会话天然适合存储于缓存中。存储于 Redis 中是一个不错的选择。

五、Realm

Realm 是 Shiro 访问程序安全相关数据(如:用户、角色、权限)的接口。

Realm 是有开发者自己实现的,开发者可以通过实现 Realm 接口,接入应用的数据源,如:JDBC、文件、Nosql 等等。

认证令牌

Shiro 支持身份验证令牌。在咨询 Realm 进行认证尝试之前,将调用其支持方法。 如果返回值为 true,则仅会调用其 getAuthenticationInfo(token) 方法。通常,Realm 会检查所提交令牌的类型(接口或类),以查看其是否可以处理它。

令牌认证处理流程如下:

  1. 检查用于标识 principal 的令牌(帐户标识信息)。
  2. 根据 principal,在数据源中查找相应的帐户数据。
  3. 确保令牌提供的凭证与数据存储中存储的凭证匹配。
  4. 如果 credentials 匹配,则返回 AuthenticationInfo 实例。
  5. 如果 credentials 不匹配,则抛出 AuthenticationException 异常。

加密

通过前文,可以了解:Shiro 需要通过一对 principal 和 credentials 来确认身份是否匹配(即认证)。

一般来说,成熟软件是不允许存储账户、密码这些敏感数据时,使用明文存储。所以,通常要将密码加密后存储。

Shiro 提供了一些加密器,其思想就是用 MD5、SHA 这种数字签名算法,加 Salt,然后转为 Base64 字符串。为了避免被暴力破解,Shiro 使用多次加密的方式获得最终的 credentials 字符串。

【示例】Shiro 加密密码示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import org.apache.shiro.crypto.hash.Sha256Hash;
import org.apache.shiro.crypto.RandomNumberGenerator;
import org.apache.shiro.crypto.SecureRandomNumberGenerator;
...

//We'll use a Random Number Generator to generate salts. This
//is much more secure than using a username as a salt or not
//having a salt at all. Shiro makes this easy.
//
//Note that a normal app would reference an attribute rather
//than create a new RNG every time:
RandomNumberGenerator rng = new SecureRandomNumberGenerator();
Object salt = rng.nextBytes();

//Now hash the plain-text password with the random salt and multiple
//iterations and then Base64-encode the value (requires less space than Hex):
String hashedPasswordBase64 = new Sha256Hash(plainTextPassword, salt, 1024).toBase64();

User user = new User(username, hashedPasswordBase64);
//save the salt with the new account. The HashedCredentialsMatcher
//will need it later when handling login attempts:
user.setPasswordSalt(salt);
userDAO.create(user);

六、配置

过滤链

运行 Web 应用程序时,Shiro 将创建一些有用的默认 Filter 实例。

Filter Name Class
anon org.apache.shiro.web.filter.authc.AnonymousFilter
authc org.apache.shiro.web.filter.authc.FormAuthenticationFilter
authcBasic org.apache.shiro.web.filter.authc.BasicHttpAuthenticationFilter
logout org.apache.shiro.web.filter.authc.LogoutFilter
noSessionCreation org.apache.shiro.web.filter.session.NoSessionCreationFilter
perms org.apache.shiro.web.filter.authz.PermissionsAuthorizationFilter
port org.apache.shiro.web.filter.authz.PortFilter
rest org.apache.shiro.web.filter.authz.HttpMethodPermissionFilter
roles org.apache.shiro.web.filter.authz.RolesAuthorizationFilter
ssl org.apache.shiro.web.filter.authz.SslFilter
user org.apache.shiro.web.filter.authc.UserFilter

RememberMe

1
2
3
UsernamePasswordToken token = new UsernamePasswordToken(username, password);
token.setRememberMe(true);
SecurityUtils.getSubject().login(token);

参考资料

Spring Security 快速入门

快速开始

参考:Securing a Web Application

核心 API

设计原理

Spring Security 对于 Servlet 的支持基于过滤链(FilterChain)实现。

Spring 提供了一个名为 DelegatingFilterProxyFilter 实现,该实现允许在 Servlet 容器的生命周期和 Spring 的 ApplicationContext 之间进行桥接。 Servlet 容器允许使用其自己的标准注册 Filters,但它不了解 Spring 定义的 Bean。 DelegatingFilterProxy 可以通过标准的 Servlet 容器机制进行注册,但是可以将所有工作委托给实现 Filter 的 Spring Bean。

1
2
3
4
5
6
7
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) {
// Lazily get Filter that was registered as a Spring Bean
// For the example in DelegatingFilterProxy delegate is an instance of Bean Filter0
Filter delegate = getFilterBean(someBeanName);
// delegate work to the Spring Bean
delegate.doFilter(request, response);
}

FilterChainProxy 使用 SecurityFilterChain 确定应对此请求调用哪些 Spring Security 过滤器。

SecurityFilterChain 中的安全过滤器通常是 Bean,但它们是使用 FilterChainProxy 而不是 DelegatingFilterProxy 注册的。

实际上,FilterChainProxy 可用于确定应使用哪个 SecurityFilterChain。如果您的应用程序可以为不同的模块提供完全独立的配置。

multi securityfilterchain

ExceptionTranslationFilter 可以将 AccessDeniedException 和 AuthenticationException 转换为 HTTP 响应。

exceptiontranslationfilter

核心源码:

1
2
3
4
5
6
7
8
9
try {
filterChain.doFilter(request, response);
} catch (AccessDeniedException | AuthenticationException e) {
if (!authenticated || e instanceof AuthenticationException) {
startAuthentication();
} else {
accessDenied();
}
}

认证

数据模型

Spring Security 框架中的认证数据模型如下:

img

  • Authentication - 认证信息实体。
    • principal - 用户标识。如:用户名、账户名等。通常是 UserDetails 的实例(后面详细讲解)。
    • credentials - 认证凭证。如:密码等。
    • authorities - 授权信息。如:用户的角色、权限等信息。
  • SecurityContext - 安全上下文。包含一个 Authentication 对象。
  • SecurityContextHolder - 安全上下文持有者。用于存储认证信息。

【示例】注册认证信息

1
2
3
4
5
SecurityContext context = SecurityContextHolder.createEmptyContext();
Authentication authentication =
new TestingAuthenticationToken("username", "password", "ROLE_USER");
context.setAuthentication(authentication);
SecurityContextHolder.setContext(context);

【示例】访问认证信息

认证基本流程

AbstractAuthenticationProcessingFilter 用作验证用户凭据的基本过滤器。 在对凭证进行身份验证之前,Spring Security 通常使用 AuthenticationEntryPoint 请求凭证。

abstractauthenticationprocessingfilter

  • (1)当用户提交其凭据时,AbstractAuthenticationProcessingFilter 从要验证的 HttpServletRequest 创建一个 Authentication。创建的身份验证类型取决于 AbstractAuthenticationProcessingFilter 的子类。例如,UsernamePasswordAuthenticationFilter 根据在 HttpServletRequest 中提交的用户名和密码来创建 UsernamePasswordAuthenticationToken
  • (2)接下来,将身份验证传递到 AuthenticationManager 进行身份验证。
  • (3)如果身份验证失败,则认证失败
    • 清除 SecurityContextHolder
    • 调用 RememberMeServices.loginFail。如果没有配置 remember me,则为空。
    • 调用 AuthenticationFailureHandler
  • (4)如果身份验证成功,则认证成功。
    • 如果是新的登录,则通知 SessionAuthenticationStrategy
    • 身份验证是在 SecurityContextHolder 上设置的。之后,SecurityContextPersistenceFilterSecurityContext 保存到 HttpSession 中。
    • 调用 RememberMeServices.loginSuccess。如果没有配置 remember me,则为空。
    • ApplicationEventPublisher 发布一个 InteractiveAuthenticationSuccessEvent

用户名/密码认证

读取用户名和密码的方式:

  • 表单
  • 基本认证
  • 数字认证

存储机制

表单认证

spring security 支持通过从 html 表单获取登录时提交的用户名、密码。

loginurlauthenticationentrypoint

一旦,登录信息被提交,UsernamePasswordAuthenticationFilter 就会验证用户名和密码。

usernamepasswordauthenticationfilter

基本认证

1
2
3
4
5
protected void configure(HttpSecurity http) {
http
// ...
.httpBasic(withDefaults());
}

内存认证

InMemoryUserDetailsManager 实现了 UserDetailsService ,提供了基本的用户名、密码认证,其认证数据存储在内存中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Bean
public UserDetailsService users() {
// The builder will ensure the passwords are encoded before saving in memory
UserBuilder users = User.withDefaultPasswordEncoder();
UserDetails user = users
.username("user")
.password("password")
.roles("USER")
.build();
UserDetails user = users
.username("admin")
.password("password")
.roles("USER", "ADMIN")
.build();
return new InMemoryUserDetailsManager(user, admin);
}

JDBC 认证

JdbcUserDetailsManager 实现了 UserDetailsService ,提供了基本的用户名、密码认证,其认证数据存储在关系型数据库中,通过 JDBC 方式访问。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Bean
UserDetailsManager users(DataSource dataSource) {
UserDetails user = User.builder()
.username("user")
.password("{bcrypt}$2a$10$GRLdNijSQMUvl/au9ofL.eDwmoohzzS7.rmNSJZ.0FxO/BTk76klW")
.roles("USER")
.build();
UserDetails admin = User.builder()
.username("admin")
.password("{bcrypt}$2a$10$GRLdNijSQMUvl/au9ofL.eDwmoohzzS7.rmNSJZ.0FxO/BTk76klW")
.roles("USER", "ADMIN")
.build();
JdbcUserDetailsManager users = new JdbcUserDetailsManager(dataSource);
users.createUser()
}

基本的 scheam:

1
2
3
4
5
6
7
8
9
10
11
12
create table users(
username varchar_ignorecase(50) not null primary key,
password varchar_ignorecase(50) not null,
enabled boolean not null
);

create table authorities (
username varchar_ignorecase(50) not null,
authority varchar_ignorecase(50) not null,
constraint fk_authorities_users foreign key(username) references users(username)
);
create unique index ix_auth_username on authorities (username,authority);

UserDetailsService

UserDetailsUserDetailsService 返回。 DaoAuthenticationProvider 验证 UserDetails,然后返回身份验证,该身份验证的主体是已配置的 UserDetailsService 返回的 UserDetails

DaoAuthenticationProvider 使用 UserDetailsService 检索用户名,密码和其他用于使用用户名和密码进行身份验证的属性。 Spring Security 提供 UserDetailsService 的内存中和 JDBC 实现。

您可以通过将自定义 UserDetailsService 公开为 bean 来定义自定义身份验证。

PasswordEncoder

Spring Security 的 servlet 支持通过与 PasswordEncoder 集成来安全地存储密码。 可以通过公开一个 PasswordEncoder Bean 来定制 Spring Security 使用的 PasswordEncoder 实现。

daoauthenticationprovider

Remember-Me

Spring Boot 集成

@EnableWebSecurity@Configuration 注解一起使用, 注解 WebSecurityConfigurer 类型的类。

或者利用@EnableWebSecurity注解继承 WebSecurityConfigurerAdapter 的类,这样就构成了 Spring Security 的配置。

  • configure(WebSecurity):通过重载该方法,可配置 Spring Security 的 Filter 链。
  • configure(HttpSecurity):通过重载该方法,可配置如何通过拦截器保护请求。

参考资料

Netty 快速入门

Netty 简介

Netty 是一款基于 NIO(Nonblocking I/O,非阻塞 IO)开发的网络通信框架

Netty 的特性

  • 高并发:Netty 是一款基于 NIO(Nonblocking IO,非阻塞 IO)开发的网络通信框架,对比于 BIO(Blocking I/O,阻塞 IO),他的并发性能得到了很大提高。
  • 传输快:Netty 的传输依赖于内存零拷贝特性,尽量减少不必要的内存拷贝,实现了更高效率的传输。
  • 封装好:Netty 封装了 NIO 操作的很多细节,提供了易于使用调用接口。

核心组件

  • Channel:Netty 网络操作抽象类,它除了包括基本的 I/O 操作,如 bind、connect、read、write 等。
  • EventLoop:主要是配合 Channel 处理 I/O 操作,用来处理连接的生命周期中所发生的事情。
  • ChannelFuture:Netty 框架中所有的 I/O 操作都为异步的,因此我们需要 ChannelFuture 的 addListener()注册一个 ChannelFutureListener 监听事件,当操作执行成功或者失败时,监听就会自动触发返回结果。
  • ChannelHandler:充当了所有处理入站和出站数据的逻辑容器。ChannelHandler 主要用来处理各种事件,这里的事件很广泛,比如可以是连接、数据接收、异常、数据转换等。
  • ChannelPipeline:为 ChannelHandler 链提供了容器,当 channel 创建时,就会被自动分配到它专属的 ChannelPipeline,这个关联是永久性的。

Netty 有两种发送消息的方式:

  • 直接写入 Channel 中,消息从 ChannelPipeline 当中尾部开始移动;
  • 写入和 ChannelHandler 绑定的 ChannelHandlerContext 中,消息从 ChannelPipeline 中的下一个 ChannelHandler 中移动。

高性能

Netty 高性能表现在哪些方面:

  • NIO 线程模型:同步非阻塞,用最少的资源做更多的事。
  • 内存零拷贝:尽量减少不必要的内存拷贝,实现了更高效率的传输。
  • 内存池设计:申请的内存可以重用,主要指直接内存。内部实现是用一颗二叉查找树管理内存分配情况。
  • 串形化处理读写:避免使用锁带来的性能开销。
  • 高性能序列化协议:支持 protobuf 等高性能序列化协议。

零拷贝

传统意义的拷贝

是在发送数据的时候,传统的实现方式是:

File.read(bytes)

Socket.send(bytes)

这种方式需要四次数据拷贝和四次上下文切换:

  1. 数据从磁盘读取到内核的 read buffer

  2. 数据从内核缓冲区拷贝到用户缓冲区

  3. 数据从用户缓冲区拷贝到内核的 socket buffer

  4. 数据从内核的 socket buffer 拷贝到网卡接口(硬件)的缓冲区

零拷贝的概念

明显上面的第二步和第三步是非必要的,通过 java 的 FileChannel.transferTo 方法,可以避免上面两次多余的拷贝(当然这需要底层操作系统支持)

  • 调用 transferTo,数据从文件由 DMA 引擎拷贝到内核 read buffer
  • 接着 DMA 从内核 read buffer 将数据拷贝到网卡接口 buffer

上面的两次操作都不需要 CPU 参与,所以就达到了零拷贝。

Netty 中的零拷贝

主要体现在三个方面:

bytebuffer

Netty 发送和接收消息主要使用 bytebuffer,bytebuffer 使用对外内存(DirectMemory)直接进行 Socket 读写。

原因:如果使用传统的堆内存进行 Socket 读写,JVM 会将堆内存 buffer 拷贝一份到直接内存中然后再写入 socket,多了一次缓冲区的内存拷贝。DirectMemory 中可以直接通过 DMA 发送到网卡接口

Composite Buffers

传统的 ByteBuffer,如果需要将两个 ByteBuffer 中的数据组合到一起,我们需要首先创建一个 size=size1+size2 大小的新的数组,然后将两个数组中的数据拷贝到新的数组中。但是使用 Netty 提供的组合 ByteBuf,就可以避免这样的操作,因为 CompositeByteBuf 并没有真正将多个 Buffer 组合起来,而是保存了它们的引用,从而避免了数据的拷贝,实现了零拷贝。

对于 FileChannel.transferTo 的使用

Netty 中使用了 FileChannel 的 transferTo 方法,该方法依赖于操作系统实现零拷贝。

Netty 流程

应用

Netty 是一个广泛使用的 Java 网络编程框架。很多著名软件都使用了它,如:Dubbo、Cassandra、Elasticsearch、Vert.x 等。

有了 Netty,你可以实现自己的 HTTP 服务器,FTP 服务器,UDP 服务器,RPC 服务器,WebSocket 服务器,Redis 的 Proxy 服务器,MySQL 的 Proxy 服务器等等。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
public class NettyOioServer {

public void server(int port) throws Exception {
final ByteBuf buf = Unpooled.unreleasableBuffer(
Unpooled.copiedBuffer("Hi!\r\n", Charset.forName("UTF-8")));
EventLoopGroup group = new OioEventLoopGroup();
try {
ServerBootstrap b = new ServerBootstrap(); //1

b.group(group) //2
.channel(OioServerSocketChannel.class)
.localAddress(new InetSocketAddress(port))
.childHandler(new ChannelInitializer<SocketChannel>() {//3
@Override
public void initChannel(SocketChannel ch)
throws Exception {
ch.pipeline().addLast(new ChannelInboundHandlerAdapter() { //4
@Override
public void channelActive(ChannelHandlerContext ctx) throws Exception {
ctx.writeAndFlush(buf.duplicate()).addListener(ChannelFutureListener.CLOSE);//5
}
});
}
});
ChannelFuture f = b.bind().sync(); //6
f.channel().closeFuture().sync();
} finally {
group.shutdownGracefully().sync(); //7
}
}
}

参考资料

Java 缓存中间件

关键词:Spring Cache、J2Cache、JetCache

一 、JSR 107

JSR107 中制订了 Java 缓存的规范。

因此,在很多缓存框架、缓存库中,其 API 都参考了 JSR 107 规范。

img

Java Caching 定义了 5 个核心接口

  • CachingProvider - 定义了创建、配置、获取、管理和控制多个 CacheManager。一个应用可以在运行期访问多个 CachingProvider
  • CacheManager - 定义了创建、配置、获取、管理和控制多个唯一命名的 Cache,这些 Cache 存在于 CacheManager 的上下文中。一个 CacheManager 仅被一个 CachingProvider 所拥有。
  • Cache - 是一个类似 Map 的数据结构并临时存储以 Key 为索引的值。一个 Cache 仅被一个 CacheManager 所拥有。
  • Entry - 是一个存储在 Cache 中的 key-value 对。
  • Expiry - 每一个存储在 Cache 中的条目有一个定义的有效期,即 Expiry Duration。一旦超过这个时间,条目为过期的状态。一旦过期,条目将不可访问、更新和删除。缓存有效期可以通过 ExpiryPolicy 设置。

二、Spring Cache

详见:Spring Cache 官方文档

Spring 作为 Java 开发最著名的框架,也提供了缓存功能的框架—— Spring Cache。

Spring 支持基于注释(annotation)的缓存(cache)技术,它本质上不是一个具体的缓存实现方案(例如:EHCache 或 OSCache),而是一个对缓存使用的抽象,通过在既有代码中添加少量它定义的各种 annotation,即能够达到缓存方法的返回对象的效果。

Spring Cache 的特点:

  • 通过缓存注解即可支持缓存功能
  • 支持 Spring EL 表达式
  • 支持 AspectJ
  • 支持自定义 key 和缓存管理

开启缓存注解

Spring 为缓存功能提供了注解功能,但是你必须启动注解。

有两种方式:

(一)使用标记注解 @EnableCaching

这种方式对于 Spring 或 Spring Boot 项目都适用。

1
2
3
4
@Configuration
@EnableCaching
public class AppConfig {
}

(二)在 xml 中声明

1
<cache:annotation-driven cache-manager="cacheManager"/>

spring 缓存注解 API

Spring 对缓存的支持类似于对事务的支持。

首先使用注解标记方法,相当于定义了切点,然后使用 Aop 技术在这个方法的调用前、调用后获取方法的入参和返回值,进而实现了缓存的逻辑。

@Cacheable

@Cacheable 用于触发缓存

表明所修饰的方法是可以缓存的:当第一次调用这个方法时,它的结果会被缓存下来,在缓存的有效时间内,以后访问这个方法都直接返回缓存结果,不再执行方法中的代码段。

这个注解可以用condition属性来设置条件,如果不满足条件,就不使用缓存能力,直接执行方法。

可以使用key属性来指定 key 的生成规则。

@CachePut

@CachePut 用于更新缓存

@Cacheable不同,@CachePut不仅会缓存方法的结果,还会执行方法的代码段。

它支持的属性和用法都与@Cacheable一致。

@CacheEvict

@CacheEvict 用于清除缓存

@Cacheable功能相反,@CacheEvict表明所修饰的方法是用来删除失效或无用的缓存数据。

下面是@Cacheable@CacheEvict@CachePut基本使用方法的一个集中展示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
@Service
public class UserService {
// @Cacheable可以设置多个缓存,形式如:@Cacheable({"books", "isbns"})
@Cacheable(value={"users"}, key="#user.id")
public User findUser(User user) {
return findUserInDB(user.getId());
}

@Cacheable(value = "users", condition = "#user.getId() <= 2")
public User findUserInLimit(User user) {
return findUserInDB(user.getId());
}

@CachePut(value = "users", key = "#user.getId()")
public void updateUser(User user) {
updateUserInDB(user);
}

@CacheEvict(value = "users")
public void removeUser(User user) {
removeUserInDB(user.getId());
}

@CacheEvict(value = "users", allEntries = true)
public void clear() {
removeAllInDB();
}
}

@Caching

@Caching 用于组合定义多种缓存功能

如果需要使用同一个缓存注解(@Cacheable@CacheEvict@CachePut)多次修饰一个方法,就需要用到@Caching

1
2
@Caching(evict = { @CacheEvict("primary"), @CacheEvict(cacheNames="secondary", key="#p0") })
public Book importBooks(String deposit, Date date)

@CacheConfig

@CacheConfig 用于定义公共缓存配置

与前面的缓存注解不同,这是一个类级别的注解。

如果类的所有操作都是缓存操作,你可以使用@CacheConfig来指定类,省去一些配置。

1
2
3
4
5
@CacheConfig("books")
public class BookRepositoryImpl implements BookRepository {
@Cacheable
public Book findBook(ISBN isbn) {...}
}

三、Spring Boot Cache

详见:Spring Boot Cache 特性官方文档

Spring Boot Cache 是在 Spring Cache 的基础上做了封装,使得使用更为便捷。

Spring Boot Cache 快速入门

(1)引入依赖

1
2
3
4
5
6
7
8
9
10
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>

<!-- 按序引入需要的缓存库 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

(2)缓存配置

例如,选用缓存为 redis,则需要配置 redis 相关的配置项(如:数据源、连接池等配置信息)

1
2
3
4
5
6
7
8
9
10
# 缓存类型,支持类型:GENERIC、JCACHE、EHCACHE、HAZELCAST、INFINISPAN、COUCHBASE、REDIS、CAFFEINE、SIMPLE
spring.cache.type = redis
# 全局缓存时间
spring.cache.redis.time-to-live = 60s

# Redis 配置
spring.redis.database = 0
spring.redis.host = localhost
spring.redis.port = 6379
spring.redis.password =

(3)使用 @EnableCaching 开启缓存

1
2
3
4
5
@EnableCaching
@SpringBootApplication
public class Application {
// ...
}

(4)缓存注解(@Cacheable@CachePut@CacheEvit 等)使用方式与 Spring Cache 完全一样

四、JetCache

JetCache 是一个基于 Java 的缓存系统封装,提供统一的 API 和注解来简化缓存的使用。 JetCache 提供了比 SpringCache 更加强大的注解,可以原生的支持 TTL、两级缓存、分布式自动刷新,还提供了Cache接口用于手工缓存操作。 当前有四个实现,RedisCacheTairCache(此部分未在 github 开源)、CaffeineCache(in memory)和一个简易的LinkedHashMapCache(in memory),要添加新的实现也是非常简单的。

详见:jetcache Github

jetcache 快速入门

如果使用 Spring Boot,可以按如下的方式配置(这里使用了 jedis 客户端连接 redis,如果需要集群、读写分离、异步等特性支持请使用lettuce客户端)。

(1)引入 POM

1
2
3
4
5
<dependency>
<groupId>com.alicp.jetcache</groupId>
<artifactId>jetcache-starter-redis</artifactId>
<version>2.5.14</version>
</dependency>

(2)配置

配置一个 spring boot 风格的 application.yml 文件,把他放到资源目录中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
jetcache:
statIntervalMinutes: 15
areaInCacheName: false
local:
default:
type: linkedhashmap
keyConvertor: fastjson
remote:
default:
type: redis
keyConvertor: fastjson
valueEncoder: java
valueDecoder: java
poolConfig:
minIdle: 5
maxIdle: 20
maxTotal: 50
host: 127.0.0.1
port: 6379

(3)开启缓存

然后创建一个 App 类放在业务包的根下,EnableMethodCache,EnableCreateCacheAnnotation 这两个注解分别激活 Cached 和 CreateCache 注解,其他和标准的 Spring Boot 程序是一样的。这个类可以直接 main 方法运行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package com.company.mypackage;

import com.alicp.jetcache.anno.config.EnableCreateCacheAnnotation;
import com.alicp.jetcache.anno.config.EnableMethodCache;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
@EnableMethodCache(basePackages = "com.company.mypackage")
@EnableCreateCacheAnnotation
public class MySpringBootApp {
public static void main(String[] args) {
SpringApplication.run(MySpringBootApp.class);
}
}

(4)API 基本使用

创建缓存实例

通过 @CreateCache 注解创建一个缓存实例,默认超时时间是 100 秒

1
2
@CreateCache(expire = 100)
private Cache<Long, UserDO> userCache;

用起来就像 map 一样

1
2
3
UserDO user = userCache.get(123L);
userCache.put(123L, user);
userCache.remove(123L);

创建一个两级(内存+远程)的缓存,内存中的元素个数限制在 50 个。

1
2
@CreateCache(name = "UserService.userCache", expire = 100, cacheType = CacheType.BOTH, localLimit = 50)
private Cache<Long, UserDO> userCache;

name 属性不是必须的,但是起个名字是个好习惯,展示统计数据的使用,会使用这个名字。如果同一个 area 两个 @CreateCache 的 name 配置一样,它们生成的 Cache 将指向同一个实例。

创建方法缓存

使用 @Cached 方法可以为一个方法添加上缓存。JetCache 通过 Spring AOP 生成代理,来支持缓存功能。注解可以加在接口方法上也可以加在类方法上,但需要保证是个 Spring bean。

1
2
3
4
public interface UserService {
@Cached(name="UserService.getUserById", expire = 3600)
User getUserById(long userId);
}

五、j2cache

六、总结

使用缓存框架,使得开发缓存功能非常便捷。

如果你的系统只需要使用一种缓存,那么推荐使用 Spring Boot Cache。Spring Boot Cache 在 Spring Cache 基础上做了封装,使用更简单、方便。

如果你的系统需要使用多级缓存,那么推荐使用 jetcache。

参考资料

Ehcache 快速入门

EhCache 是一个纯 Java 的进程内缓存框架,具有快速、精干等特点,是 Hibernate 中默认的 CacheProvider。

img

一、简介

Ehcache 虽然也支持分布式模式,但是分布式方案不是很好好,建议只将其作为单机的进程内缓存使用。

Ehcache 特性

优点

  • 快速、简单
  • 支持多种缓存策略:LRU、LFU、FIFO 淘汰算法
  • 缓存数据有两级:内存和磁盘,因此无需担心容量问题
  • 缓存数据会在虚拟机重启的过程中写入磁盘
  • 可以通过 RMI、可插入 API 等方式进行分布式缓存
  • 具有缓存和缓存管理器的侦听接口
  • 支持多缓存管理器实例,以及一个实例的多个缓存区域
  • 提供 Hibernate 的缓存实现

缺点

  • 使用磁盘 Cache 的时候非常占用磁盘空间
  • 不保证数据的安全
  • 虽然支持分布式缓存,但效率不高(通过组播方式,在不同节点之间同步数据)。

Ehcache 集群

Ehcache 目前支持五种集群方式:

  • RMI
  • JMS
  • JGroup
  • Terracotta
  • Ehcache Server

RMI

使用组播方式通知所有节点同步数据。

如果网络有问题,或某台服务宕机,则存在数据无法同步的可能,导致数据不一致。

Ehcache Image

JMS

JMS 类似 MQ,所有节点订阅消息,当某节点缓存发生变化,就向 JMS 发消息,其他节点感知变化后,同步数据。

Ehcache Image

Cache Server

Ehcache Image

二、快速入门

引入 Ehcache

如果你的项目使用 maven 管理,添加以下依赖到你的 pom.xml 中。

1
2
3
4
5
6
<dependency>
<groupId>net.sf.ehcache</groupId>
<artifactId>ehcache</artifactId>
<version>2.10.2</version>
<type>pom</type>
</dependency>

如果你的项目不使用 maven 管理,请在 Ehcache 官网下载地址 下载 jar 包。

Spring 提供了对于 Ehcache 接口的封装,可以更简便的使用其功能。接入方式如下:

如果你的项目使用 maven 管理,添加以下依赖到你的pom.xml中。

spring-context-support这个 jar 包中含有 Spring 对于缓存功能的抽象封装接口。

1
2
3
4
5
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-context-support</artifactId>
<version>4.1.4.RELEASE</version>
</dependency>

添加配置文件

(1)在 classpath 下添加 ehcache.xml
添加一个名为 helloworld 的缓存。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
<?xml version="1.0" encoding="UTF-8"?>
<ehcache xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="http://ehcache.org/ehcache.xsd">

<!-- 磁盘缓存位置 -->
<diskStore path="java.io.tmpdir/ehcache"/>

<!-- 默认缓存 -->
<defaultCache
maxEntriesLocalHeap="10000"
eternal="false"
timeToIdleSeconds="120"
timeToLiveSeconds="120"
maxEntriesLocalDisk="10000000"
diskExpiryThreadIntervalSeconds="120"
memoryStoreEvictionPolicy="LRU"/>

<!-- helloworld缓存 -->
<cache name="helloworld"
maxElementsInMemory="1000"
eternal="false"
timeToIdleSeconds="5"
timeToLiveSeconds="5"
overflowToDisk="false"
memoryStoreEvictionPolicy="LRU"/>
</ehcache>

Ehcache 工作示例

Ehcache 会自动加载 classpath 根目录下名为 ehcache.xml 文件。

EhcacheDemo 的工作步骤如下:

  1. 在 EhcacheDemo 中,我们引用 ehcache.xml 声明的名为 helloworld 的缓存来创建Cache对象;
  2. 然后我们用一个键值对来实例化Element对象;
  3. Element对象添加到Cache
  4. 然后用Cache的 get 方法获取Element对象。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class EhcacheDemo {
public static void main(String[] args) throws Exception {
// Create a cache manager
final CacheManager cacheManager = new CacheManager();

// create the cache called "helloworld"
final Cache cache = cacheManager.getCache("helloworld");

// create a key to map the data to
final String key = "greeting";

// Create a data element
final Element putGreeting = new Element(key, "Hello, World!");

// Put the element into the data store
cache.put(putGreeting);

// Retrieve the data element
final Element getGreeting = cache.get(key);

// Print the value
System.out.println(getGreeting.getObjectValue());
}
}

输出

1
Hello, World!

三、Ehcache API

ElementCacheCacheManager是 Ehcache 最重要的 API。

  • Element - 缓存的元素,它维护着一个键值对。
  • Cache - 它是 Ehcache 的核心类,它有多个Element,并被CacheManager管理。它实现了对缓存的逻辑行为。
  • CacheManager - Cache的容器对象,并管理着Cache的生命周期。CacheManager 支持两种创建模式:单例(Singleton mode)和实例(InstanceMode)。

创建 CacheManager

下面的代码列举了创建 CacheManager 的五种方式。

使用静态方法create()会以默认配置来创建单例的CacheManager实例。

newInstance()方法是一个工厂方法,以默认配置创建一个新的CacheManager实例。

此外,newInstance()还有几个重载函数,分别可以通过传入StringURLInputStream参数来加载配置文件,然后创建CacheManager实例。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// 使用Ehcache默认配置获取单例的CacheManager实例
CacheManager.create();
String[] cacheNames = CacheManager.getInstance().getCacheNames();

// 使用Ehcache默认配置新建一个CacheManager实例
CacheManager.newInstance();
String[] cacheNames = manager.getCacheNames();

// 使用不同的配置文件分别创建一个CacheManager实例
CacheManager manager1 = CacheManager.newInstance("src/config/ehcache1.xml");
CacheManager manager2 = CacheManager.newInstance("src/config/ehcache2.xml");
String[] cacheNamesForManager1 = manager1.getCacheNames();
String[] cacheNamesForManager2 = manager2.getCacheNames();

// 基于classpath下的配置文件创建CacheManager实例
URL url = getClass().getResource("/anotherconfigurationname.xml");
CacheManager manager = CacheManager.newInstance(url);

// 基于文件流得到配置文件,并创建CacheManager实例
InputStream fis = new FileInputStream(new File
("src/config/ehcache.xml").getAbsolutePath());
try {
CacheManager manager = CacheManager.newInstance(fis);
} finally {
fis.close();
}

添加缓存

需要强调一点,Cache对象在用addCache方法添加到CacheManager之前,是无效的。

使用 CacheManager 的 addCache 方法可以根据缓存名将 ehcache.xml 中声明的 cache 添加到容器中;它也可以直接将 Cache 对象添加到缓存容器中。

Cache有多个构造函数,提供了不同方式去加载缓存的配置参数。

有时候,你可能需要使用 API 来动态的添加缓存,下面的例子就提供了这样的范例。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// 除了可以使用xml文件中配置的缓存,你也可以使用API动态增删缓存
// 添加缓存
manager.addCache(cacheName);

// 使用默认配置添加缓存
CacheManager singletonManager = CacheManager.create();
singletonManager.addCache("testCache");
Cache test = singletonManager.getCache("testCache");

// 使用自定义配置添加缓存,注意缓存未添加进CacheManager之前并不可用
CacheManager singletonManager = CacheManager.create();
Cache memoryOnlyCache = new Cache("testCache", 5000, false, false, 5, 2);
singletonManager.addCache(memoryOnlyCache);
Cache test = singletonManager.getCache("testCache");

// 使用特定的配置添加缓存
CacheManager manager = CacheManager.create();
Cache testCache = new Cache(
new CacheConfiguration("testCache", maxEntriesLocalHeap)
.memoryStoreEvictionPolicy(MemoryStoreEvictionPolicy.LFU)
.eternal(false)
.timeToLiveSeconds(60)
.timeToIdleSeconds(30)
.diskExpiryThreadIntervalSeconds(0)
.persistence(new PersistenceConfiguration().strategy(Strategy.LOCALTEMPSWAP)));
manager.addCache(testCache);

删除缓存

删除缓存比较简单,你只需要将指定的缓存名传入removeCache方法即可。

1
2
CacheManager singletonManager = CacheManager.create();
singletonManager.removeCache("sampleCache1");

基本缓存操作

Cache 最重要的两个方法就是 put 和 get,分别用来添加 Element 和获取 Element。

Cache 还提供了一系列的 get、set 方法来设置或获取缓存参数,这里不一一列举,更多 API 操作可参考官方 API 开发手册

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
/**
* 测试:使用默认配置或使用指定配置来创建CacheManager
*
* @author Zhang Peng
*/
public class CacheOperationTest {
private final Logger log = LoggerFactory.getLogger(CacheOperationTest.class);

/**
* 使用Ehcache默认配置(classpath下的ehcache.xml)获取单例的CacheManager实例
*/
@Test
public void operation() {
CacheManager manager = CacheManager.newInstance("src/test/resources/ehcache/ehcache.xml");

// 获得Cache的引用
Cache cache = manager.getCache("userCache");

// 将一个Element添加到Cache
cache.put(new Element("key1", "value1"));

// 获取Element,Element类支持序列化,所以下面两种方法都可以用
Element element1 = cache.get("key1");
// 获取非序列化的值
log.debug("key:{}, value:{}", element1.getObjectKey(), element1.getObjectValue());
// 获取序列化的值
log.debug("key:{}, value:{}", element1.getKey(), element1.getValue());

// 更新Cache中的Element
cache.put(new Element("key1", "value2"));
Element element2 = cache.get("key1");
log.debug("key:{}, value:{}", element2.getObjectKey(), element2.getObjectValue());

// 获取Cache的元素数
log.debug("cache size:{}", cache.getSize());

// 获取MemoryStore的元素数
log.debug("MemoryStoreSize:{}", cache.getMemoryStoreSize());

// 获取DiskStore的元素数
log.debug("DiskStoreSize:{}", cache.getDiskStoreSize());

// 移除Element
cache.remove("key1");
log.debug("cache size:{}", cache.getSize());

// 关闭当前CacheManager对象
manager.shutdown();

// 关闭CacheManager单例实例
CacheManager.getInstance().shutdown();
}
}

四、Ehcache 配置

Ehcache 支持通过 xml 文件和 API 两种方式进行配置。

详情参考:Ehcache 官方 XML 配置手册

xml 配置方式

Ehcache 的CacheManager构造函数或工厂方法被调用时,会默认加载 classpath 下名为ehcache.xml的配置文件。如果加载失败,会加载 Ehcache jar 包中的ehcache-failsafe.xml文件,这个文件中含有简单的默认配置。
ehcache.xml 配置参数说明:

  • name:缓存名称。
  • maxElementsInMemory:缓存最大个数。
  • eternal:缓存中对象是否为永久的,如果是,超时设置将被忽略,对象从不过期。
  • timeToIdleSeconds:置对象在失效前的允许闲置时间(单位:秒)。仅当 eternal=false 对象不是永久有效时使用,可选属性,默认值是 0,也就是可闲置时间无穷大。
  • timeToLiveSeconds:缓存数据的生存时间(TTL),也就是一个元素从构建到消亡的最大时间间隔值,这只能在元素不是永久驻留时有效,如果该值是 0 就意味着元素可以停顿无穷长的时间。
  • maxEntriesLocalDisk:当内存中对象数量达到 maxElementsInMemory 时,Ehcache 将会对象写到磁盘中。
  • overflowToDisk:内存不足时,是否启用磁盘缓存。
  • diskSpoolBufferSizeMB:这个参数设置 DiskStore(磁盘缓存)的缓存区大小。默认是 30MB。每个 Cache 都应该有自己的一个缓冲区。
  • maxElementsOnDisk:硬盘最大缓存个数。
  • diskPersistent:是否在 VM 重启时存储硬盘的缓存数据。默认值是 false。
  • diskExpiryThreadIntervalSeconds:磁盘失效线程运行时间间隔,默认是 120 秒。
  • memoryStoreEvictionPolicy:当达到 maxElementsInMemory 限制时,Ehcache 将会根据指定的策略去清理内存。默认策略是 LRU(最近最少使用)。你可以设置为 FIFO(先进先出)或是 LFU(较少使用)。
  • clearOnFlush:内存数量最大时是否清除。

API 配置方式

xml 配置的参数也可以直接通过编程方式来动态的进行配置(dynamicConfig 没有设为 false)。

1
2
3
4
5
6
Cache cache = manager.getCache("sampleCache");
CacheConfiguration config = cache.getCacheConfiguration();
config.setTimeToIdleSeconds(60);
config.setTimeToLiveSeconds(120);
config.setmaxEntriesLocalHeap(10000);
config.setmaxEntriesLocalDisk(1000000);

也可以通过disableDynamicFeatures()方式关闭动态配置开关。配置以后你将无法再以编程方式配置参数。

1
2
Cache cache = manager.getCache("sampleCache");
cache.disableDynamicFeatures();

五、Spring 集成 Ehcache

Spring3.1 开始添加了对缓存的支持。和事务功能的支持方式类似,缓存抽象允许底层使用不同的缓存解决方案来进行整合。

Spring4.1 开始支持 JSR-107 注解。

注:我本人使用的 Spring 版本为 4.1.4.RELEASE,目前 Spring 版本仅支持 Ehcache2.5 以上版本,但不支持 Ehcache3。

绑定 Ehcache

org.springframework.cache.ehcache.EhCacheManagerFactoryBean这个类的作用是加载 Ehcache 配置文件。
org.springframework.cache.ehcache.EhCacheCacheManager这个类的作用是支持 net.sf.ehcache.CacheManager。

spring-ehcache.xml的配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:cache="http://www.springframework.org/schema/cache"
xsi:schemaLocation="http://www.springframework.org/schema/beans
http://www.springframework.org/schema/beans/spring-beans-3.0.xsd
http://www.springframework.org/schema/cache
http://www.springframework.org/schema/cache/spring-cache-3.2.xsd">

<description>ehcache缓存配置管理文件</description>

<bean id="ehcache" class="org.springframework.cache.ehcache.EhCacheManagerFactoryBean">
<property name="configLocation" value="classpath:ehcache/ehcache.xml"/>
</bean>

<bean id="cacheManager" class="org.springframework.cache.ehcache.EhCacheCacheManager">
<property name="cacheManager" ref="ehcache"/>
</bean>

<!-- 启用缓存注解开关 -->
<cache:annotation-driven cache-manager="cacheManager"/>
</beans>

使用 Spring 的缓存注解

开启注解

Spring 为缓存功能提供了注解功能,但是你必须启动注解。
你有两个选择:
(1) 在 xml 中声明
像上一节 spring-ehcache.xml 中的做法一样,使用<cache:annotation-driven/>

1
<cache:annotation-driven cache-manager="cacheManager"/>

(2) 使用标记注解
你也可以通过对一个类进行注解修饰的方式在这个类中使用缓存注解。
范例如下:

1
2
3
4
@Configuration
@EnableCaching
public class AppConfig {
}

注解基本使用方法

Spring 对缓存的支持类似于对事务的支持。
首先使用注解标记方法,相当于定义了切点,然后使用 Aop 技术在这个方法的调用前、调用后获取方法的入参和返回值,进而实现了缓存的逻辑。
下面三个注解都是方法级别:

@Cacheable

表明所修饰的方法是可以缓存的:当第一次调用这个方法时,它的结果会被缓存下来,在缓存的有效时间内,以后访问这个方法都直接返回缓存结果,不再执行方法中的代码段。
这个注解可以用condition属性来设置条件,如果不满足条件,就不使用缓存能力,直接执行方法。
可以使用key属性来指定 key 的生成规则。

@CachePut

@Cacheable不同,@CachePut不仅会缓存方法的结果,还会执行方法的代码段。
它支持的属性和用法都与@Cacheable一致。

@CacheEvict

@Cacheable功能相反,@CacheEvict表明所修饰的方法是用来删除失效或无用的缓存数据。
下面是@Cacheable@CacheEvict@CachePut基本使用方法的一个集中展示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
@Service
public class UserService {
// @Cacheable可以设置多个缓存,形式如:@Cacheable({"books", "isbns"})
@Cacheable(value={"users"}, key="#user.id")
public User findUser(User user) {
return findUserInDB(user.getId());
}

@Cacheable(value = "users", condition = "#user.getId() <= 2")
public User findUserInLimit(User user) {
return findUserInDB(user.getId());
}

@CachePut(value = "users", key = "#user.getId()")
public void updateUser(User user) {
updateUserInDB(user);
}

@CacheEvict(value = "users")
public void removeUser(User user) {
removeUserInDB(user.getId());
}

@CacheEvict(value = "users", allEntries = true)
public void clear() {
removeAllInDB();
}
}

@Caching

如果需要使用同一个缓存注解(@Cacheable@CacheEvict@CachePut)多次修饰一个方法,就需要用到@Caching

1
2
@Caching(evict = { @CacheEvict("primary"), @CacheEvict(cacheNames="secondary", key="#p0") })
public Book importBooks(String deposit, Date date)

@CacheConfig

与前面的缓存注解不同,这是一个类级别的注解。
如果类的所有操作都是缓存操作,你可以使用@CacheConfig来指定类,省去一些配置。

1
2
3
4
5
@CacheConfig("books")
public class BookRepositoryImpl implements BookRepository {
@Cacheable
public Book findBook(ISBN isbn) {...}
}

参考资料

Java 进程内缓存

关键词:ConcurrentHashMap、LRUHashMap、Guava Cache、Caffeine、Ehcache

一、ConcurrentHashMap

最简单的进程内缓存可以通过 JDK 自带的 HashMapConcurrentHashMap 实现。

适用场景:不需要淘汰的缓存数据

缺点:无法进行缓存淘汰,内存会无限制的增长。

二、LRUHashMap

可以通过**继承 LinkedHashMap 来实现一个简单的 LRUHashMap**,即可完成一个简单的 LRU (最近最少使用)算法。

缺点:

  • 锁竞争严重,性能比较低。
  • 不支持过期时间
  • 不支持自动刷新

【示例】LRUHashMap 的简单实现

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

private final int max;
private Object lock;

public LRUCache(int max) {
//无需扩容
super((int) (max * 1.4f), 0.75f, true);
this.max = max;
this.lock = new Object();
}

/**
* 重写LinkedHashMap的removeEldestEntry方法即可 在Put的时候判断,如果为true,就会删除最老的
*
* @param eldest
* @return
*/
@Override
protected boolean removeEldestEntry(Map.Entry eldest) {
return size() > max;
}

public Object getValue(Object key) {
synchronized (lock) {
return get(key);
}
}

public void putValue(Object key, Object value) {
synchronized (lock) {
put(key, value);
}
}

public boolean removeValue(Object key) {
synchronized (lock) {
return remove(key) != null;
}
}

public boolean removeAll() {
clear();
return true;
}

}

三、Guava Cache

Guava Cache 解决了 LRUHashMap 中的几个缺点。

Guava Cache 提供了基于容量,时间和引用的缓存回收方式。基于容量的方式内部实现采用 LRU 算法,基于引用回收很好的利用了 Java 虚拟机的垃圾回收机制。

其中的缓存构造器 CacheBuilder 采用构建者模式提供了设置好各种参数的缓存对象。缓存核心类 LocalCache 里面的内部类 Segment 与 jdk1.7 及以前的 ConcurrentHashMap 非常相似,分段加锁,减少锁竞争,并且都继承于 ReetrantLock,还有六个队列,以实现丰富的本地缓存方案。Guava Cache 对于过期的 Entry 并没有马上过期(也就是并没有后台线程一直在扫),而是通过进行读写操作的时候进行过期处理,这样做的好处是避免后台线程扫描的时候进行全局加锁。

直接通过查询,判断其是否满足刷新条件,进行刷新。

Guava Cache 缓存回收

Guava Cache 提供了三种基本的缓存回收方式。

基于容量回收

maximumSize(long):当缓存中的元素数量超过指定值时触发回收。

基于定时回收

  • expireAfterAccess(long, TimeUnit):缓存项在给定时间内没有被读/写访问,则回收。请注意这种缓存的回收顺序和基于大小回收一样。
  • expireAfterWrite(long, TimeUnit):缓存项在给定时间内没有被写访问(创建或覆盖),则回收。如果认为缓存数据总是在固定时候后变得陈旧不可用,这种回收方式是可取的。

如下文所讨论,定时回收周期性地在写操作中执行,偶尔在读操作中执行。

基于引用回收

  • CacheBuilder.weakKeys():使用弱引用存储键。当键没有其它(强或软)引用时,缓存项可以被垃圾回收。
  • CacheBuilder.weakValues():使用弱引用存储值。当值没有其它(强或软)引用时,缓存项可以被垃圾回收。
  • CacheBuilder.softValues():使用软引用存储值。软引用只有在响应内存需要时,才按照全局最近最少使用的顺序回收。

Guava Cache 核心 API

CacheBuilder

缓存构建器。构建缓存的入口,指定缓存配置参数并初始化本地缓存。
主要采用 builder 的模式,CacheBuilder 的每一个方法都返回这个 CacheBuilder 知道 build 方法的调用。
注意 build 方法有重载,带有参数的为构建一个具有数据加载功能的缓存,不带参数的构建一个没有数据加载功能的缓存。

LocalManualCache

作为 LocalCache 的一个内部类,在构造方法里面会把 LocalCache 类型的变量传入,并且调用方法时都直接或者间接调用 LocalCache 里面的方法。

LocalLoadingCache

可以看到该类继承了 LocalManualCache 并实现接口 LoadingCache。
覆盖了 get,getUnchecked 等方法。

LocalCache

Guava Cache 中的核心类,重点了解。

LocalCache 的数据结构与 ConcurrentHashMap 很相似,都由多个 segment 组成,且各 segment 相对独立,互不影响,所以能支持并行操作。每个 segment 由一个 table 和若干队列组成。缓存数据存储在 table 中,其类型为 AtomicReferenceArray。

四、Caffeine

caffeine 是一个使用 JDK8 改进 Guava 缓存的高性能缓存库。

Caffeine 实现了 W-TinyLFU(LFU + LRU 算法的变种),其命中率和读写吞吐量大大优于 Guava Cache

其实现原理较复杂,可以参考你应该知道的缓存进化史

五、Ehcache

参考:Ehcache

六、进程内缓存对比

常用进程内缓存技术对比:

比较项 ConcurrentHashMap LRUMap Ehcache Guava Cache Caffeine
读写性能 很好,分段锁 一般,全局加锁 好,需要做淘汰操作 很好
淘汰算法 LRU,一般 支持多种淘汰算法,LRU,LFU,FIFO LRU,一般 W-TinyLFU, 很好
功能丰富程度 功能比较简单 功能比较单一 功能很丰富 功能很丰富,支持刷新和虚引用等 功能和 Guava Cache 类似
工具大小 jdk 自带类,很小 基于 LinkedHashMap,较小 很大,最新版本 1.4MB 是 Guava 工具类中的一个小部分,较小 一般,最新版本 644KB
是否持久化
是否支持集群
  • ConcurrentHashMap - 比较适合缓存比较固定不变的元素,且缓存的数量较小的。虽然从上面表格中比起来有点逊色,但是其由于是 JDK 自带的类,在各种框架中依然有大量的使用,比如我们可以用来缓存我们反射的 Method,Field 等等;也可以缓存一些链接,防止其重复建立。在 Caffeine 中也是使用的 ConcurrentHashMap 来存储元素。
  • LRUMap - 如果不想引入第三方包,又想使用淘汰算法淘汰数据,可以使用这个。
  • Ehcache - 由于其 jar 包很大,较重量级。对于需要持久化和集群的一些功能的,可以选择 Ehcache。需要注意的是,虽然 Ehcache 也支持分布式缓存,但是由于其节点间通信方式为 rmi,表现不如 Redis,所以一般不建议用它来作为分布式缓存。
  • Guava Cache - Guava 这个 jar 包在很多 Java 应用程序中都有大量的引入,所以很多时候其实是直接用就好了,并且其本身是轻量级的而且功能较为丰富,在不了解 Caffeine 的情况下可以选择 Guava Cache。
  • Caffeine - 其在命中率,读写性能上都比 Guava Cache 好很多,并且其 API 和 Guava cache 基本一致,甚至会多一点。在真实环境中使用 Caffeine,取得过不错的效果。

总结一下:**如果不需要淘汰算法则选择 ConcurrentHashMap,如果需要淘汰算法和一些丰富的 API,推荐选择 Caffeine**。

参考资料

Http 缓存

HTTP 缓存分为 2 种,一种是强缓存,另一种是协商缓存。主要作用是可以加快资源获取速度,提升用户体验,减少网络传输,缓解服务端的压力。

Http 强缓存

不需要发送请求到服务端,直接读取浏览器本地缓存,在 Chrome 的 Network 中显示的 HTTP 状态码是 200 ,在 Chrome 中,强缓存又分为 Disk Cache (存放在硬盘中)和 Memory Cache (存放在内存中),存放的位置是由浏览器控制的。是否强缓存由 ExpiresCache-ControlPragma 3 个 Header 属性共同来控制。

Expires

Expires 的值是一个 HTTP 日期,在浏览器发起请求时,会根据系统时间和 Expires 的值进行比较,如果系统时间超过了 Expires 的值,缓存失效。由于和系统时间进行比较,所以当系统时间和服务器时间不一致的时候,会有缓存有效期不准的问题。Expires 的优先级在三个 Header 属性中是最低的。

Cache-Control

Cache-Control 是 HTTP/1.1 中新增的属性,在请求头和响应头中都可以使用,常用的属性值如有:

  • max-age:单位是秒,缓存时间计算的方式是距离发起的时间的秒数,超过间隔的秒数缓存失效
  • no-cache:不使用强缓存,需要与服务器验证缓存是否新鲜
  • no-store:禁止使用缓存(包括协商缓存),每次都向服务器请求最新的资源
  • private:专用于个人的缓存,中间代理、CDN 等不能缓存此响应
  • public:响应可以被中间代理、CDN 等缓存
  • must-revalidate:在缓存过期前可以使用,过期后必须向服务器验证

Pragma

Pragma 只有一个属性值,就是 no-cache ,效果和 Cache-Control 中的 no-cache 一致,不使用强缓存,需要与服务器验证缓存是否新鲜,在 3 个头部属性中的优先级最高。

协商缓存

当浏览器的强缓存失效的时候或者请求头中设置了不走强缓存,并且在请求头中设置了 If-Modified-Since 或者 If-None-Match 的时候,会将这两个属性值到服务端去验证是否命中协商缓存,如果命中了协商缓存,会返回 304 状态,加载浏览器缓存,并且响应头会设置 Last-Modified 或者 ETag 属性。

ETag/If-None-Match

Etag: 服务器响应请求时,通过此字段告诉浏览器当前资源在服务器生成的唯一标识(生成规则由服务器决定)

If-None-Match: 再次请求服务器时,浏览器的请求报文头部会包含此字段,后面的值为在缓存中获取的标识。服务器接收到次报文后发现 If-None-Match 则与被请求资源的唯一标识进行对比。

  1. 不同,说明资源被改动过,则响应整个资源内容,返回状态码 200。
  2. 相同,说明资源无心修改,则响应 header,浏览器直接从缓存中获取数据信息。返回状态码 304.

但是实际应用中由于 Etag 的计算是使用算法来得出的,而算法会占用服务端计算的资源,所有服务端的资源都是宝贵的,所以就很少使用 Etag 了。

Last-Modified/If-Modified-Since

Last-Modified: 服务器在响应请求时,会告诉浏览器资源的最后修改时间。

if-Modified-Since: 浏览器再次请求服务器的时候,请求头会包含此字段,后面跟着在缓存中获得的最后修改时间。服务端收到此请求头发现有 if-Modified-Since,则与被请求资源的最后修改时间进行对比,如果一致则返回 304 和响应报文头,浏览器只需要从缓存中获取信息即可。 从字面上看,就是说:从某个时间节点算起,是否文件被修改了

  1. 如果真的被修改:那么开始传输响应一个整体,服务器返回:200 OK
  2. 如果没有被修改:那么只需传输响应 header,服务器返回:304 Not Modified

if-Unmodified-Since: 从字面上看, 就是说: 从某个时间点算起, 是否文件没有被修改

  1. 如果没有被修改:则开始`继续’传送文件: 服务器返回: 200 OK
  2. 如果文件被修改:则不传输,服务器返回: 412 Precondition failed (预处理错误)

这两个的区别是一个是修改了才下载一个是没修改才下载。 Last-Modified 说好却也不是特别好,因为如果在服务器上,一个资源被修改了,但其实际内容根本没发生改变,会因为 Last-Modified 时间匹配不上而返回了整个实体给客户端(即使客户端缓存里有个一模一样的资源)。为了解决这个问题,HTTP1.1 推出了 Etag。

参考资料

Hystrix 快速入门

一、Hystrix 简介

Hystrix 是什么

Hystrix 是 Netflix 开源的一款容错框架,包含常用的容错方法:线程池隔离、信号量隔离、熔断、降级。

Hystrix 官方宣布不再发布新版本

但是 Hystrix 的客户端熔断保护,断路器设计理念,有非常高的学习价值。

为什么需要 Hystrix

复杂的分布式系统架构中的应用程序往往具有数十个依赖项,每个依赖项都会不可避免地在某个时刻失败。 如果主机应用程序未与这些外部故障隔离开来,则可能会被波及。

例如,对于依赖于 30 个服务的应用程序,假设每个服务的正常运行时间为 99.99%,则可以期望:

99.9930 = 99.7% 的正常运行时间

10 亿个请求中的 0.3%= 3,000,000 个失败

即使所有依赖项都具有出色的正常运行时间,每月也会有 2 个小时以上的停机时间。

然而,现实情况一般比这种估量情况更糟糕。


当一切正常时,整体系统如下所示:

img

在高并发场景,这些依赖的稳定性与否对系统的影响非常大,但是依赖有很多不可控问题:如网络连接、资源繁忙、服务宕机等。例如:下图中有一个 QPS 为 50 的依赖 I 出现不可用,但是其他依赖服务是可用的。

img

但是,在高并发场景下,当依赖 I 阻塞时,大多数服务器的线程池就出现阻塞(BLOCK)。当这种级联故障愈演愈烈,就可能造成整个线上服务不可用的雪崩效应,如下图:

img

Hystrix 就是为了解决这类问题而应运而生。

Hystrix 的功能

Hystrix 具有以下功能:

  • 避免资源耗尽:阻止任何一个依赖服务耗尽所有的资源,比如 tomcat 中的所有线程资源。
  • 避免请求排队和积压:采用限流和 fail fast 来控制故障。
  • 支持降级:提供 fallback 降级机制来应对故障。
  • 资源隔离:比如 bulkhead(舱壁隔离技术)、swimlane(泳道技术)、circuit breaker(断路技术)来限制任何一个依赖服务的故障的影响。
  • 统计/监控/报警:通过近实时的统计/监控/报警功能,来提高故障发现的速度。
  • 通过近实时的属性和配置热修改功能,来提高故障处理和恢复的速度。
  • 保护依赖服务调用的所有故障情况,而不仅仅只是网络故障情况。

如果使用 Hystrix 对每个基础依赖服务进行过载保护,则整个系统架构将会类似下图所示,每个依赖项彼此隔离,受到延迟时发生饱和的资源的被限制访问,并包含 fallback 逻辑(用于降级处理),该逻辑决定了在依赖项中发生任何类型的故障时做出对应的处理。

img

Hystrix 核心概念

二、Hystrix 工作流程

如下图所示,Hystrix 的工作流程大致可以分为 9 个步骤。

img

(一)包装命令

x 支持资源隔离。

资源隔离,就是说,你如果要把对某一个依赖服务的所有调用请求,全部隔离在同一份资源池内,不会去用其它资源了,这就叫资源隔离。哪怕对这个依赖服务,比如说商品服务,现在同时发起的调用量已经到了 1000,但是分配给商品服务线程池内就 10 个线程,最多就只会用这 10 个线程去执行。不会因为对商品服务调用的延迟,将 Tomcat 内部所有的线程资源全部耗尽。

Hystrix 进行资源隔离,其实是提供了一个抽象,叫做命令模式。这也是 Hystrix 最最基本的资源隔离技术。

在使用 Hystrix 的过程中,会对依赖服务的调用请求封装成命令对象,Hystrix 对 命令对象抽象了两个抽象类:HystrixCommandHystrixObservableCommand

  • HystrixCommand 表示的命令对象会返回一个唯一返回值。
  • HystrixObservableCommand 表示的命令对象 会返回多个返回值。
1
2
HystrixCommand command = new HystrixCommand(arg1, arg2);
HystrixObservableCommand command = new HystrixObservableCommand(arg1, arg2);

(二)执行命令

Hystrix 中共有 4 种方式执行命令,如下所示:

执行方式 说明 可用对象
execute() 阻塞式同步执行,返回依赖服务的单一返回结果(或者抛出异常) HystrixCommand
queue() 基于 Future 的异步方式执行,返回依赖服务的单一返回结果(或者抛出异常) HystrixCommand
observe() 基于 Rxjava 的 Observable 方式,返回通过 Observable 表示的依赖服务返回结果,代调用代码先执行(Hot Obserable) HystrixObservableCommand
toObvsevable 基于 Rxjava 的 Observable 方式,返回通过 Observable 表示的依赖服务返回结果,执行代码等到真正订阅的时候才会执行(cold observable) HystrixObservableCommand

这四种命令中,exeucte()queue()observe()的表示也是通过toObservable()实现的,其转换关系如下图所示:

img

HystrixCommand 执行方式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
K value   = command.execute();
// 等价语句:
K value = command.execute().queue().get();


Future<K> fValue = command.queue();
//等价语句:
Future<K> fValue = command.toObservable().toBlocking().toFuture();


Observable<K> ohValue = command.observe(); //hot observable,立刻订阅,命令立刻执行
//等价语句:
Observable<K> ohValue = command.toObservable().subscribe(subject);

// 上述执行最终实现还是基于 toObservable()
Observable<K> ocValue = command.toObservable(); //cold observable,延后订阅,订阅发生后,执行才真正执行

(三)是否缓存

如果当前命令对象配置了允许从结果缓存中取返回结果,并且在结果缓存中已经缓存了请求结果,则缓存的请求结果会立刻通过 Observable 的格式返回。

(四)是否开启断路器

如果第三步没有缓存没有命中,则判断一下当前断路器的断路状态是否打开。如果断路器状态为打开状态,则 Hystrix 将不会执行此 Command 命令,直接执行步骤 8 调用 Fallback;

如果断路器状态是关闭,则执行 步骤 5 检查是否有足够的资源运行 Command 命令

(五)信号量、线程池是否拒绝

如果当前要执行的 Command 命令 先关连的线程池 和队列(或者信号量)资源已经满了,Hystrix 将不会运行 Command 命令,直接执行 步骤 8的 Fallback 降级处理;如果未满,表示有剩余的资源执行 Command 命令,则执行步骤 6

(六)construct() 或 run()

当经过步骤 5 判断,有足够的资源执行 Command 命令时,本步骤将调用 Command 命令运行方法,基于不同类型的 Command,有如下两种两种运行方式:

运行方式 说明
HystrixCommand.run() 返回一个处理结果或者抛出一个异常
HystrixObservableCommand.construct() 返回一个 Observable 表示的结果(可能多个),或者 基于onError的错误通知

如果run() 或者construct()方法 的真实执行时间超过了 Command 设置的超时时间阈值, 则当前则执行线程(或者是独立的定时器线程)将会抛出TimeoutException。抛出超时异常 TimeoutException,后,将执行步骤 8的 Fallback 降级处理。即使run()或者construct()执行没有被取消或中断,最终能够处理返回结果,但在降级处理逻辑中,将会抛弃run()construct()方法的返回结果,而返回 Fallback 降级处理结果。

注意事项
需要注意的是,Hystrix 无法强制 将正在运行的线程停止掉–Hystrix 能够做的最好的方式就是在 JVM 中抛出一个InterruptedException。如果 Hystrix 包装的工作不抛出中断异常InterruptedException, 则在 Hystrix 线程池中的线程将会继续执行,尽管调用的客户端已经接收到了TimeoutException。这种方式会使 Hystrix 的线程池处于饱和状态。大部分的 Java Http Client 开源库并不会解析 InterruptedException。所以确认 HTTP client 相关的连接和读/写相关的超时时间设置。
如果 Command 命令没有抛出任何异常,并且有返回结果,则 Hystrix 将会在做完日志记录和统计之后会将结果返回。 如果是通过run()方式运行,则返回一个Obserable对象,包含一个唯一值,并且发送一个onCompleted通知;如果是通过consturct()方式运行 ,则返回一个Observable对象

(七)健康检查

Hystrix 会统计 Command 命令执行执行过程中的成功数失败数拒绝数超时数,将这些信息记录到断路器(Circuit Breaker)中。断路器将上述统计按照时间窗的形式记录到一个定长数组中。断路器根据时间窗内的统计数据去判定请求什么时候可以被熔断,熔断后,在接下来一段恢复周期内,相同的请求过来后会直接被熔断。当再次校验,如果健康监测通过后,熔断开关将会被关闭。

(八)获取 Fallback

当以下场景出现后,Hystrix 将会尝试触发 Fallback:

  • 步骤 6 Command 执行时抛出了任何异常;
  • 步骤 4 断路器已经被打开
  • 步骤 5 执行命令的线程池、队列或者信号量资源已满
  • 命令执行的时间超过阈值

(九)返回结果

如果 Hystrix 命令对象执行成功,将会返回结果,或者以Observable形式包装的结果。根据步骤 2的 command 调用方式,返回的Observable 会按照如下图说是的转换关系进行返回:

img

  • execute() — 用和 .queue() 相同的方式获取 Future,然后调用 Futureget() 以获取 Observable 的单个值。
  • queue() —将 Observable 转换为 BlockingObservable,以便可以将其转换为 Future 并返回。
  • watch() —订阅 Observable 并开始执行命令的流程; 返回一个 Observable,当订阅该 Observable 时,它会重新通知。
  • toObservable() —返回不变的 Observable; 必须订阅它才能真正开始执行命令的流程。

三、断路器工作原理

img

  1. 断路器时间窗内的请求数 是否超过了请求数断路器生效阈值circuitBreaker.requestVolumeThreshold,如果超过了阈值,则将会触发断路,断路状态为开启
    例如,如果当前阈值设置的是20,则当时间窗内统计的请求数共计 19 个,即使 19 个全部失败了,都不会触发断路器。
  2. 并且请求错误率超过了请求错误率阈值errorThresholdPercentage
  3. 如果两个都满足,则将断路器由关闭迁移到开启
  4. 如果断路器开启,则后续的所有相同请求将会被断路掉;
  5. 直到过了沉睡时间窗sleepWindowInMilliseconds后,再发起请求时,允许其通过(此时的状态为半开起状态)。如果请求失败了,则保持断路器状态为开启状态,并更新沉睡时间窗。如果请求成功了,则将断路器状态改为关闭状态;

核心的逻辑如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Override
public void onNext(HealthCounts hc) {
// check if we are past the statisticalWindowVolumeThreshold
if (hc.getTotalRequests() < properties.circuitBreakerRequestVolumeThreshold().get()) {
// we are not past the minimum volume threshold for the stat window,
// so no change to circuit status.
// if it was CLOSED, it stays CLOSED
// if it was half-open, we need to wait for a successful command execution
// if it was open, we need to wait for sleep window to elapse
} else {
if (hc.getErrorPercentage() < properties.circuitBreakerErrorThresholdPercentage().get()) {
//we are not past the minimum error threshold for the stat window,
// so no change to circuit status.
// if it was CLOSED, it stays CLOSED
// if it was half-open, we need to wait for a successful command execution
// if it was open, we need to wait for sleep window to elapse
} else {
// our failure rate is too high, we need to set the state to OPEN
if (status.compareAndSet(Status.CLOSED, Status.OPEN)) {
circuitOpened.set(System.currentTimeMillis());
}
}
}
}

系统指标

Hystrix 对系统指标的统计是基于时间窗模式的:

时间窗:最近的一个时间区间内,比如前一小时到现在,那么时间窗的长度就是1小时
:桶是在特定的时间窗内,等分的指标收集的统计集合;比如时间窗的长度为1小时,而桶的数量为10,那么每个桶在时间轴上依次排开,时间由远及近,每个桶统计的时间分片为 1h / 10 = 6 min 6 分钟。一个桶中,包含了成功数失败数超时数拒绝数 四个指标。

在系统内,时间窗会随着系统的运行逐渐向前移动,而时间窗的长度和桶的数量是固定不变的,那么随着时间的移动,会出现较久的过期的桶被移除出去,新的桶被添加进来,如下图所示:

img

四、资源隔离技术

线程池隔离

如下图所示,由于计算机系统的基本执行单位就是线程,线程具备独立的执行能力,所以,为了做到资源保护,需要对系统的线程池进行划分,对于外部调用方

1
User Request

的请求,调用各个线程池的服务,各个线程池独立完成调用,然后将结果返回

1
调用方

。在调用服务的过程中,如果

1
服务提供方

执行时间过长,则

1
调用方

可以直接以超时的方式直接返回,快速失败。

img

线程池隔离的几点好处

  1. 使用超时返回的机制,避免同步调用服务时,调用时间过长,无法释放,导致资源耗尽的情况
  2. 服务方可以控制请求数量,请求过多,可以直接拒绝,达到快速失败的目的;
  3. 请求排队,线程池可以维护执行队列,将请求压到队列中处理

举个例子,如下代码段,模拟了同步调用服务的过程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
//服务提供方,执行服务的时候模拟2分钟的耗时
Callable<String> callableService = ()->{
long start = System.currentTimeMillis();
while(System.currentTimeMillis()-start> 1000 * 60 *2){
//模拟服务执行时间过长的情况
}
return "OK";
};

//模拟10个客户端调用服务
ExecutorService clients = Executors.newFixedThreadPool(10);
//模拟给10个客户端提交处理请求
for (int i = 0; i < 20; i++) {
clients.execute(()->{
//同步调用
try {
String result = callableService.call();
System.out.println("当前客户端:"+Thread.currentThread().getName()+"调用服务完成,得到结果:"+result);
} catch (Exception e) {
e.printStackTrace();
}
});
}

在此环节中,客户端 clients必须等待服务方返回结果之后,才能接收新的请求。如果用吞吐量来衡量系统的话,会发现系统的处理能力比较低。为了提高相应时间,可以借助线程池的方式,设置超时时间,这样的话,客户端就不需要必须等待服务方返回,如果时间过长,可以提前返回,改造后的代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
//服务提供方,执行服务的时候模拟2分钟的耗时
Callable<String> callableService = ()->{
long start = System.currentTimeMillis();
while(System.currentTimeMillis()-start> 1000 * 60 *2){
//模拟服务执行时间过长的情况
}
return "OK";
};

//创建线程池作为服务方
ExecutorService executorService = Executors.newFixedThreadPool(30);


//模拟10个客户端调用服务
ExecutorService clients = Executors.newFixedThreadPool(10);
for (int i = 0; i < 10; i++) {
clients.execute(()->{
//同步调用
//将请求提交给线程池执行,Callable 和 Runnable在某种意义上,也是Command对象
Future<String> future = executorService.submit(callableService::call);
//在指定的时间内获取结果,如果超时,调用方可以直接返回
try {
String result = future.get(1000, TimeUnit.SECONDS);
//客户端等待时间之后,快速返回
System.out.println("当前客户端:"+Thread.currentThread().getName()+"调用服务完成,得到结果:"+result);
}catch (TimeoutException timeoutException){
System.out.println("服务调用超时,返回处理");
} catch (InterruptedException e) {

} catch (ExecutionException e) {
}
});
}

如果我们将服务方的线程池设置为:

1
2
3
4
ThreadPoolExecutor executorService = new ThreadPoolExecutor(10,1000,TimeUnit.SECONDS,
new ArrayBlockingQueue<>(100),
new ThreadPoolExecutor.DiscardPolicy() // 提交请求过多时,可以丢弃请求,避免死等阻塞的情况。
)

线程池隔离模式的弊端

线程池隔离模式,会根据服务划分出独立的线程池,系统资源的线程并发数是有限的,当线程数过多,系统话费大量的 CPU 时间来做线程上下文切换的无用操作,反而降低系统性能;如果线程池隔离的过多,会导致真正用于接收用户请求的线程就相应地减少,系统吞吐量反而下降;
在实践上,应当对像远程方法调用,网络资源请求这种服务时间不太可控的场景下使用线程池隔离模式处理
如下图所示,是线程池隔离模式的三种场景:

img

信号量隔离

由于基于线程池隔离的模式占用系统线程池资源,Hystrix 还提供了另外一个隔离技术:基于信号量的隔离。

基于信号量的隔离方式非常地简单,其核心就是使用共用变量

1
semaphore

进行原子操作,控制线程的并发量,当并发量达到一定量级时,服务禁止调用。如下图所示:信号量本身不会消耗多余的线程资源,所以就非常轻量。

img

基于信号量隔离的利弊

利:基于信号量的隔离,利用 JVM 的原子性 CAS 操作,避免了资源锁的竞争,省去了线程池开销,效率非常高;
弊:本质上基于信号量的隔离是同步行为,所以无法做到超时熔断,所以服务方自身要控制住执行时间,避免超时。
应用场景:业务服务上,有并发上限限制时,可以考虑此方式 > Alibaba Sentinel开源框架,就是基于信号量的熔断和断路器框架。

五、Hystrix 应用

Spring Cloud + Hystrix

  • Hystrix 配置无法动态调节生效。Hystrix 框架本身是使用的Archaius框架完成的配置加载和刷新,但是集成自 Spring Cloud 下,无法有效地根据实时监控结果,动态调整熔断和系统参数
  • 线程池和 Command 之间的配置比较复杂,在 Spring Cloud 在做 feigin-hystrix 集成的时候,还有些 BUG,对 command 的默认配置没有处理好,导致所有 command 占用公共的 command 线程池,没有细粒度控制,还需要做框架适配调整
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public interface SetterFactory {

/**
* Returns a hystrix setter appropriate for the given target and method
*/
HystrixCommand.Setter create(Target<?> target, Method method);

/**
* Default behavior is to derive the group key from {@link Target#name()} and the command key from
* {@link Feign#configKey(Class, Method)}.
*/
final class Default implements SetterFactory {

@Override
public HystrixCommand.Setter create(Target<?> target, Method method) {
String groupKey = target.name();
String commandKey = Feign.configKey(target.type(), method);
return HystrixCommand.Setter
.withGroupKey(HystrixCommandGroupKey.Factory.asKey(groupKey))
.andCommandKey(HystrixCommandKey.Factory.asKey(commandKey));
//没有处理好default配置项的加载
}
}
}

Hystrix 配置

详细配置可以参考 Hystrix 官方配置手册,这里仅介绍比较核心的配置

执行配置

以下配置用于控制 HystrixCommand.run() 如何执行。

配置项 说明 默认值
execution.isolation.strategy 线程隔离(THREAD)或信号量隔离(SEMAPHORE) THREAD
execution.isolation.thread.timeoutInMilliseconds 方法执行超时时间 1000(ms)
execution.isolation.semaphore.maxConcurrentRequests 信号量隔离最大并发数 10

断路配置

以下配置用于控制 HystrixCircuitBreaker 的断路处理。

配置项 说明 默认值
circuitBreaker.enabled 是否开启断路器 true
circuitBreaker.requestVolumeThreshold 断路器启用请求数阈值 20
circuitBreaker.sleepWindowInMilliseconds 断路器启用后的休眠时间 5000(ms)
circuitBreaker.errorThresholdPercentage 断路器启用失败率阈值 50(%)
circuitBreaker.forceOpen 是否强制将断路器设置成开启状态 false
circuitBreaker.forceClosed 是否强制将断路器设置成关闭状态 false

指标配置

以下配置用于从 HystrixCommand 和 HystrixObservableCommand 执行中捕获相关指标。

配置项 说明 默认值
metrics.rollingStats.timeInMilliseconds 时间窗的长度 10000(ms)
metrics.rollingStats.numBuckets 桶的数量,需要保证timeInMilliseconds % numBuckets =0 10
metrics.rollingPercentile.enabled 是否统计运行延迟的占比 true
metrics.rollingPercentile.timeInMilliseconds 运行延迟占比统计的时间窗 60000(ms)
metrics.rollingPercentile.numBuckets 运行延迟占比统计的桶数 6
metrics.rollingPercentile.bucketSize 百分比统计桶的容量,桶内最多保存的运行时间统计 100
metrics.healthSnapshot.intervalInMilliseconds 统计快照刷新间隔 500 (ms)

线程池配置

以下配置用于控制 Hystrix Command 执行所使用的线程池。

配置项 说明 默认值
coreSize 线程池核心线程数 10
maximumSize 线程池最大线程数 10
maxQueueSize 最大 LinkedBlockingQueue 的大小,-1 表示用 SynchronousQueue -1
queueSizeRejectionThreshold 队列大小阈值,超过则拒绝 5
allowMaximumSizeToDivergeFromCoreSize 此属性允许 maximumSize 的配置生效。该值可以等于或大于 coreSize。设置 coreSize <maximumSize 使得线程池可以维持 maximumSize 并发性,但是会在相对空闲时将线程回收。(取决于 keepAliveTimeInMinutes) false

六、其他限流技术

  • resilience4j
    Hystrix 虽然官方宣布不再维护,其推荐另外一个框架:resilience4j, 这个框架是是为 Java 8 和 函数式编程设计的一个轻量级的容错框架,该框架充分利用函数式编程的概念,为函数式接口lamda表达式方法引用高阶函数进行包装,(本质上是装饰者模式的概念),通过包装实现断路限流重试舱壁功能。
    这个框架整体而言比较轻量,没有控制台,不太好做系统级监控;

  • Alibaba Sentinel

    1
    Sentinel

    是 阿里巴巴开源的轻量级的流量控制、熔断降级 Java 库,该库的核心是使用的是信号量隔离的方式做流量控制和熔断,其优点是其集成性和易用性,几乎能和当前主流的 Spring Cloud, dubbo ,grpc ,nacos, zookeeper 做集成,如下图所示:

    img

    sentinel-features-overview-en.png

    1
    Sentinel

    的目标生态圈:

    img

    1
    sentinel

    一个强大的功能,就是它有一个流控管理控制台,你可以实时地监控每个服务的流控情况,并且可以实时编辑各种流控、熔断规则,有效地保证了服务保护的及时性。下图是内部试用的 sentinel 控制台:

    img另外,

    1
    sentinel

    还可以和

    1
    ctrip apollo

    分布式配置系统进行集成,将流控规降级等各种规则先配置在 apollo 中,然后服务启动自动加载流控规则。

参考资料

Tomcat 快速入门

🎁 版本说明

当前最新版本:Tomcat 8.5.24

环境要求:JDK7+

1. Tomcat 简介

1.1. Tomcat 是什么

Tomcat 是由 Apache 开发的一个 Servlet 容器,实现了对 Servlet 和 JSP 的支持,并提供了作为 Web 服务器的一些特有功能,如 Tomcat 管理和控制平台、安全域管理和 Tomcat 阀等。

由于 Tomcat 本身也内含了一个 HTTP 服务器,它也可以被视作一个单独的 Web 服务器。但是,不能将 Tomcat 和 Apache HTTP 服务器混淆,Apache HTTP 服务器是一个用 C 语言实现的 HTTP Web 服务器;这两个 HTTP web server 不是捆绑在一起的。Tomcat 包含了一个配置管理工具,也可以通过编辑 XML 格式的配置文件来进行配置。

1.2. Tomcat 重要目录

  • /bin - Tomcat 脚本存放目录(如启动、关闭脚本)。 *.sh 文件用于 Unix 系统; *.bat 文件用于 Windows 系统。
  • /conf - Tomcat 配置文件目录。
  • /logs - Tomcat 默认日志目录。
  • /webapps - webapp 运行的目录。

1.3. web 工程发布目录结构

一般 web 项目路径结构

1
2
3
4
5
6
7
8
9
10
11
12
|-- webapp                         # 站点根目录
|-- META-INF # META-INF 目录
| `-- MANIFEST.MF # 配置清单文件
|-- WEB-INF # WEB-INF 目录
| |-- classes # class文件目录
| | |-- *.class # 程序需要的 class 文件
| | `-- *.xml # 程序需要的 xml 文件
| |-- lib # 库文件夹
| | `-- *.jar # 程序需要的 jar 包
| `-- web.xml # Web应用程序的部署描述文件
|-- <userdir> # 自定义的目录
|-- <userfiles> # 自定义的资源文件
  • webapp:工程发布文件夹。其实每个 war 包都可以视为 webapp 的压缩包。

  • META-INF:META-INF 目录用于存放工程自身相关的一些信息,元文件信息,通常由开发工具,环境自动生成。

  • WEB-INF:Java web 应用的安全目录。所谓安全就是客户端无法访问,只有服务端可以访问的目录。

  • /WEB-INF/classes:存放程序所需要的所有 Java class 文件。

  • /WEB-INF/lib:存放程序所需要的所有 jar 文件。

  • /WEB-INF/web.xml:web 应用的部署配置文件。它是工程中最重要的配置文件,它描述了 servlet 和组成应用的其它组件,以及应用初始化参数、安全管理约束等。

1.4. Tomcat 功能

Tomcat 支持的 I/O 模型有:

  • NIO:非阻塞 I/O,采用 Java NIO 类库实现。
  • NIO2:异步 I/O,采用 JDK 7 最新的 NIO2 类库实现。
  • APR:采用 Apache 可移植运行库实现,是 C/C++ 编写的本地库。

Tomcat 支持的应用层协议有:

  • HTTP/1.1:这是大部分 Web 应用采用的访问协议。
  • AJP:用于和 Web 服务器集成(如 Apache)。
  • HTTP/2:HTTP 2.0 大幅度的提升了 Web 性能。

2. Tomcat 入门

2.1. 安装

前提条件

Tomcat 8.5 要求 JDK 版本为 1.7 以上。

进入 Tomcat 官方下载地址 选择合适版本下载,并解压到本地。

Windows

添加环境变量 CATALINA_HOME ,值为 Tomcat 的安装路径。

进入安装目录下的 bin 目录,运行 startup.bat 文件,启动 Tomcat

Linux / Unix

下面的示例以 8.5.24 版本为例,包含了下载、解压、启动操作。

1
2
3
4
5
# 下载解压到本地
wget http://mirrors.hust.edu.cn/apache/tomcat/tomcat-8/v8.5.24/bin/apache-tomcat-8.5.24.tar.gz
tar -zxf apache-tomcat-8.5.24.tar.gz
# 启动 Tomcat
./apache-tomcat-8.5.24/bin/startup.sh

启动后,访问 http://localhost:8080 ,可以看到 Tomcat 安装成功的测试页面。

img

2.2. 配置

本节将列举一些重要、常见的配置项。详细的 Tomcat8 配置可以参考 Tomcat 8 配置官方参考文档

2.2.1. Server

Server 元素表示整个 Catalina servlet 容器。

因此,它必须是 conf/server.xml 配置文件中的根元素。它的属性代表了整个 servlet 容器的特性。

属性表

属性 描述 备注
className 这个类必须实现 org.apache.catalina.Server 接口。 默认 org.apache.catalina.core.StandardServer
address 服务器等待关机命令的 TCP / IP 地址。如果没有指定地址,则使用 localhost。
port 服务器等待关机命令的 TCP / IP 端口号。设置为-1 以禁用关闭端口。
shutdown 必须通过 TCP / IP 连接接收到指定端口号的命令字符串,以关闭 Tomcat。

2.2.2. Service

Service 元素表示一个或多个连接器组件的组合,这些组件共享一个用于处理传入请求的引擎组件。Server 中可以有多个 Service。

属性表

属性 描述 备注
className 这个类必须实现org.apache.catalina.Service接口。 默认 org.apache.catalina.core.StandardService
name 此服务的显示名称,如果您使用标准 Catalina 组件,将包含在日志消息中。与特定服务器关联的每个服务的名称必须是唯一的。

实例 - conf/server.xml 配置文件示例

1
2
3
4
5
6
<?xml version="1.0" encoding="UTF-8"?>
<Server port="8080" shutdown="SHUTDOWN">
<Service name="xxx">
...
</Service>
</Server>

2.2.3. Executor

Executor 表示可以在 Tomcat 中的组件之间共享的线程池。

属性表

属性 描述 备注
className 这个类必须实现org.apache.catalina.Executor接口。 默认 org.apache.catalina.core.StandardThreadExecutor
name 线程池名称。 要求唯一, 供 Connector 元素的 executor 属性使用
namePrefix 线程名称前缀。
maxThreads 最大活跃线程数。 默认 200
minSpareThreads 最小活跃线程数。 默认 25
maxIdleTime 当前活跃线程大于 minSpareThreads 时,空闲线程关闭的等待最大时间。 默认 60000ms
maxQueueSize 线程池满情况下的请求排队大小。 默认 Integer.MAX_VALUE
1
2
3
<Service name="xxx">
<Executor name="tomcatThreadPool" namePrefix="catalina-exec-" maxThreads="300" minSpareThreads="25"/>
</Service>

2.2.4. Connector

Connector 代表连接组件。Tomcat 支持三种协议:HTTP/1.1、HTTP/2.0、AJP。

属性表

属性 说明 备注
asyncTimeout Servlet3.0 规范中的异步请求超时 默认 30s
port 请求连接的 TCP Port 设置为 0,则会随机选取一个未占用的端口号
protocol 协议. 一般情况下设置为 HTTP/1.1,这种情况下连接模型会在 NIO 和 APR/native 中自动根据配置选择
URIEncoding 对 URI 的编码方式. 如果设置系统变量 org.apache.catalina.STRICT_SERVLET_COMPLIANCE 为 true,使用 ISO-8859-1 编码;如果未设置此系统变量且未设置此属性, 使用 UTF-8 编码
useBodyEncodingForURI 是否采用指定的 contentType 而不是 URIEncoding 来编码 URI 中的请求参数

以下属性在标准的 Connector(NIO, NIO2 和 APR/native)中有效:

属性 说明 备注
acceptCount 当最大请求连接 maxConnections 满时的最大排队大小 默认 100,注意此属性和 Executor 中属性 maxQueueSize 的区别.这个指的是请求连接满时的堆栈大小,Executor 的 maxQueueSize 指的是处理线程满时的堆栈大小
connectionTimeout 请求连接超时 默认 60000ms
executor 指定配置的线程池名称
keepAliveTimeout keeAlive 超时时间 默认值为 connectionTimeout 配置值.-1 表示不超时
maxConnections 最大连接数 连接满时后续连接放入最大为 acceptCount 的队列中. 对 NIO 和 NIO2 连接,默认值为 10000;对 APR/native,默认值为 8192
maxThreads 如果指定了 Executor, 此属性忽略;否则为 Connector 创建的内部线程池最大值 默认 200
minSpareThreads 如果指定了 Executor, 此属性忽略;否则为 Connector 创建线程池的最小活跃线程数 默认 10
processorCache 协议处理器缓存 Processor 对象的大小 -1 表示不限制.当不使用 servlet3.0 的异步处理情况下: 如果配置 Executor,配置为 Executor 的 maxThreads;否则配置为 Connnector 的 maxThreads. 如果使用 Serlvet3.0 异步处理, 取 maxThreads 和 maxConnections 的最大值

2.2.5. Context

Context 元素表示一个 Web 应用程序,它在特定的虚拟主机中运行。每个 Web 应用程序都基于 Web 应用程序存档(WAR)文件,或者包含相应的解包内容的相应目录,如 Servlet 规范中所述。

属性表

属性 说明 备注
altDDName web.xml 部署描述符路径 默认 /WEB-INF/web.xml
docBase Context 的 Root 路径 和 Host 的 appBase 相结合, 可确定 web 应用的实际目录
failCtxIfServletStartFails 同 Host 中的 failCtxIfServletStartFails, 只对当前 Context 有效 默认为 false
logEffectiveWebXml 是否日志打印 web.xml 内容(web.xml 由默认的 web.xml 和应用中的 web.xml 组成) 默认为 false
path web 应用的 context path 如果为根路径,则配置为空字符串(“”), 不能不配置
privileged 是否使用 Tomcat 提供的 manager servlet
reloadable /WEB-INF/classes/ 和/WEB-INF/lib/ 目录中 class 文件发生变化是否自动重新加载 默认为 false
swallowOutput true 情况下, System.out 和 System.err 输出将被定向到 web 应用日志中 默认为 false

2.2.6. Engine

Engine 元素表示与特定的 Catalina 服务相关联的整个请求处理机器。它接收并处理来自一个或多个连接器的所有请求,并将完成的响应返回给连接器,以便最终传输回客户端。

属性表

属性 描述 备注
defaultHost 默认主机名,用于标识将处理指向此服务器上主机名称但未在此配置文件中配置的请求的主机。 这个名字必须匹配其中一个嵌套的主机元素的名字属性。
name 此引擎的逻辑名称,用于日志和错误消息。 在同一服务器中使用多个服务元素时,每个引擎必须分配一个唯一的名称。

2.2.7. Host

Host 元素表示一个虚拟主机,它是一个服务器的网络名称(如“www.mycompany.com”)与运行 Tomcat 的特定服务器的关联。

属性表

属性 说明 备注
name 名称 用于日志输出
appBase 虚拟主机对应的应用基础路径 可以是个绝对路径, 或${CATALINA_BASE}相对路径
xmlBase 虚拟主机 XML 基础路径,里面应该有 Context xml 配置文件 可以是个绝对路径, 或${CATALINA_BASE}相对路径
createDirs 当 appBase 和 xmlBase 不存在时,是否创建目录 默认为 true
autoDeploy 是否周期性的检查 appBase 和 xmlBase 并 deploy web 应用和 context 描述符 默认为 true
deployIgnore 忽略 deploy 的正则
deployOnStartup Tomcat 启动时是否自动 deploy 默认为 true
failCtxIfServletStartFails 配置为 true 情况下,任何 load-on-startup >=0 的 servlet 启动失败,则其对应的 Contxt 也启动失败 默认为 false

2.2.8. Cluster

由于在实际开发中,我从未用过 Tomcat 集群配置,所以没研究。

2.3. 启动

2.3.1. 部署方式

这种方式要求本地必须安装 Tomcat 。

将打包好的 war 包放在 Tomcat 安装目录下的 webapps 目录下,然后在 bin 目录下执行 startup.batstartup.sh ,Tomcat 会自动解压 webapps 目录下的 war 包。

成功后,可以访问 http://localhost:8080/xxx (xxx 是 war 包文件名)。

注意

以上步骤是最简单的示例。步骤中的 war 包解压路径、启动端口以及一些更多的功能都可以修改配置文件来定制 (主要是 server.xmlcontext.xml 文件)。

2.3.2. 嵌入式

2.3.2.1. API 方式

在 pom.xml 中添加依赖

1
2
3
4
5
<dependency>
<groupId>org.apache.tomcat.embed</groupId>
<artifactId>tomcat-embed-core</artifactId>
<version>8.5.24</version>
</dependency>

添加 SimpleEmbedTomcatServer.java 文件,内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import java.util.Optional;
import org.apache.catalina.startup.Tomcat;

public class SimpleTomcatServer {
private static final int PORT = 8080;
private static final String CONTEXT_PATH = "/javatool-server";

public static void main(String[] args) throws Exception {
// 设定 profile
Optional<String> profile = Optional.ofNullable(System.getProperty("spring.profiles.active"));
System.setProperty("spring.profiles.active", profile.orElse("develop"));

Tomcat tomcat = new Tomcat();
tomcat.setPort(PORT);
tomcat.getHost().setAppBase(".");
tomcat.addWebapp(CONTEXT_PATH, getAbsolutePath() + "src/main/webapp");
tomcat.start();
tomcat.getServer().await();
}

private static String getAbsolutePath() {
String path = null;
String folderPath = SimpleEmbedTomcatServer.class.getProtectionDomain().getCodeSource().getLocation().getPath()
.substring(1);
if (folderPath.indexOf("target") > 0) {
path = folderPath.substring(0, folderPath.indexOf("target"));
}
return path;
}
}

成功后,可以访问 http://localhost:8080/javatool-server

说明

本示例是使用 org.apache.tomcat.embed 启动嵌入式 Tomcat 的最简示例。

这个示例中使用的是 Tomcat 默认的配置,但通常,我们需要对 Tomcat 配置进行一些定制和调优。为了加载配置文件,启动类就要稍微再复杂一些。这里不想再贴代码,有兴趣的同学可以参考:

示例项目

2.3.2.2. 使用 maven 插件启动(不推荐)

不推荐理由:这种方式启动 maven 虽然最简单,但是有一个很大的问题是,真的很久很久没发布新版本了(最新版本发布时间:2013-11-11)。且貌似只能找到 Tomcat6 、Tomcat7 插件。

使用方法

在 pom.xml 中引入插件

1
2
3
4
5
6
7
8
9
10
<plugin>
<groupId>org.apache.tomcat.maven</groupId>
<artifactId>tomcat7-maven-plugin</artifactId>
<version>2.2</version>
<configuration>
<port>8080</port>
<path>/${project.artifactId}</path>
<uriEncoding>UTF-8</uriEncoding>
</configuration>
</plugin>

运行 mvn tomcat7:run 命令,启动 Tomcat。

成功后,可以访问 http://localhost:8080/xxx (xxx 是 ${project.artifactId} 指定的项目名)。

2.3.3. IDE 插件

常见 Java IDE 一般都有对 Tomcat 的支持。

以 Intellij IDEA 为例,提供了 Tomcat and TomEE Integration 插件(一般默认会安装)。

使用步骤

  • 点击 Run/Debug Configurations > New Tomcat Server > local ,打开 Tomcat 配置页面。
  • 点击 Confiure… 按钮,设置 Tomcat 安装路径。
  • 点击 Deployment 标签页,设置要启动的应用。
  • 设置启动应用的端口、JVM 参数、启动浏览器等。
  • 成功后,可以访问 http://localhost:8080/(当然,你也可以在 url 中设置上下文名称)。

img

说明

个人认为这个插件不如 Eclipse 的 Tomcat 插件好用,Eclipse 的 Tomcat 插件支持对 Tomcat xml 配置文件进行配置。而这里,你只能自己去 Tomcat 安装路径下修改配置文件。

文中的嵌入式启动示例可以参考我的示例项目

3. Tomcat 架构

img

Tomcat 要实现 2 个核心功能:

  • 处理 Socket 连接,负责网络字节流与 Request 和 Response 对象的转化。
  • 加载和管理 Servlet,以及处理具体的 Request 请求

为此,Tomcat 设计了两个核心组件:

  • 连接器(Connector):负责和外部通信
  • 容器(Container):负责内部处理

3.1. Service

Tomcat 支持的 I/O 模型有:

  • NIO:非阻塞 I/O,采用 Java NIO 类库实现。
  • NIO2:异步 I/O,采用 JDK 7 最新的 NIO2 类库实现。
  • APR:采用 Apache 可移植运行库实现,是 C/C++ 编写的本地库。

Tomcat 支持的应用层协议有:

  • HTTP/1.1:这是大部分 Web 应用采用的访问协议。
  • AJP:用于和 Web 服务器集成(如 Apache)。
  • HTTP/2:HTTP 2.0 大幅度的提升了 Web 性能。

Tomcat 支持多种 I/O 模型和应用层协议。为了实现这点,一个容器可能对接多个连接器。但是,单独的连接器或容器都不能对外提供服务,需要把它们组装起来才能工作,组装后这个整体叫作 Service 组件。Tomcat 内可能有多个 Service,通过在 Tomcat 中配置多个 Service,可以实现通过不同的端口号来访问同一台机器上部署的不同应用。

img

一个 Tomcat 实例有一个或多个 Service;一个 Service 有多个 Connector 和 Container。Connector 和 Container 之间通过标准的 ServletRequest 和 ServletResponse 通信。

3.2. 连接器

连接器对 Servlet 容器屏蔽了协议及 I/O 模型等的区别,无论是 HTTP 还是 AJP,在容器中获取到的都是一个标准的 ServletRequest 对象。

连接器的主要功能是:

  • 网络通信
  • 应用层协议解析
  • Tomcat Request/Response 与 ServletRequest/ServletResponse 的转化

Tomcat 设计了 3 个组件来实现这 3 个功能,分别是 EndPointProcessor 和 **Adapter**。

img

组件间通过抽象接口交互。这样做还有一个好处是封装变化。这是面向对象设计的精髓,将系统中经常变化的部分和稳定的部分隔离,有助于增加复用性,并降低系统耦合度。网络通信的 I/O 模型是变化的,可能是非阻塞 I/O、异步 I/O 或者 APR。应用层协议也是变化的,可能是 HTTP、HTTPS、AJP。浏览器端发送的请求信息也是变化的。但是整体的处理逻辑是不变的,EndPoint 负责提供字节流给 Processor,Processor 负责提供 Tomcat Request 对象给 Adapter,Adapter 负责提供 ServletRequest 对象给容器。

如果要支持新的 I/O 方案、新的应用层协议,只需要实现相关的具体子类,上层通用的处理逻辑是不变的。由于 I/O 模型和应用层协议可以自由组合,比如 NIO + HTTP 或者 NIO2 + AJP。Tomcat 的设计者将网络通信和应用层协议解析放在一起考虑,设计了一个叫 ProtocolHandler 的接口来封装这两种变化点。各种协议和通信模型的组合有相应的具体实现类。比如:Http11NioProtocol 和 AjpNioProtocol。

img

3.2.1. ProtocolHandler 组件

连接器用 ProtocolHandler 接口来封装通信协议和 I/O 模型的差异。ProtocolHandler 内部又分为 EndPoint 和 Processor 模块,EndPoint 负责底层 Socket 通信,Proccesor 负责应用层协议解析。

3.2.1.1. EndPoint

EndPoint 是通信端点,即通信监听的接口,是具体的 Socket 接收和发送处理器,是对传输层的抽象,因此 EndPoint 是用来实现 TCP/IP 协议的。

EndPoint 是一个接口,对应的抽象实现类是 AbstractEndpoint,而 AbstractEndpoint 的具体子类,比如在 NioEndpoint 和 Nio2Endpoint 中,有两个重要的子组件:Acceptor 和 SocketProcessor。

其中 Acceptor 用于监听 Socket 连接请求。SocketProcessor 用于处理接收到的 Socket 请求,它实现 Runnable 接口,在 Run 方法里调用协议处理组件 Processor 进行处理。为了提高处理能力,SocketProcessor 被提交到线程池来执行。而这个线程池叫作执行器(Executor)。

3.2.1.2. Processor

如果说 EndPoint 是用来实现 TCP/IP 协议的,那么 Processor 用来实现 HTTP 协议,Processor 接收来自 EndPoint 的 Socket,读取字节流解析成 Tomcat Request 和 Response 对象,并通过 Adapter 将其提交到容器处理,Processor 是对应用层协议的抽象。

Processor 是一个接口,定义了请求的处理等方法。它的抽象实现类 AbstractProcessor 对一些协议共有的属性进行封装,没有对方法进行实现。具体的实现有 AJPProcessor、HTTP11Processor 等,这些具体实现类实现了特定协议的解析方法和请求处理方式。

img

从图中我们看到,EndPoint 接收到 Socket 连接后,生成一个 SocketProcessor 任务提交到线程池去处理,SocketProcessor 的 Run 方法会调用 Processor 组件去解析应用层协议,Processor 通过解析生成 Request 对象后,会调用 Adapter 的 Service 方法。

3.2.2. Adapter

连接器通过适配器 Adapter 调用容器

由于协议不同,客户端发过来的请求信息也不尽相同,Tomcat 定义了自己的 Request 类来适配这些请求信息。

ProtocolHandler 接口负责解析请求并生成 Tomcat Request 类。但是这个 Request 对象不是标准的 ServletRequest,也就意味着,不能用 Tomcat Request 作为参数来调用容器。Tomcat 的解决方案是引入 CoyoteAdapter,这是适配器模式的经典运用,连接器调用 CoyoteAdapter 的 Sevice 方法,传入的是 Tomcat Request 对象,CoyoteAdapter 负责将 Tomcat Request 转成 ServletRequest,再调用容器的 Service 方法。

3.3. 容器

Tomcat 设计了 4 种容器,分别是 Engine、Host、Context 和 Wrapper。

  • Engine - Servlet 的顶层容器,包含一 个或多个 Host 子容器;
  • Host - 虚拟主机,负责 web 应用的部署和 Context 的创建;
  • Context - Web 应用上下文,包含多个 Wrapper,负责 web 配置的解析、管理所有的 Web 资源;
  • Wrapper - 最底层的容器,是对 Servlet 的封装,负责 Servlet 实例的创 建、执行和销毁。

3.3.1. 请求分发 Servlet 过程

Tomcat 是怎么确定请求是由哪个 Wrapper 容器里的 Servlet 来处理的呢?答案是,Tomcat 是用 Mapper 组件来完成这个任务的。

举例来说,假如有一个网购系统,有面向网站管理人员的后台管理系统,还有面向终端客户的在线购物系统。这两个系统跑在同一个 Tomcat 上,为了隔离它们的访问域名,配置了两个虚拟域名:manage.shopping.comuser.shopping.com,网站管理人员通过manage.shopping.com域名访问 Tomcat 去管理用户和商品,而用户管理和商品管理是两个单独的 Web 应用。终端客户通过user.shopping.com域名去搜索商品和下订单,搜索功能和订单管理也是两个独立的 Web 应用。如下所示,演示了 url 应声 Servlet 的处理流程。

img

假如有用户访问一个 URL,比如图中的http://user.shopping.com:8080/order/buy,Tomcat 如何将这个 URL 定位到一个 Servlet 呢?

  1. 首先,根据协议和端口号选定 Service 和 Engine。
  2. 然后,根据域名选定 Host。
  3. 之后,根据 URL 路径找到 Context 组件。
  4. 最后,根据 URL 路径找到 Wrapper(Servlet)。

这个路由分发过程具体是怎么实现的呢?答案是使用 Pipeline-Valve 管道。

3.3.2. Pipeline-Value

Pipeline 可以理解为现实中的管道,Valve 为管道中的阀门,Request 和 Response 对象在管道中经过各个阀门的处理和控制。

Pipeline-Valve 是责任链模式,责任链模式是指在一个请求处理的过程中有很多处理者依次对请求进行处理,每个处理者负责做自己相应的处理,处理完之后将再调用下一个处理者继续处理。Valve 表示一个处理点,比如权限认证和记录日志。

先来了解一下 Valve 和 Pipeline 接口的设计:

img

  • 每一个容器都有一个 Pipeline 对象,只要触发这个 Pipeline 的第一个 Valve,这个容器里 Pipeline 中的 Valve 就都会被调用到。但是,不同容器的 Pipeline 是怎么链式触发的呢,比如 Engine 中 Pipeline 需要调用下层容器 Host 中的 Pipeline。
  • 这是因为 Pipeline 中还有个 getBasic 方法。这个 BasicValve 处于 Valve 链表的末端,它是 Pipeline 中必不可少的一个 Valve,负责调用下层容器的 Pipeline 里的第一个 Valve。
  • Pipeline 中有 addValve 方法。Pipeline 中维护了 Valve 链表,Valve 可以插入到 Pipeline 中,对请求做某些处理。我们还发现 Pipeline 中没有 invoke 方法,因为整个调用链的触发是 Valve 来完成的,Valve 完成自己的处理后,调用 getNext.invoke() 来触发下一个 Valve 调用。
  • Valve 中主要的三个方法:setNextgetNextinvoke。Valve 之间的关系是单向链式结构,本身 invoke 方法中会调用下一个 Valve 的 invoke 方法。
  • 各层容器对应的 basic valve 分别是 StandardEngineValveStandardHostValveStandardContextValveStandardWrapperValve
  • 由于 Valve 是一个处理点,因此 invoke 方法就是来处理请求的。注意到 Valve 中有 getNext 和 setNext 方法,因此我们大概可以猜到有一个链表将 Valve 链起来了。

img

整个调用过程由连接器中的 Adapter 触发的,它会调用 Engine 的第一个 Valve:

1
connector.getService().getContainer().getPipeline().getFirst().invoke(request, response);

4. Tomcat 生命周期

4.1. Tomcat 的启动过程

img

  1. Tomcat 是一个 Java 程序,它的运行从执行 startup.sh 脚本开始。startup.sh 会启动一个 JVM 来运行 Tomcat 的启动类 Bootstrap
  2. Bootstrap 会初始化 Tomcat 的类加载器并实例化 Catalina
  3. Catalina 会通过 Digester 解析 server.xml,根据其中的配置信息来创建相应组件,并调用 Serverstart 方法。
  4. Server 负责管理 Service 组件,它会调用 Servicestart 方法。
  5. Service 负责管理 Connector 和顶层容器 Engine,它会调用 ConnectorEnginestart 方法。

4.1.1. Catalina 组件

Catalina 的职责就是解析 server.xml 配置,并据此实例化 Server。接下来,调用 Server 组件的 init 方法和 start 方法,将 Tomcat 启动起来。

Catalina 还需要处理各种“异常”情况,比如当我们通过“Ctrl + C”关闭 Tomcat 时,Tomcat 将如何优雅的停止并且清理资源呢?因此 Catalina 在 JVM 中注册一个“关闭钩子”。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
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 做了些什么呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
protected class CatalinaShutdownHook extends Thread {

@Override
public void run() {
try {
if (getServer() != null) {
Catalina.this.stop();
}
} catch (Throwable ex) {
...
}
}
}

Tomcat 的“关闭钩子”实际上就执行了 Serverstop 方法,Serverstop 方法会释放和清理所有的资源。

4.1.2. Server 组件

Server 组件的具体实现类是 StandardServer,Server 继承了 LifeCycleBase,它的生命周期被统一管理,并且它的子组件是 Service,因此它还需要管理 Service 的生命周期,也就是说在启动时调用 Service 组件的启动方法,在停止时调用它们的停止方法。Server 在内部维护了若干 Service 组件,它是以数组来保存的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
@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);
}

}

Server 并没有一开始就分配一个很长的数组,而是在添加的过程中动态地扩展数组长度,当添加一个新的 Service 实例时,会创建一个新数组并把原来数组内容复制到新数组,这样做的目的其实是为了节省内存空间。

除此之外,Server 组件还有一个重要的任务是启动一个 Socket 来监听停止端口,这就是为什么你能通过 shutdown 命令来关闭 Tomcat。不知道你留意到没有,上面 Caralina 的启动方法的最后一行代码就是调用了 Server 的 await 方法。

在 await 方法里会创建一个 Socket 监听 8005 端口,并在一个死循环里接收 Socket 上的连接请求,如果有新的连接到来就建立连接,然后从 Socket 中读取数据;如果读到的数据是停止命令“SHUTDOWN”,就退出循环,进入 stop 流程。

4.1.3. Service 组件

Service 组件的具体实现类是 StandardService。

【源码】StandardService 源码定义

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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 维护了一个 MapperListener 用于支持 Tomcat 热部署。当 Web 应用的部署发生变化时,Mapper 中的映射信息也要跟着变化,MapperListener 就是一个监听器,它监听容器的变化,并把信息更新到 Mapper 中,这是典型的观察者模式。

作为“管理”角色的组件,最重要的是维护其他组件的生命周期。此外在启动各种组件时,要注意它们的依赖关系,也就是说,要注意启动的顺序。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
protected void startInternal() throws LifecycleException {

//1. 触发启动监听器
setState(LifecycleState.STARTING);

//2. 先启动 Engine,Engine 会启动它子容器
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 在容器组件之后启动。组件停止的顺序跟启动顺序正好相反的,也是基于它们的依赖关系。

4.1.4. Engine 组件

Engine 本质是一个容器,因此它继承了 ContainerBase 基类,并且实现了 Engine 接口。

4.2. Web 应用的部署方式

注:catalina.home:安装目录;catalina.base:工作目录;默认值 user.dir

  • Server.xml 配置 Host 元素,指定 appBase 属性,默认$catalina.base/webapps/
  • Server.xml 配置 Context 元素,指定 docBase,元素,指定 web 应用的路径
  • 自定义配置:在$catalina.base/EngineName/HostName/XXX.xml 配置 Context 元素

HostConfig 监听了 StandardHost 容器的事件,在 start 方法中解析上述配置文件:

  • 扫描 appbase 路径下的所有文件夹和 war 包,解析各个应用的 META-INF/context.xml,并 创建 StandardContext,并将 Context 加入到 Host 的子容器中。
  • 解析$catalina.base/EngineName/HostName/下的所有 Context 配置,找到相应 web 应 用的位置,解析各个应用的 META-INF/context.xml,并创建 StandardContext,并将 Context 加入到 Host 的子容器中。

注:

  • HostConfig 并没有实际解析 Context.xml,而是在 ContextConfig 中进行的。
  • HostConfig 中会定期检查 watched 资源文件(context.xml 配置文件)

ContextConfig 解析 context.xml 顺序:

  • 先解析全局的配置 config/context.xml
  • 然后解析 Host 的默认配置 EngineName/HostName/context.xml.default
  • 最后解析应用的 META-INF/context.xml

ContextConfig 解析 web.xml 顺序:

  • 先解析全局的配置 config/web.xml
  • 然后解析 Host 的默认配置 EngineName/HostName/web.xml.default 接着解析应用的 MEB-INF/web.xml
  • 扫描应用 WEB-INF/lib/下的 jar 文件,解析其中的 META-INF/web-fragment.xml 最后合并 xml 封装成 WebXml,并设置 Context

注:

  • 扫描 web 应用和 jar 中的注解(Filter、Listener、Servlet)就是上述步骤中进行的。
  • 容器的定期执行:backgroundProcess,由 ContainerBase 来实现的,并且只有在顶层容器 中才会开启线程。(backgroundProcessorDelay=10 标志位来控制)

4.3. LifeCycle

img

4.3.1. 请求处理过程

  1. 根据 server.xml 配置的指定的 connector 以及端口监听 http、或者 ajp 请求
  2. 请求到来时建立连接,解析请求参数,创建 Request 和 Response 对象,调用顶层容器 pipeline 的 invoke 方法
  3. 容器之间层层调用,最终调用业务 servlet 的 service 方法
  4. Connector 将 response 流中的数据写到 socket 中

4.4. Connector 流程

4.4.1. 阻塞 IO

4.4.2. 非阻塞 IO

4.4.3. IO 多路复用

阻塞与非阻塞的区别在于进行读操作和写操作的系统调用时,如果此时内核态没有数据可读或者没有缓冲空间可写时,是否阻塞。

IO 多路复用的好处在于可同时监听多个 socket 的可读和可写事件,这样就能使得应用可以同时监听多个 socket,释放了应用线程资源。

4.4.4. Tomcat 各类 Connector 对比

  • JIO:用 java.io 编写的 TCP 模块,阻塞 IO
  • NIO:用 java.nio 编写的 TCP 模块,非阻塞 IO,(IO 多路复用)
  • APR:全称 Apache Portable Runtime,使用 JNI 的方式来进行读取文件以及进行网络传输

Apache Portable Runtime 是一个高度可移植的库,它是 Apache HTTP Server 2.x 的核心。 APR 具有许多用途,包括访问高级 IO 功能(如 sendfile,epoll 和 OpenSSL),操作系统级功能(随机数生成,系统状态等)和本地进程处理(共享内存,NT 管道和 Unix 套接字)。

表格中字段含义说明:

  • Support Polling - 是否支持基于 IO 多路复用的 socket 事件轮询
  • Polling Size - 轮询的最大连接数
  • Wait for next Request - 在等待下一个请求时,处理线程是否释放,BIO 是没有释放的,所以在 keep-alive=true 的情况下处理的并发连接数有限
  • Read Request Headers - 由于 request header 数据较少,可以由容器提前解析完毕,不需要阻塞
  • Read Request Body - 读取 request body 的数据是应用业务逻辑的事情,同时 Servlet 的限制,是需要阻塞读取的
  • Write Response - 跟读取 request body 的逻辑类似,同样需要阻塞写

NIO 处理相关类

Poller 线程从 EventQueue 获取 PollerEvent,并执行 PollerEvent 的 run 方法,调用 Selector 的 select 方法,如果有可读的 Socket 则创建 Http11NioProcessor,放入到线程池中执行;

CoyoteAdapter 是 Connector 到 Container 的适配器,Http11NioProcessor 调用其提供的 service 方法,内部创建 Request 和 Response 对象,并调用最顶层容器的 Pipeline 中的第一个 Valve 的 invoke 方法

Mapper 主要处理 http url 到 servlet 的映射规则的解析,对外提供 map 方法

4.5. Comet

Comet 是一种用于 web 的推送技术,能使服务器实时地将更新的信息传送到客户端,而无须客户端发出请求
在 WebSocket 出来之前,如果不适用 comet,只能通过浏览器端轮询 Server 来模拟实现服务器端推送。
Comet 支持 servlet 异步处理 IO,当连接上数据可读时触发事件,并异步写数据(阻塞)

Tomcat 要实现 Comet,只需继承 HttpServlet 同时,实现 CometProcessor 接口

  • Begin:新的请求连接接入调用,可进行与 Request 和 Response 相关的对象初始化操作,并保存 response 对象,用于后续写入数据
  • Read:请求连接有数据可读时调用
  • End:当数据可用时,如果读取到文件结束或者 response 被关闭时则被调用
  • Error:在连接上发生异常时调用,数据读取异常、连接断开、处理异常、socket 超时

Note:

  • Read:在 post 请求有数据,但在 begin 事件中没有处理,则会调用 read,如果 read 没有读取数据,在会触发 Error 回调,关闭 socket
  • End:当 socket 超时,并且 response 被关闭时也会调用;server 被关闭时调用
  • Error:除了 socket 超时不会关闭 socket,其他都会关闭 socket
  • End 和 Error 时间触发时应关闭当前 comet 会话,即调用 CometEvent 的 close 方法
    Note:在事件触发时要做好线程安全的操作

4.6. 异步 Servlet

传统流程:

  • 首先,Servlet 接收到请求之后,request 数据解析;
  • 接着,调用业务接口的某些方法,以完成业务处理;
  • 最后,根据处理的结果提交响应,Servlet 线程结束

异步处理流程:

  • 客户端发送一个请求
  • Servlet 容器分配一个线程来处理容器中的一个 servlet
  • servlet 调用 request.startAsync(),保存 AsyncContext, 然后返回
  • 任何方式存在的容器线程都将退出,但是 response 仍然保持开放
  • 业务线程使用保存的 AsyncContext 来完成响应(线程池)
  • 客户端收到响应

Servlet 线程将请求转交给一个异步线程来执行业务处理,线程本身返回至容器,此时 Servlet 还没有生成响应数据,异步线程处理完业务以后,可以直接生成响应数据(异步线程拥有 ServletRequest 和 ServletResponse 对象的引用)

为什么 web 应用中支持异步?

推出异步,主要是针对那些比较耗时的请求:比如一次缓慢的数据库查询,一次外部 REST API 调用, 或者是其他一些 I/O 密集型操作。这种耗时的请求会很快的耗光 Servlet 容器的线程池,继而影响可扩展性。

Note:从客户端的角度来看,request 仍然像任何其他的 HTTP 的 request-response 交互一样,只是耗费了更长的时间而已

异步事件监听

  • onStartAsync:Request 调用 startAsync 方法时触发
  • onComplete:syncContext 调用 complete 方法时触发
  • onError:处理请求的过程出现异常时触发
  • onTimeout:socket 超时触发

Note :
onError/ onTimeout 触发后,会紧接着回调 onComplete
onComplete 执行后,就不可再操作 request 和 response

5. 参考资料

Tomcat 连接器

1. NioEndpoint 组件

Tomcat 的 NioEndPoint 组件利用 Java NIO 实现了 I/O 多路复用模型。

img

NioEndPoint 子组件功能简介:

  • LimitLatch 是连接控制器,负责控制最大连接数。NIO 模式下默认是 10000,达到这个阈值后,连接请求被拒绝。
  • Acceptor 负责监听连接请求。Acceptor 运行在一个单独的线程里,它在一个死循环里调用 accept 方法来接收新连接,一旦有新的连接请求到来,accept 方法返回一个 Channel 对象,接着把 Channel 对象交给 Poller 去处理。
  • Poller 的本质是一个 Selector,也运行在单独线程里。Poller 内部维护一个 Channel 数组,它在一个死循环里不断检测 Channel 的数据就绪状态,一旦有 Channel 可读,就生成一个 SocketProcessor 任务对象扔给 Executor 去处理。
  • Executor 就是线程池,负责运行 SocketProcessor 任务类,SocketProcessor 的 run 方法会调用 Http11Processor 来读取和解析请求数据。我们知道,Http11Processor 是应用层协议的封装,它会调用容器获得响应,再把响应通过 Channel 写出。

NioEndpoint 如何实现高并发的呢?

要实现高并发需要合理设计线程模型充分利用 CPU 资源,尽量不要让线程阻塞;另外,就是有多少任务,就用相应规模的线程数去处理。

NioEndpoint 要完成三件事情:接收连接、检测 I/O 事件以及处理请求,那么最核心的就是把这三件事情分开,用不同规模的线程去处理,比如用专门的线程组去跑 Acceptor,并且 Acceptor 的个数可以配置;用专门的线程组去跑 Poller,Poller 的个数也可以配置;最后具体任务的执行也由专门的线程池来处理,也可以配置线程池的大小。

1.1. LimitLatch

LimitLatch 用来控制连接个数,当连接数到达最大时阻塞线程,直到后续组件处理完一个连接后将连接数减 1。请你注意到达最大连接数后操作系统底层还是会接收客户端连接,但用户层已经不再接收。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
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 扩展了 AQS,AQS 是 Java 并发包中的一个核心类,它在内部维护一个状态和一个线程队列,可以用来控制线程什么时候挂起,什么时候唤醒。我们可以扩展它来实现自己的同步器,实际上 Java 并发包里的锁和条件变量等等都是通过 AQS 来实现的,而这里的 LimitLatch 也不例外。

理解源码要点:

  • 用户线程通过调用 LimitLatch 的 countUpOrAwait 方法来拿到锁,如果暂时无法获取,这个线程会被阻塞到 AQS 的队列中。那 AQS 怎么知道是阻塞还是不阻塞用户线程呢?其实这是由 AQS 的使用者来决定的,也就是内部类 Sync 来决定的,因为 Sync 类重写了 AQS 的tryAcquireShared() 方法。它的实现逻辑是如果当前连接数 count 小于 limit,线程能获取锁,返回 1,否则返回 -1。
  • 如何用户线程被阻塞到了 AQS 的队列,那什么时候唤醒呢?同样是由 Sync 内部类决定,Sync 重写了 AQS 的releaseShared() 方法,其实就是当一个连接请求处理完了,这时又可以接收一个新连接了,这样前面阻塞的线程将会被唤醒。

1.2. Acceptor

Acceptor 实现了 Runnable 接口,因此可以跑在单独线程里。一个端口号只能对应一个 ServerSocketChannel,因此这个 ServerSocketChannel 是在多个 Acceptor 线程之间共享的,它是 Endpoint 的属性,由 Endpoint 完成初始化和端口绑定。

1
2
3
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 通信。

1.3. Poller

Poller 本质是一个 Selector,它内部维护一个 Queue

1
private final SynchronizedQueue<PollerEvent> events = new SynchronizedQueue<>();

SynchronizedQueue 的核心方法都使用了 Synchronized 关键字进行修饰,用来保证同一时刻只有一个线程进行读写。

使用 SynchronizedQueue,意味着同一时刻只有一个 Acceptor 线程对队列进行读写;同时有多个 Poller 线程在运行,每个 Poller 线程都有自己的队列。每个 Poller 线程可能同时被多个 Acceptor 线程调用来注册 PollerEvent。同样 Poller 的个数可以通过 pollers 参数配置。

Poller 不断的通过内部的 Selector 对象向内核查询 Channel 的状态,一旦可读就生成任务类 SocketProcessor 交给 Executor 去处理。Poller 的另一个重要任务是循环遍历检查自己所管理的 SocketChannel 是否已经超时,如果有超时就关闭这个 SocketChannel

1.4. SocketProcessor

我们知道,Poller 会创建 SocketProcessor 任务类交给线程池处理,而 SocketProcessor 实现了 Runnable 接口,用来定义 Executor 中线程所执行的任务,主要就是调用 Http11Processor 组件来处理请求。Http11Processor 读取 Channel 的数据来生成 ServletRequest 对象,这里请你注意:

Http11Processor 并不是直接读取 Channel 的。这是因为 Tomcat 支持同步非阻塞 I/O 模型和异步 I/O 模型,在 Java API 中,相应的 Channel 类也是不一样的,比如有 AsynchronousSocketChannelSocketChannel,为了对 Http11Processor 屏蔽这些差异,Tomcat 设计了一个包装类叫作 SocketWrapperHttp11Processor 只调用 SocketWrapper 的方法去读写数据。

2. Nio2Endpoint 组件

Nio2Endpoint 工作流程跟 NioEndpoint 较为相似。

img

Nio2Endpoint 子组件功能说明:

  • LimitLatch 是连接控制器,它负责控制最大连接数。
  • Nio2Acceptor 扩展了 Acceptor,用异步 I/O 的方式来接收连接,跑在一个单独的线程里,也是一个线程组。Nio2Acceptor 接收新的连接后,得到一个 AsynchronousSocketChannelNio2AcceptorAsynchronousSocketChannel 封装成一个 Nio2SocketWrapper,并创建一个 SocketProcessor 任务类交给线程池处理,并且 SocketProcessor 持有 Nio2SocketWrapper 对象。
  • Executor 在执行 SocketProcessor 时,SocketProcessor 的 run 方法会调用 Http11Processor 来处理请求,Http11Processor 会通过 Nio2SocketWrapper 读取和解析请求数据,请求经过容器处理后,再把响应通过 Nio2SocketWrapper 写出。

Nio2Endpoint 跟 NioEndpoint 的一个明显不同点是,Nio2Endpoint 中没有 Poller 组件,也就是没有 Selector。这是为什么呢?因为在异步 I/O 模式下,Selector 的工作交给内核来做了。

2.1. Nio2Acceptor

NioEndpint 一样,Nio2Endpoint 的基本思路是用 LimitLatch 组件来控制连接数。

但是 Nio2Acceptor 的监听连接的过程不是在一个死循环里不断的调 accept 方法,而是通过回调函数来完成的。我们来看看它的连接监听方法:

1
serverSock.accept(null, this);

其实就是调用了 accept 方法,注意它的第二个参数是 this,表明 Nio2Acceptor 自己就是处理连接的回调类,因此 Nio2Acceptor 实现了 CompletionHandler 接口。那么它是如何实现 CompletionHandler 接口的呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
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 方法,在这个方法里,会创建 Nio2SocketWrapperSocketProcessor,并交给线程池处理。

2.2. 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 是作为附件类来传递的**,这样在回调函数里能拿到所有的上下文。

1
2
3
4
5
6
7
8
9
10
11
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) {
...
}
}

3. AprEndpoint 组件

我们在使用 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(Apache 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 方式来调用。JNI(Java Native Interface) 是 JDK 提供的一个编程接口,它允许 Java 程序调用其他语言编写的程序或者代码库,其实 JDK 本身的实现也大量用到 JNI 技术来调用本地 C 程序库。

3.1. AprEndpoint 工作流程

img

3.1.1. Acceptor

Accpetor 的功能就是监听连接,接收并建立连接。它的本质就是调用了四个操作系统 API:socket、bind、listen 和 accept。那 Java 语言如何直接调用 C 语言 API 呢?答案就是通过 JNI。具体来说就是两步:先封装一个 Java 类,在里面定义一堆用native 关键字修饰的方法,像下面这样。

1
2
3
4
5
6
7
8
9
10
11
12
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 函数就是这样实现的:

1
2
3
4
5
6
7
8
9
10
11
12
// 注意函数的名字要符合 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。

3.1.2. 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 来说,它的目的就是尽可能提升性能,因此它向用户暴露了这个参数。

3.2. APR 提升性能的秘密

APR 连接器之所以能提高 Tomcat 的性能,除了 APR 本身是 C 程序库之外,还有哪些提速的秘密呢?

JVM 堆 VS 本地内存

我们知道 Java 的类实例一般在 JVM 堆上分配,而 Java 是通过 JNI 调用 C 代码来实现 Socket 通信的,那么 C 代码在运行过程中需要的内存又是从哪里分配的呢?C 代码能否直接操作 Java 堆?

为了回答这些问题,我先来说说 JVM 和用户进程的关系。如果你想运行一个 Java 类文件,可以用下面的 Java 命令来执行。

1
java my.class

这个命令行中的java其实是一个可执行程序,这个程序会创建 JVM 来加载和运行你的 Java 类。操作系统会创建一个进程来执行这个java可执行程序,而每个进程都有自己的虚拟地址空间,JVM 用到的内存(包括堆、栈和方法区)就是从进程的虚拟地址空间上分配的。请你注意的是,JVM 内存只是进程空间的一部分,除此之外进程空间内还有代码段、数据段、内存映射区、内核空间等。从 JVM 的角度看,JVM 内存之外的部分叫作本地内存,C 程序代码在运行过程中用到的内存就是本地内存中分配的。下面我们通过一张图来理解一下。

img

Tomcat 的 Endpoint 组件在接收网络数据时需要预先分配好一块 Buffer,所谓的 Buffer 就是字节数组byte[],Java 通过 JNI 调用把这块 Buffer 的地址传给 C 代码,C 代码通过操作系统 API 读取 Socket 并把数据填充到这块 Buffer。Java NIO API 提供了两种 Buffer 来接收数据:HeapByteBuffer 和 DirectByteBuffer,下面的代码演示了如何创建两种 Buffer。

1
2
3
4
5
// 分配 HeapByteBuffer
ByteBuffer buf = ByteBuffer.allocate(1024);

// 分配 DirectByteBuffer
ByteBuffer buf = ByteBuffer.allocateDirect(1024);

创建好 Buffer 后直接传给 Channel 的 read 或者 write 函数,最终这块 Buffer 会通过 JNI 调用传递给 C 程序。

1
2
// 将 buf 作为 read 函数的参数
int bytesRead = socketChannel.read(buf);

那 HeapByteBuffer 和 DirectByteBuffer 有什么区别呢?HeapByteBuffer 对象本身在 JVM 堆上分配,并且它持有的字节数组byte[]也是在 JVM 堆上分配。但是如果用HeapByteBuffer来接收网络数据,需要把数据从内核先拷贝到一个临时的本地内存,再从临时本地内存拷贝到 JVM 堆,而不是直接从内核拷贝到 JVM 堆上。这是为什么呢?这是因为数据从内核拷贝到 JVM 堆的过程中,JVM 可能会发生 GC,GC 过程中对象可能会被移动,也就是说 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 没有去冒这个险。

3.2.1. sendfile

我们再来考虑另一个网络通信的场景,也就是静态文件的处理。浏览器通过 Tomcat 来获取一个 HTML 文件,而 Tomcat 的处理逻辑无非是两步:

  1. 从磁盘读取 HTML 到内存。
  2. 将这段内存的内容通过 Socket 发送出去。

但是在传统方式下,有很多次的内存拷贝:

  • 读取文件时,首先是内核把文件内容读取到内核缓冲区。
  • 如果使用 HeapByteBuffer,文件数据从内核到 JVM 堆内存需要经过本地内存中转。
  • 同样在将文件内容推入网络时,从 JVM 堆到内核缓冲区需要经过本地内存中转。
  • 最后还需要把文件从内核缓冲区拷贝到网卡缓冲区。

从下面的图你会发现这个过程有 6 次内存拷贝,并且 read 和 write 等系统调用将导致进程从用户态到内核态的切换,会耗费大量的 CPU 和内存资源。

img

而 Tomcat 的 AprEndpoint 通过操作系统层面的 sendfile 特性解决了这个问题,sendfile 系统调用方式非常简洁。

1
sendfile(socket, file, len);

它带有两个关键参数:Socket 和文件句柄。将文件从磁盘写入 Socket 的过程只有两步:

第一步:将文件内容读取到内核缓冲区。

第二步:数据并没有从内核缓冲区复制到 Socket 关联的缓冲区,只有记录数据位置和长度的描述符被添加到 Socket 缓冲区中;接着把数据直接从内核缓冲区传递给网卡。这个过程你可以看下面的图。

img

4. Executor 组件

为了提高处理能力和并发度,Web 容器一般会把处理请求的工作放到线程池里来执行,Tomcat 扩展了原生的 Java 线程池,来满足 Web 容器高并发的需求。

4.1. Tomcat 定制线程池

Tomcat 的线程池也是一个定制版的 ThreadPoolExecutor。Tomcat 传入的参数是这样的:

1
2
3
4
5
6
7
8
// 定制版的任务队列
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 原生线程池的任务处理逻辑比较简单:

  1. 前 corePoolSize 个任务时,来一个任务就创建一个新线程。
  2. 后面再来任务,就把任务添加到任务队列里让所有的线程去抢,如果队列满了就创建临时线程。
  3. 如果总线程数达到 maximumPoolSize,执行拒绝策略。

Tomcat 线程池扩展了原生的 ThreadPoolExecutor,通过重写 execute 方法实现了自己的任务处理逻辑:

  1. 前 corePoolSize 个任务时,来一个任务就创建一个新线程。
  2. 再来任务的话,就把任务添加到任务队列里让所有的线程去抢,如果队列满了就创建临时线程。
  3. 如果总线程数达到 maximumPoolSize,则继续尝试把任务添加到任务队列中去。
  4. 如果缓冲队列也满了,插入失败,执行拒绝策略。

观察 Tomcat 线程池和 Java 原生线程池的区别,其实就是在第 3 步,Tomcat 在线程总数达到最大数时,不是立即执行拒绝策略,而是再尝试向任务队列添加任务,添加失败后再执行拒绝策略。那具体如何实现呢,其实很简单,我们来看一下 Tomcat 线程池的 execute 方法的核心代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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) {
// 如果总线程数达到 maximumPoolSize,Java 原生线程池执行拒绝策略
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 去执行任务,如果总线程数达到 maximumPoolSize,Java 原生线程池的 execute 方法会抛出 RejectedExecutionException 异常,但是这个异常会被 Tomcat 线程池的 execute 方法捕获到,并继续尝试把这个任务放到任务队列中去;如果任务队列也满了,再执行拒绝策略。

4.2. Tomcat 定制任务队列

细心的你有没有发现,在 Tomcat 线程池的 execute 方法最开始有这么一行:

1
submittedCount.incrementAndGet();

这行代码的意思把 submittedCount 这个原子变量加一,并且在任务执行失败,抛出拒绝异常时,将这个原子变量减一:

1
submittedCount.decrementAndGet();

其实 Tomcat 线程池是用这个变量 submittedCount 来维护已经提交到了线程池,但是还没有执行完的任务个数。Tomcat 为什么要维护这个变量呢?这跟 Tomcat 的定制版的任务队列有关。Tomcat 的任务队列 TaskQueue 扩展了 Java 中的 LinkedBlockingQueue,我们知道 LinkedBlockingQueue 默认情况下长度是没有限制的,除非给它一个 capacity。因此 Tomcat 给了它一个 capacity,TaskQueue 的构造函数中有个整型的参数 capacity,TaskQueue 将 capacity 传给父类 LinkedBlockingQueue 的构造函数。

1
2
3
4
5
6
7
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 方法的核心源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
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 参数来限制任务队列的长度。

5. WebSocket 组件

HTTP 协议是“请求 - 响应”模式,浏览器必须先发请求给服务器,服务器才会响应这个请求。也就是说,服务器不会主动发送数据给浏览器。

对于实时性要求比较的高的应用,比如在线游戏、股票基金实时报价和在线协同编辑等,浏览器需要实时显示服务器上最新的数据,因此出现了 Ajax 和 Comet 技术。Ajax 本质上还是轮询,而 Comet 是在 HTTP 长连接的基础上做了一些 hack,但是它们的实时性不高,另外频繁的请求会给服务器带来压力,也会浪费网络流量和带宽。于是 HTML5 推出了 WebSocket 标准,使得浏览器和服务器之间任何一方都可以主动发消息给对方,这样服务器有新数据时可以主动推送给浏览器。

Tomcat 如何支持 WebSocket?简单来说,Tomcat 做了两件事:

  • Endpoint 加载
  • WebSocket 请求处理

5.1. WebSocket 加载

Tomcat 的 WebSocket 加载是通过 SCI 机制完成的。SCI 全称 ServletContainerInitializer,是 Servlet 3.0 规范中定义的用来接收 Web 应用启动事件的接口。那为什么要监听 Servlet 容器的启动事件呢?因为这样我们有机会在 Web 应用启动时做一些初始化工作,比如 WebSocket 需要扫描和加载 Endpoint 类。SCI 的使用也比较简单,将实现 ServletContainerInitializer 接口的类增加 HandlesTypes 注解,并且在注解内指定的一系列类和接口集合。比如 Tomcat 为了扫描和加载 Endpoint 而定义的 SCI 类如下:

1
2
3
4
5
6
7
@HandlesTypes({ServerEndpoint.class, ServerApplicationConfig.class, Endpoint.class})
public class WsSci implements ServletContainerInitializer {

public void onStartup(Set<Class<?>> clazzes, ServletContext ctx) throws ServletException {
...
}
}

一旦定义好了 SCI,Tomcat 在启动阶段扫描类时,会将 HandlesTypes 注解中指定的类都扫描出来,作为 SCI 的 onStartup 方法的参数,并调用 SCI 的 onStartup 方法。注意到 WsSci 的 HandlesTypes 注解中定义了ServerEndpoint.classServerApplicationConfig.classEndpoint.class,因此在 Tomcat 的启动阶段会将这些类的类实例(注意不是对象实例)传递给 WsSci 的 onStartup 方法。那么 WsSci 的 onStartup 方法又做了什么事呢?

它会构造一个 WebSocketContainer 实例,你可以把 WebSocketContainer 理解成一个专门处理 WebSocket 请求的Endpoint 容器。也就是说 Tomcat 会把扫描到的 Endpoint 子类和添加了注解@ServerEndpoint的类注册到这个容器中,并且这个容器还维护了 URL 到 Endpoint 的映射关系,这样通过请求 URL 就能找到具体的 Endpoint 来处理 WebSocket 请求。

5.2. WebSocket 请求处理

Tomcat 用 ProtocolHandler 组件屏蔽应用层协议的差异,其中 ProtocolHandler 中有两个关键组件:Endpoint 和 Processor。需要注意,这里的 Endpoint 跟上文提到的 WebSocket 中的 Endpoint 完全是两回事,连接器中的 Endpoint 组件用来处理 I/O 通信。WebSocket 本质就是一个应用层协议,因此不能用 HttpProcessor 来处理 WebSocket 请求,而要用专门 Processor 来处理,而在 Tomcat 中这样的 Processor 叫作 UpgradeProcessor。

为什么叫 Upgrade Processor 呢?这是因为 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,而是用专门的 UpgradeProcessor,UpgradeProcessor 最终会调用相应的 Endpoint 实例来处理请求。

img

你可以看到,Tomcat 对 WebSocket 请求的处理没有经过 Servlet 容器,而是通过 UpgradeProcessor 组件直接把请求发到 ServerEndpoint 实例,并且 Tomcat 的 WebSocket 实现不需要关注具体 I/O 模型的细节,从而实现了与具体 I/O 方式的解耦。

6. 参考资料