Tallate

该吃吃该喝喝 啥事别往心里搁

开始使用 SpringBoot

使用 sdkman 搭建环境

介绍

  1. sdkman
    工具包管理器,可以用于管理 Spring Boot CLI。
  2. Spring Boot CLI
    命令行工具。
  3. groovy
    一种可以运行在 JVM 上的语言,和 Java 的编译器不同(语法、语义不同)。

安装

  1. 安装 sdkman
    按下面链接中的步骤来,可能需要连接 VPN:
    http://sdkman.io/install.html
  2. 安装 Spring Boot CLI
    默认安装最新版本的(安装位置在$HOME/.sdkman 下):
    1
    sdk install springboot
  3. 切换 Spring Boot CLI 版本
    1
    2
    3
    sdk list springboot
    sdk install springboot XXX.RELEASE
    sdk use springboot XXX.RELEASE
  4. 设置默认版本
    1
    sdk default springboot XXX.RELEASE
  5. 使用本地编译的 springboot
    1
    2
    3
    $ sdk install springboot dev /path/to/spring-boot/spring-boot-cli/target/spring-boot-cli-2.0.0.BUILD-SNAPSHOT-bin/spring-2.0.0.BUILD-SNAPSHOT/
    $ sdk default springboot dev
    $ spring --version

第一个 SpringBoot 应用

  1. 创建一个 app.groovy 文件:
    1
    2
    3
    4
    5
    6
    7
    @RestController
    class ThisWillActuallyRun {
    @RequestMapping("/")
    String home() {
    "Hello World!"
    }
    }
  2. 执行
    1
    spring run app.groovy
  3. 访问
    1
    localhost:8080

IDEA 下搭建 SpringBoot 项目

创建一个简单的 Web 项目

  1. 创建项目
    new Projects… -> Spring Initializr(这里可能需要连接 VPN) -> 设置项目元数据 -> 添加 Web 组件(对于这个例子来说足够了)
  2. 添加控制器
    创建 controller.HelloController:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    @RestController
    public class HelloController {

    @RequestMapping("/hello")
    public String hello() {
    System.out.println("Hello");
    return "hello";
    }
    }
  3. 执行 XxxApplication.main()
  4. 访问
    localhost:8080

添加 Jpa 支持

  1. 添加依赖
    1
    2
    3
    4
    5
    6
    7
    8
    <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
    </dependency>
    <dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-java</artifactId>
    </dependency>
  2. 添加配置
    1
    2
    3
    4
    5
    6
    7
    spring.datasource.driver-class-name=com.mysql.jdbc.Driver
    spring.datasource.url=jdbc:mysql://localhost:3306/test
    spring.datasource.username=root
    spring.datasource.password=85382855

    spring.jpa.hibernate.ddl-auto=create
    spring.jpa.show-sql=true
  3. 创建实体类
    1
    2
    3
    4
    5
    6
    7
    8
    9
    @Entity
    public class User {
    @Id
    @GeneratedValue
    private Integer id;
    private String username;
    private String password;
    ...
    }
  4. 创建 Dao 接口
    1
    2
    public interface UserDao extends JpaRepository<User, Integer> {
    }
  5. 注入 Dao 接口
    1
    2
    @Autowired
    private UserDao userDao;

测试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@RunWith(SpringRunner.class)
@SpringBootTest
public class HelloTest {

private MockMvc mockMvc;
@Before
public void setUp() throws Exception {
//测试 HelloWorldController
mockMvc = MockMvcBuilders.standaloneSetup(new HelloWorldController()).build();
}
@Test
public void getHello() throws Exception {
mockMvc.perform(MockMvcRequestBuilders.post("/hello?name=小明")
.accept(MediaType.APPLICATION_JSON_UTF8)).andDo(print());
}
}

SpringBoot 原理分析

核心思想

  1. 约定优于配置(convention over configuration)
    传统 Spring 项目配置繁琐是 SpringBoot 主要解决的痛点之一,autoconfigure 模块会在启动时为自动注入的 Bean 设置默认配置。
  2. 模块化
    类似于 maven 管理项目的依赖、git 管理代码的版本,SpringBoot 提供了对一些常用组件的模块化管理,只要添加依赖几乎就可以马上使用,展现出一种插件化的效果。

spring-boot-load 模块

正常情况下一个类加载器只能找到加载路径的 jar 包里当前目录或者文件类里面的 *.class 文件,SpringBoot 允许我们使用 java -jar archive.jar 运行包含嵌套依赖 jar 的 jar 或者 war 文件。

传统类加载器的局限

传统类加载器无法加载 jar 包中嵌套的 jar 包。比如项目打包后包含如下这些类和 jar 包,则 c.jar 中的类无法被加载:

1
2
3
4
5
6
a1.class
a2.class
b.jar
b1.class
b2.class
c.jar

为了能够加载嵌套 jar 里面的资源,之前的做法都是把嵌套 jar 里面的 class 文件和应用的 class 文件打包为一个 jar,这样就不存在嵌套 jar 了,但是这样做就不能很清晰的知道哪些是应用自己的,哪些是应用依赖的,另外多个嵌套 jar 里面的 class 文件可能内容不一样但是文件名却一样时候又会引发新的问题。

加载嵌套 jar 包中的 class 文件

在 Java 中,AppClassLoader 和 ExtClassLoader 都继承自 URLClassLoader,并通过构造函数来传递需要加载的 class 文件所在的目录。
那么只要将 jar 包中嵌套的 jar 包所在的路径作为 URLClassLoader 的扫描路径,就可以实现对嵌套 jar 包的扫描了。
但是默认情况下 Java 使用 AppClassLoader 加载使用命令启动时指定的 classpath 下的类和 jar 包,那么如何使用自定义的 URLClassLoader 来加载这些类和 jar 包呢?具体做法是加一个中间层。
原来是直接使用 AppClassLoader 来加载应用:AppClassLoader -> Application。
现在先加载一个自定义的启动类 Launcher,其中自定义 URLClassLoader,再使用该 URLClassLoader 来加载我们真正的 main 函数:AppClassLoader -> Launcher -> URLClassLoader -> Application。

spring-boot-load 提供的启动器 Launcher

spring-boot-load 模块允许我们加载嵌套 jar 包中的 class 文件,它包含三种类启动器:

  1. JarLauncher
  2. WarLauncher
  3. PropertiesLauncher

spring-boot-maven-plugin 打包插件及 SpringBoot 指定的包结构

为了为 SpringBoot 应用打包,需要引入如下 Maven 插件:

1
2
3
4
5
6
7
8
9
10
11
12
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<version>1.5.9.RELEASE</version>
<executions>
<execution>
<goals>
<goal>repackage</goal>
</goals>
</execution>
</executions>
</plugin>

SpringBoot项目打包后的jar包结构如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
archive.jar
|
+-META-INF(1)
| +-MANIFEST.MF
+-org(2)
| +-springframework
| +-boot
| +-loader
| +-<spring boot loader classes>
+-com(3)
| +-mycompany
| + project
| +-YouClasses.class
+-lib(4)
+-dependency1.jar
+-dependency2.jar

目录(1)是 jar 文件中 MANIFEST.MF 文件的存放处。
目录(2)是 Spring-boot-loader 本身需要的 class 放置处。
目录(3)是应用本身的文件资源放置处。
目录(4)是应用依赖的 jar 固定放置处,即 lib 目录。

打包按如下顺序进行:

  1. 执行mvn clean package按原流程进行打包生成 jar 文件;
  2. spring-boot-maven-plugin 插件对 jar 包进行重写。
    1. 改写 MANIFEST.MF 文件:Main-Class: org.springframework.boot.loader.JarLauncherStart-Class: com.abc.MyApplication,这里的 MyApplication 即项目中被@SpringBootApplication注解了的那个类,指定主类后 AppClassLoader 就会先加载 JarLauncher 并执行其中的 main 方法了;
    2. 写入应用依赖的 jar 包到 lib 目录;
    3. 拷贝 spring-boot-load 包里的 class 文件到 jar 包的目录(2)处。

      为什么要拷贝到目录(2)的位置?因为后面启动流程中LaunchedURLClassLoader位于spring-boot-loader.jar包内,AppClassLoader无法加载嵌套的jar中的文件。

启动顺序如下,以 JarLauncher 为例,假设 SpringBoot 项目打包后的 jar 包名为 test.jar:
JarLauncher执行时序图

  1. 使用 AppClassLoader 加载 JarLauncher,执行其 main 函数;
  2. 查找 test.jar 中的嵌套 jar 后生成一个列表List<Archive>,每个Archive包含了一个嵌套 jar 的信息,对应 jar 包 Archive 的子类是 JarFileArchive
  3. 将 test.jar 本身构造成的 Archive 插入List<Archive>列表的第一个位置;
  4. 将这个列表作为参数构建LaunchedURLClassLoader类加载器,这个类加载器继承自 URLClassLoader;
  5. 使用 LaunchedURLClassLoader 加载并实例化 MyApplication 的一个对象,并通过反射调用 main 函数,这时候 SpringBoot 才正式开始初始化 SpringBoot 的环境,并创建容器。

spring-boot-autoconfigure 模块

Spring 的出现给我们管理 Bean 的依赖注入提供了便捷,但是当我们需要使用通过 pom 引入的 jar 里面的一个 Bean 时候,还是需要手动在 XML 配置文件里面配置。
spring-boot-autoconfigure 思路类似 **SPI(Service Provider Interface)**,都是不同的实现类实现了定义的接口,加载时候去查找 classpath 下的实现类,不同在于前者使用 autoconfigure 实现,后者使用的是 ServiceLoader
autoconfigure 模块的主要功能如下:

  1. 可以依据 classpath 里面的依赖内容自动配置 Bean 到 Spring 容器,并为默认化属性配置。
  2. 尝试推断哪些 Beans 是用户可能会需要的。比如如果 HSQLDB 包在当前 classpath 下,并且用户并没有配置其他数据库链接,这时候 Auto-configuration 功能会自动注入一个基于内存的数据库连接到应用的 IOC 容器。再比如当我们在 pom 引入了 Tomcat 的 start 后,如果当前还没 Web 容器被注入到应用 IOC,那么 SpringBoot 就会为我们自动创建并启动一个内嵌 Tomcat 容器来服务。

EnableAutoConfiguration

核心注解@EnableAutoConfiguration的定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
@AutoConfigurationPackage
@Import({AutoConfigurationImportSelector.class})
public @interface EnableAutoConfiguration {
String ENABLED_OVERRIDE_PROPERTY = "spring.boot.enableautoconfiguration";

Class<?>[] exclude() default {};

String[] excludeName() default {};
}

EnableAutoConfiguration 注解主要作用是通过 @Import 注入 EnableAutoConfigurationImportSelector 实例,后者是实现 AutoConfiguration 功能的核心类。
EnableAutoConfigurationImportSelector时序图
EnableAutoConfigurationImportSelector 类实现了 DeferredImportSelector 接口的 String[] selectImports(AnnotationMetadata metadata) 方法,当 Spring 框架解析 @Import 注解时候会调用该方法。
代码EnableAutoConfigurationImportSelector#getCandidateConfigurationsloadFactoryNames的作用是扫描 classpath 下含有 Meta-INF/spring.factories 文件的 jar,并解析文件中名字为 org.springframework.boot.autoconfigure.EnableAutoConfiguration 的配置项。
getCandidateConfigurations会返回名字为 org.springframework.boot.autoconfigure.EnableAutoConfiguration 的配置项的值的一个列表并且对列表内容进行去重处理。

正常情况下,去重后的列表里面的类都会被自动注入到 Spring IOC 容器,除非指定不自动注入某些功能,比如若要排除自动创建数据源和事务管理的功能可以:@EnableAutoConfiguration(exclude={DataSourceAutoConfiguration.class, DataSourceTransactionManagerAutoConfiguration.class}),getExclusions 就是读取 EnableAutoConfiguration 注解里面的 excludeName 和 exclude 配置项,然后从去重后的列名列表里面剔除。这样在自动注入时候就不会对排除掉的自动配置功能进行注入了。

这个列表中的每个元素就是一个自动配置功能,比如 org.springframework.boot.autoconfigure.web.servlet.ServletWebServerFactoryAutoConfiguration 是 Web 容器自动注入的功能类(比如这个类可以自动扫描 Tomcat 的 start 并创建一个 Tomcat 容器)。

例子 - 注入 Web 容器

ServletWebServerFactoryAutoConfiguration 的部分核心代码如下:

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
54
55
56
57
58
59
60
61
@Configuration
@AutoConfigureOrder(Ordered.HIGHEST_PRECEDENCE)
@ConditionalOnClass(ServletRequest.class)
@ConditionalOnWebApplication(type = Type.SERVLET)
@EnableConfigurationProperties(ServerProperties.class)
@Import({ ServletWebServerFactoryAutoConfiguration.BeanPostProcessorsRegistrar.class,
ServletWebServerFactoryConfiguration.EmbeddedTomcat.class,
ServletWebServerFactoryConfiguration.EmbeddedJetty.class,
ServletWebServerFactoryConfiguration.EmbeddedUndertow.class })
public class ServletWebServerFactoryAutoConfiguration {

@Bean
public ServletWebServerFactoryCustomizer servletWebServerFactoryCustomizer(ServerProperties serverProperties) {
return new ServletWebServerFactoryCustomizer(serverProperties);
}

@Bean
@ConditionalOnClass(name = "org.apache.catalina.startup.Tomcat")
public TomcatServletWebServerFactoryCustomizer tomcatServletWebServerFactoryCustomizer(
ServerProperties serverProperties) {
return new TomcatServletWebServerFactoryCustomizer(serverProperties);
}

/**
* Registers a {@link WebServerFactoryCustomizerBeanPostProcessor}. Registered via
* {@link ImportBeanDefinitionRegistrar} for early registration.
*/
public static class BeanPostProcessorsRegistrar implements ImportBeanDefinitionRegistrar, BeanFactoryAware {

private ConfigurableListableBeanFactory beanFactory;

@Override
public void setBeanFactory(BeanFactory beanFactory) throws BeansException {
if (beanFactory instanceof ConfigurableListableBeanFactory) {
this.beanFactory = (ConfigurableListableBeanFactory) beanFactory;
}
}

@Override
public void registerBeanDefinitions(AnnotationMetadata importingClassMetadata,
BeanDefinitionRegistry registry) {
if (this.beanFactory == null) {
return;
}
registerSyntheticBeanIfMissing(registry, "webServerFactoryCustomizerBeanPostProcessor",
WebServerFactoryCustomizerBeanPostProcessor.class);
registerSyntheticBeanIfMissing(registry, "errorPageRegistrarBeanPostProcessor",
ErrorPageRegistrarBeanPostProcessor.class);
}

private void registerSyntheticBeanIfMissing(BeanDefinitionRegistry registry, String name, Class<?> beanClass) {
if (ObjectUtils.isEmpty(this.beanFactory.getBeanNamesForType(beanClass, true, false))) {
RootBeanDefinition beanDefinition = new RootBeanDefinition(beanClass);
beanDefinition.setSynthetic(true);
registry.registerBeanDefinition(name, beanDefinition);
}
}

}

}

ServletWebServerFactoryAutoConfiguration 类是 Web 容器自动注入的 Auto-configuration 类。

  • @ConditionalOnWebApplication 说明当前是 Web 环境上下文时候才注入本类到 IOC。
  • @Import 注解导入了另一个配置ServletWebServerFactoryConfiguration.EmbeddedTomcat.class,这里包含了真正的注入 Tomcat 实例的逻辑:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    @Configuration
    class ServletWebServerFactoryConfiguration {

    @Configuration
    @ConditionalOnClass({ Servlet.class, Tomcat.class, UpgradeProtocol.class })
    @ConditionalOnMissingBean(value = ServletWebServerFactory.class, search = SearchStrategy.CURRENT)
    public static class EmbeddedTomcat {

    @Bean
    public TomcatServletWebServerFactory tomcatServletWebServerFactory() {
    return new TomcatServletWebServerFactory();
    }

    }
    ...
  • @ConditionalOnClass:只有在 classpath 下存在指定的类的时候 Bean 才能被创建。
  • @ConditionalOnMissingBean:只有对应的 Bean 在系统中都没有被创建,它修饰的初始化代码块才会执行,【用户自己手动创建的 Bean 优先】。

当应用引入 spring-boot-starter-web 时候默认引入的是 Tomcat 的 starter,所以会发现 classpath 下存在 Servlet.class、 Tomcat.class 这两个类,并且 IOC 里面没有 TomcatEmbeddedServletContainerFactory 的实例,因此会创建其实例到 IOC 容器中,最终会创建一个 Tomcat 容器。
当然,如果需要使用 Jetty 则需要在引用 spring-boot-starter-web 的时候排除掉 Tomcat 的 start,然后在引入 Jetty 的 start 即可。

总结

总而言之,自动配置的生效步骤可以概括如下:

  1. Spring Boot 在启动时会去依赖的 Starter 包中寻找 resources/META-INF/spring.factories 文件,然后根据文件中配置的 Jar 包去扫描项目所依赖的 Jar 包。
  2. 根据 spring.factories 配置加载 AutoConfigure 类
  3. 根据 @Conditional 注解的条件,进行自动配置并将 Bean 注入 Spring Context

spring-boot 模块

提供了一些特性用来支持 SpringBoot 中其它模块,这些特性包括:

  1. SpringApplication 类提供了静态方法以便于写一个独立了 Spring 应用程序,该类的主要职责是 create 和 refresh 一个合适的 Spring 应用程序上下文(ApplicationContext)。
  2. 一流的外部配置的支持(application.properties)。
  3. 提供了便捷的应用程序上下文(ApplicationContext)的初始化器,以便在 ApplicationContext 使用前对其进行用户定制。
  4. 给 Web 应用提供了一个可选的 Web 容器(目前有 Tomcat 或 Jetty)。

SpringApplication 的执行流程

一般的 SpringBoot 项目都会有一个如下所示的 Application 类:

1
2
3
4
5
6
7
@SpringBootApplication
public class Web1Application {

public static void main(String[] args) {
SpringApplication.run(Web1Application.class, args);
}
}

JarLauncher启动过后里面实际上就是调用了 SpringApplication#run 方法,SpringApplication 的执行流程如下:
SpringApplication执行时序图

  1. SpringApplication 的构造函数里面会调用 initialize 方法在 classpath 的 jar 包里面查找 META-INF/spring.factories,如果找到则看里面是否有配置 ApplicationContextInitializer 类型的 Bean,有则加载到 SpringApplication 的变量 initializers 里面存放,比如 spring-boot.jar 里面。
    ApplicationContextInitializer配置
  2. createAndRefreshContext 做了这几件事情:
    1. 设置环境,加载 application.properties 等配置文件;
    2. 根据 classpath 的 jar 里面是否有 ConfigurableWebEnvironment 判断当前是否需要创建 Web 应用程序上下文还是创建一个非 Web 应用程序上下文;
    3. 使用前面加载的应用程序初始化器对创建的应用程序上下文进行初始化;
    4. 刷新应用程序上下文解析 Bean 定义到应用程序上下文里面的 IOC 容器,在刷新过程的 invokeBeanFactoryPostProcessors 过程中(10)(11)会去解析类上面标注的 @import 注解,然后就会调用所有的 ImportSelector 的 selectImports 方法,这也是第二部分时序图开始执行的地方。

Web 容器的创建

通过自动配置将 TomcatEmbeddedServletContainerFactory 或者 JettyEmbeddedServletContainerFactory 的实例注入 IOC 容器后,我们就可以使用 Web 容器来提供 Web 服务了。
具体的 Web 容器的创建是在容器刷新过程的 onRefresh 阶段进行的(这个阶段是在刷新过程的 invokeBeanFactoryPostProcessors 阶段的后面):
Web容器创建过程

  • getBeanNamesForType 获取了 IOC 容器中的 EmbeddedServletContainerFactory 类型的 Bean 的 name 集合,如果 name 集合为空或者多个则抛出异常。还记得 Web 容器工厂是通过自动配置注入到 IOC 的吧,并且 TomcatEmbeddedServletContainerFactory 或者 JettyEmbeddedServletContainerFactory 都是实现了 EmbeddedServletContainerFactory 接口。
  • 如果 IOC 里面只有一个 Web 容器工厂 Bean 则获取该 Bean 的实例,然后调用该 Bean 的 getEmbeddedServletContainer 获取 Web 容器,这里假设 Web 容器工厂为 Tomcat ,则创建 Tomcat 容器并进行初始化。

Actuator 模块

Actuator类图
Actuator 模块提供了一些组件用于检查应用或中间件的健康状况:

  1. Inspect:检测应用或中间件的健康状况;
  2. Aggregate:聚合所有结果。

基于 Actuator 模块,我们可以开发一个服务探活系统:

  1. Actuator 为业务服务器暴露出一个探活端口healthcheck,借助 Spring 容器获取到中间件的客户端实例;
  2. 探活服务HealthCheck-Task定时轮询所有服务,统计单位时间内请求出现问题的次数,从而发送报警短信提醒研发同学。

服务的轮询探活将是这个系统的主要瓶颈,有一些措施可以作为参考:

  1. 灵活使用CompletableFuture这些并发编排组件,因为探活请求一般都是比较”慢”的,因此可以通过并行化来提高效率;
  2. 探活不能占用太多系统资源,合理设置探活超时时间,很多客户端的默认超时时间达到 1 分钟甚至更长,必须额外设置探活请求的超时时间,避免服务器挂掉时所有线程都被hang死,导致服务雪崩。

    探活不能和业务代码共用线程池,做到线程池隔离,即使出问题也只影响到HealthCheck-Task

参考

原理

  1. SpringBoot应用篇(一):自定义starter
  2. 从零开始开发一个Spring Boot Starter
  3. SpringBoot启动流程解析

源码分析

  1. fangjian0423/springboot-analysis

Eureka

  • 服务发现,提供高可用的服务注册表功能,是之后 Ribbon、Zuul 等组件的基础。

创建 Eureka Server

  1. 创建 Spring Initializr 项目
    选中 Cloud Discovery -> Eureka Server 模块
  2. EurekaServerApplication 类上添加 @EnableEurekaServer
  3. 指定一个 Eureka Server
    在默认情况下 erureka server 也是一个 eureka client,必须要指定一个 server,在配置文件中添加:
    1
    2
    3
    4
    5
    6
        server.port=8761
    eureka.instance.hostname=localhost
    eureka.client.register-with-eureka=false
    eureka.client.fetch-registry=false
    eureka.client.service-url.default-zone=http://eureka.instance.hostname:eureka.instance.hostname:
    {server.port}/eureka/
    其中通过 eureka.client.registerWithEureka=false 和 fetchRegistry=false 来表明自己是一个 Eureka Server。
  4. 访问 Eureka Server 服务器
    localhost:8761
    此时还没有服务被注册进来。

创建 Eureka Client

  1. 类似的,创建的 Spring Initializr 项目是 Eureka Discovery
  2. EurekaClientApplication 类上添加 @EnableEurekaClient 注解
  3. 添加配置
    1
    2
    3
    4
    5
    eureka.client.service-url.default-zone=http://localhost:8761/eureka
    server.port=8762
    spring.application.name=service-name
    eureka.client.register-with-eureka=false
    eureka.client.fetch-registry=false
    需要指明 spring.application.name,这个很重要,这在以后的服务与服务之间相互调用一般都是根据这个 name。

集群化 Eureka Server

  1. 创建两个配置文件,分别代表两个实例的属性
    application-peer1.properties:
    1
    2
    3
    server.port=8761
    eureka.instance.hostname=peer1
    eureka.client.service-url.defaultZone=http://peer2:8769/eureka/
    application-peer2.properties:
    1
    2
    3
    server.port=8769
    eureka.instance.hostname=peer2
    eureka.client.service-url.defaultZone=http://peer1:8761/eureka/
  2. 在/etc/hosts 文件中添加下面两条记录:
    1
    2
    127.0.0.1 peer1
    127.0.0.1 peer2
  3. 启动
    先在application.properties中指定:
    1
    spring.profiles.active=peer1
    启动 peer1,然后指定:
    1
    spring.profiles.active=peer2
    启动 peer2。
    这时访问 localhost:8761 和 localhost:8769 可以看到两个节点的注册信息,他们分别是对方的复制,他们是对等的。

Ribbon - 服务消费

  • 用来作为软件的负载均衡,微服务之间相互调用是通过 ribbon 作为软件负载均衡使用负载到微服务集群内的不同的实例。

配置Bean

IClientConfig ribbonClientConfig: DefaultClientConfigImpl
IRule ribbonRule: ZoneAvoidanceRule
IPing ribbonPing: NoOpPing
ServerList ribbonServerList: ConfigurationBasedServerList
ServerListFilter ribbonServerListFilter: ZonePreferenceServerListFilter
ILoadBalancer ribbonLoadBalancer: ZoneAwareLoadBalancer

创建服务消费者

  1. 创建 Spring Initializr
    勾选 Eureka Discovery、Ribbon
  2. 添加配置
    1
    2
    3
    eureka.client.service-url.defaultZone=http://localhost:8761/eureka/
    server.port=8764
    spring.application.name=service-ribbon
  3. 除了为 ServiceRibbonApplication 类添加 @EnableDiscoveryClient 外,还需要注册一个 RestTemplate 的 Bean:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    /**
    * 向Spring容器注入一个RestTemplate
    * 通过@LoadBalanced表明开启负载均衡功能
    * @return
    */
    @Bean
    @LoadBalanced
    RestTemplate restTemplate() {
    return new RestTemplate();
    }
  4. 创建服务类来消费service-name服务的”/hi”接口的服务,当该服务名有多个服务实例时会从中选择一个具体的服务实例:
    1
    2
    3
    4
    5
    6
    7
    8
    @Service
    public class HelloService {
    @Autowired
    RestTemplate restTemplate;
    public String hello(String name) {
    return restTemplate.getForObject("http://service-name/hi?name=" + name, String.class);
    }
    }
  5. 使用一个 Controller 来调用负载均衡服务
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    @RestController
    public class HelloController {

    @Autowired
    HelloService helloService;

    @RequestMapping("/hi")
    public String hi(@RequestParam String name) {
    return helloService.hello(name);
    }
    }
  6. 此时访问 service-ribbon 的”/hi”接口就会被转发到 service-name 的对应服务上去,并且会根据负载情况来轮流调用不同实例的端口。

Feign - 服务消费

与 Ribbon 区别

  1. Feign 整合了 Ribbon
  2. Feign 是基于接口的注解,Ribbon 是基于 RestTemplate 的

创建 Feign 服务

  1. 创建一个 Spring Initializr 项目
    选中 Eureka Discovery 和 Feign
  2. ServiceFeignApplication 类上添加 **@EnableDiscoveryClient
    ** 和 @EnableFeignClients 注解开启 Eureka 服务发现和 Feign 功能
  3. 定义一个 Feign 接口
    1
    2
    3
    4
    5
    @FeignClient("service-name")
    public interface ScheduleServiceHi {
    @RequestMapping(value = "/hi", method = RequestMethod.GET)
    String sayHiFromClientOne(@RequestParam("name") String name);
    }
    注解 @FeignClient 指定了服务名,具体的接口使用@RequestMapping指定。
  4. 创建一个 Controller,对外暴露一个”/hi”接口,它调用上面定义的 Feign 客户端的 ScheduleServiceHi 来消费服务
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    @RestController
    public class HiController {

    @Autowired
    private ScheduleServiceHi scheduleServiceHi;

    @RequestMapping(value = "/hi", method = RequestMethod.GET)
    public String sayHi(@RequestParam String name) {
    return scheduleServiceHi.sayHiFromClientOne(name);
    }
    }

Zuul

Hystrix - 断路器

Dashboard 和 Turbine:一个是单个节点的监控,后者可以从多个 Hystrix 服务器获取数据、整合到一个 Dashboard 界面上显示

应用

  1. 防止服务器雪崩
    雪崩:如果单个服务出现问题,调用这个服务就会出现线程阻塞,此时若有大量的请求涌入,Servlet容器的线程资源会被消耗完毕,导致服务瘫痪。服务与服务之间的依赖性,故障会传播,会对整个微服务系统造成灾难性的严重后果。
    断路就是及早阻止对这些不可用服务的请求。
  2. 断路原理
    当对特定的服务的调用的不可用达到一个阀值(Hystric 是 5 秒 20 次) 断路器将会被打开,然后???

为 Ribbon 工程添加 Hystrix 功能

  1. 创建 Spring Initializr 项目
    勾选 Hystrix、Eureka Discovery、Ribbon,或者在 Ribbon 项目中添加下面的依赖:
    1
    2
    3
    4
    <dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-hystrix</artifactId>
    </dependency>
  2. ServiceRibbonApplication 类添加 @EnableHystrix 注解开启Hystrix
  3. 为 Service 方法添加 @HystrixCommand 注解
    1
    2
    3
    4
    5
    6
    7
    8
    @HystrixCommand(fallbackMethod = "hiError")
    public String hello(String name) {
    return restTemplate.getForObject("http://service-name/hi?name=" + name, String.class);
    }

    public String hiError(String name) {
    return "hi, " + name + ", sorry, error!";
    }
  4. 接下来在启动所有服务后,关闭 service-name 服务(这是断路器依赖的服务)
    这样就会在访问 service-ribbon 服务时被断路,并调用 hiError()方法。

为 Feign 工程添加 Hystrix 功能

  1. 为 Feign 客户端添加 fallback 属性,该属性值是服务接口的实现类
    1
    @FeignClient(value = "service-name", fallback = ScheduleServiceHiHystrix.class)
  2. 创建接口的实现类
    1
    2
    3
    4
    5
    6
    7
    @Component
    public class ScheduleServiceHiHystrix implements ScheduleServiceHi {
    @Override
    public String sayHiFromClientOne(String name) {
    return "sorry " + name;
    }
    }
  3. 添加下面的配置项
    feign.hystrix.enabled=true
  4. 必须还得添加下面的依赖包
    1
    2
    3
    4
    5
    6
    7
    8
    9
    <dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-hystrix</artifactId>
    </dependency>
    <dependency>
    <groupId>com.netflix.hystrix</groupId>
    <artifactId>hystrix-javanica</artifactId>
    <version>1.5.12</version>
    </dependency>
  5. 测试接口时,可以先开着 service-name 服务关掉再开起来,在这个过程中访问 service-feign 服务,可以看到响应的变化

Hystrix Dashboard

  1. 开启仪表盘很简单,先添加依赖库:
    1
    2
    3
    4
    5
    6
    7
    8
    <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-actuator</artifactId>
    </dependency>
    <dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-hystrix-dashboard</artifactId>
    </dependency>
  2. 以 service-ribbon 服务为例,为 ServiceRibbonApplication 类添加 @EnableHystrixDashboard 注解
  3. 访问http://localhost:8764/hystrix 接口,进入监控界面
    HystrixDashboard

Hystrix Turbine

  1. 创建一个 Spring Initializr 工程
    勾选 Actuator、Turbine
  2. 为启动类添加注解 @EnableTurbine ,它其实包含了@EnableDiscoveryClient,所以 Turbine 默认是作为 Eureka 集群中的一员的
  3. 添加配置
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    spring.application.name=service-turbine
    server.port=8770
    security.basic.enabled=false
    # 指定聚合的集群,多个使用","分割,默认为default。可使用http://.../turbine.stream?cluster={clusterConfig之一}访问
    turbine.aggregator.cluster-config=default
    # 监控的Hystrix服务名列表
    turbine.app-config=service-ribbon
    # 1. clusterNameExpression指定集群名称,默认表达式appName;此时:turbine.aggregator.clusterConfig需要配置想要监控的应用名称
    # 2. 当clusterNameExpression: default时,turbine.aggregator.clusterConfig可以不写,因为默认就是default
    # 3. 当clusterNameExpression: metadata['cluster']时,假设想要监控的应用配置了eureka.instance.metadata-map.cluster: ABC,则需要配置,同时turbine.aggregator.clusterConfig: ABC
    turbine.cluster-name-expression=new String("default")
    eureka.client.service-url.defaultZone=http://localhost:8761/eureka/
  4. 进入 Dashboard 查看监控流
    比如http://localhost:8764/hystrix,输入监控流 http://localhost:8770/turbine.stream
    会发现监控的服务列表都呈现在了 Thread Pools 中

Sleuth - 服务链路追踪

概念

  1. Zipkin:Sleuth 集成了服务追踪组件 Zipkin,而 Sleuth 通过了通过 Spring Boot 来集成 Zipkin 到微服务系统的方案。
  2. Span:基本工作单元,比如发送一个 PRC 请求或 RPC 响应都是一个 Span,使用一个 64 位的 ID 表示(文档这里相当费解???),启动 Trace 的第一个 Span 称为 Root Span;
  3. Trace:一组 Span 可以构成一个树状结构;
  4. Annotation:用于标识事件,比如请求的开始、结束,有以下 Annotation 种类:
    • cs - Client Sent -客户端发起一个请求,这个 annotion 描述了这个 span 的开始
    • sr - Server Received -服务端获得请求并准备开始处理它,如果将其 sr 减去 cs 时间戳便可得到网络延迟
    • ss - Server Sent -注解表明请求处理的完成(当请求返回客户端),如果 ss 减去 sr 时间戳便可得到服务端需要的处理请求时间
    • cr - Client Received -表明 span 的结束,客户端成功接收到服务端的回复,如果 cr 减去 cs 时间戳便可得到客户端从服务端获取回复的所有所需时间

服务链路追踪

  1. 创建一个 Spring Initializr 项目
    一个 zipkin-server 勾选 Web、Zipkin Stream、Zipkin UI 和 Rabbit Stream,还有供跟踪的服务 zipkin-test1 和 zipkin-test2 勾选 Zipkin Client 和 Web,
  2. 加入注解
    在zipkin-server的Application上添加 @EnableZipkinServer
  3. 配置
    在 zipkin-server 中指定服务端口号即可:
    1
    server.port=9411
    在 zipkin-test1(zipkin-test2 类似)中需要指定 zipkin 服务器的地址
    1
    2
    3
    server.port=8988
    spring.zipkin.base-url=http://localhost:9411
    spring.application.name=service-hi
  4. 在两个 Service 之间使用 RestTemplate 进行互调
    第一个服务:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    private static final Logger LOG = Logger.getLogger(ZipkinTest1Application.class);
    @Autowired
    private RestTemplate restTemplate;
    @Bean
    public RestTemplate getRestTemplate() {
    return new RestTemplate();
    }

    @RequestMapping("/hi")
    public String callMiya(){
    LOG.log(Level.INFO, "calling trace service-hi ");
    return restTemplate.getForObject("http://localhost:8989/miya", String.class);
    }
    @RequestMapping("/info")
    public String info(){
    LOG.log(Level.INFO, "calling trace service-hi ");
    return "i'm service-hi";
    }
    另一个服务:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    private static final Logger LOG = Logger.getLogger(ZipkinTest2Application.class);
    @Autowired
    private RestTemplate restTemplate;
    @Bean
    public RestTemplate getRestTemplate() {
    return new RestTemplate();
    }

    @RequestMapping("/miya")
    public String miya() {
    LOG.log(Level.INFO, "info is being called");
    return restTemplate.getForObject("http://localhost:8988/info", String.class);
    }
  5. 为 Service 注入 Sampler 用于记录 Span
    1
    2
    3
    4
    @Bean
    public AlwaysSampler defaultSampler() {
    return new AlwaysSampler();
    }
    如果不注入 Sampler,会出现之后访问服务后,在 Zipkin 界面看不到服务依赖图的情况。
  6. 访问,并读取链路
    访问服务http://localhost:8988/hi
    现在访问http://localhost:9411/会出现 Zipkin 界面,可以查看服务间的相互依赖关系,和服务间调用的具体数据。

Docker部署

应用

  1. web 应用的自动化打包和发布;
  2. 自动化测试和持续集成、发布;
  3. 在服务型环境中部署和调整数据库或其他的后台应用;
  4. 从头编译或者扩展现有的 OpenShift 或 Cloud Foundry 平台来搭建自己的 PaaS 环境。

为 Spring Cloud 项目构建 Docker 镜像

  1. 添加依赖
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    <!--Spotify的docker镜像构建插件-->
    <plugin>
    <groupId>com.spotify</groupId>
    <artifactId>docker-maven-plugin</artifactId>
    <version>0.4.3</version>
    <configuration>
    <!--
    imageName:镜像名
    dockerDirectory:Dockerfile位置
    resources:指定需要和Dockerfile放在一起构建镜像的文件,包括jar包-->
    <imageName>${docker.image.prefix}/${project.artifactId}</imageName>
    <dockerDirectory>src/main/docker</dockerDirectory>
    <resources>
    <resource>
    <targetPath>/</targetPath>
    <directory>${project.build.directory}</directory>
    <include>${project.build.finalName}.jar</include>
    </resource>
    </resources>
    </configuration>
    </plugin>
  2. 配置
    这个是注册中心的配置:
    1
    2
    3
    4
    server.port=8761
    eureka.instance.prefer-ip-address=true
    eureka.client.register-with-eureka=false
    eureka.client.fetch-registry=false
    服务的配置比较简单(注意 defaultZone 改成了镜像名):
    1
    2
    3
    eureka.client.service-url.defaultZone=http://eureka-server:8761/eureka/ # 使用docker启动,defaultZone的host改为镜像名
    server.port=8762
    spring.application.name=service-name
  3. 在 src/main/docker 下创建 Dockerfile 文件
    注册中心:
    1
    2
    3
    4
    5
    6
    FROM frolvlad/alpine-oraclejdk8:slim # 指定image,可以是官方仓库或本地仓库
    VOLUME /tmp # 使容器中的一个目录具有持久化数据的功能
    ADD eureka-server-0.0.1-SNAPSHOT.jar app.jar # 从src目录复制文件到容器的dest
    # RUN bash -c 'touch /app.jar' # 容器启动时执行的命令,可以多次设置但只有最后一个有效
    ENTRYPOINT ["java", "-Djava.security.egd=file:/dev/./urandom", "-jar", "/app.jar"]
    EXPOSE 8761 # 暴露的端口号
    服务类似,只是要将 ADD 操作拷贝的镜像名和 EXPOSE 暴露的端口号改一改。
  4. 构建
    1
    2
    mvn clean
    mvn package docker:build
    这样运行可能会报错:没有权限,这时候可以切换到 root 用户运行,或将当前用户加入到 docker 组中。
  5. 运行
    注册中心:
    1
    2
    # --name指定容器名用于之后被其他容器连接,-p指定端口映射,第一个是主机端口,第二个是容器内的端口,这两个端口也可以实现为一个范围且这两个范围中的端口数必须相同,比如`-p 1234-1236:1222-1224`,还可以在端口前面指定监听的主机ip,-t指定分配一个虚拟终端,这样在该终端退出后这个服务还是在运行着的,最后的参数是docker镜像名
    docker run --name eureka-server -p 8761:8761 -t springboot/eureka-server
    服务:
    1
    2
    # --link连接到某个容器:端口上,它会在两个容器之间建立一个安全通道,且该容器的该端口必须是暴露的
    docker run --link eureka-server:8761 -p 8762:8762 -t springboot/eureka-client

使用 Docker-Compose 启动镜像

Compose 是一个用于定义和运行多容器的 Docker 应用的工具。使用 Compose,你可以在一个配置文件(yaml 格式)中配置你应用的服务,然后使用一个命令,即可创建并启动配置中引用的所有服务。

  1. 按上面的步骤构建 docker 镜像
  2. 在上面两个项目的父一级目录下创建配置文件 docker-compose.yml 配置文件
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    version: '2'
    services:
    eureka-server:
    image: springboot/eureka-server
    restart: always
    ports:
    - 8761:8761

    eureka-client:
    image: springboot/eureka-client
    restart: always
    ports:
    - 8762:8762
  3. 启动
    1
    docker-compose up

使用Docker-Compose构建并启动镜像

docker-compose 也可以用于构建镜像,这样就不用一个一个镜像构建再去运行了。

  1. 在 docker-compose.yml 同级目录下创建配置文件 docker-compose-dev.yml:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    version: '2'
    services:
    eureka-server:
    build: eureka-server
    ports:
    - 8761:8761

    eureka-client:
    build: eureka-client
    ports:
    - 8762:8762
  2. 构建并启动
    1
    docker-compose -f docker-compose.yml -f docker-compose-dev.yml up

参考

Eureka

  1. 服务发现:Eureka客户端

Ribbon

  1. Netflix/ribbon https://github.com/Netflix/ribbon/wiki
  2. 客户端负载平衡器:Ribbon https://springcloud.cc/spring-cloud-dalston.html#spring-cloud-ribbon

Feign

  1. 声明性 REST 客户端:Feign https://springcloud.cc/spring-cloud-dalston.html#spring-cloud-feign

Hystrix

  1. Netflix/Hystrix https://github.com/Netflix/Hystrix/wiki
  2. Circuit Breaker: Hystrix Clients https://springcloud.cc/spring-cloud-dalston.html#_circuit_breaker_hystrix_clients

Sleuth

  1. Zipkin https://zipkin.io/
  2. Spring Cloud Sleuth https://springcloud.cc/spring-cloud-dalston.html#_spring_cloud_sleuth
  3. Google Dapper-大规模分布式系统的基础跟踪设施 http://duanple.blog.163.com/blog/static/70971767201329113141336/
  4. http://research.google.com/pubs/pub36356.html
  5. http://bigbully.github.io/Dapper-translation/
  6. http://research.google.com/pubs/pub40378.html
  7. Eagleeye
  8. Sleuth 源码分析

Docker

  1. 用 Docker 构建、运行、发布一个 Spring Boot 应用
  2. docker-maven-plugin
  3. Docker Compose

降级有几种方案

降级分类

降级主要分手动降级和自动降级,手动降级其实就是在代码里加上一些开关把功能直接关闭,自动降级可以分为以下几种:

  1. 超时降级
    配置好超时时间和超时重试次数和机制,并使用异步机制探测恢复情况。
  2. 失败次数降级
    失败次数达到阈值自动降级,同样要使用异步机制探测回复情况。
  3. 故障降级
    如要调用的远程服务挂掉了(网络故障、DNS 故障、HTTP 服务返回错误的状态码和 RPC 服务抛出异常),则可以直接降级。
    和上面那种失败次数降级原理类似,只是需要区分错误类型。
  4. 限流降级
    单位时间内调用次数超过阈值,可以使用暂时屏蔽的方式来进行短暂的屏蔽。

降级处理方案

降级后,在代码层面,可以进行的处理策略如下:

  1. 抛异常
  2. 返回 NULL
  3. 调用 Mock 数据
  4. 调用 Fallback 处理逻辑

Hystrix 原理

Hystrix工作流程

4 种调用方法

  • toObservable()
    最基础的 API,直接返回 Observable,未作订阅,需要由用户来发起订阅;
  • observe()
    调用 #toObservable() 方法,并向 Observable 注册 rx.subjects.ReplaySubject 发起订阅,这会立刻触发 run()方法的执行。
  • queue()
    调用 #toObservable() 方法的基础上,调用:Observable#toBlocking() 和 BlockingObservable#toFuture() 返回 Future 对象,实现了异步调用
  • execute()
    调用 #queue() 方法的基础上,调用 Future#get() 方法,同步返回 #run() 的执行结果。

HystrixCommand.execute
-> HystrixCommand.queue
-> AbstractCommand.toObservable

AbstractCommand.observe
-> AbstractCommand.toObservable

隔离

限制调用分布式服务的资源使用,某一个调用的服务出现问题不会影响其他服务调用,包括线程池隔离信号量隔离
开启配置:
HystrixCommand(…, Setter.andCommandPropertiesDefaults(
HystrixCommandProperties.Setter().withExecutionIsolationStrategy(ExecutionIsolationStrategy.SEMAPHORE)), …)
-> withExecutionIsolationStrategy(ExecutionIsolationStrategy.SEMAPHORE) 配置隔离策略,线程池同理

实施信号量隔离:
AbstractCommand.toObservable
…中略
-> AbstractCommand.applyHystrixSemantics
-> TryableSemaphore.tryAcquire 如果没开启信号量模式,这个方法永远返回 true

实施线程隔离:
AbstractCommand.toObservable
…中略
-> AbstractCommand.applyHystrixSemantics
-> AbstractCommand.executeCommandAndObserve
-> AbstractCommand.executeCommandWithSpecifiedIsolation 创建一个新线程来执行
-> AbstractCommand.getUserExecutionObservable
-> HystrixCommand.getExecutionObservable

限流

Hystrix的限流限制的是并发量,其实和隔离特性的实现方式一样,可以通过调整信号量或线程池大小来实现限流。

熔断

熔断是一种异常处理机制,如果接口的失败率达到了阈值就触发降级,
当失败率达到阈值自动触发降级(如因网络故障/超时造成的失败率高),之后熔断器会尝试进行恢复。
Hystrix 会为降级的接口维持一个熔断状态,状态扭转如下图所示:
熔断状态扭转

  1. 熔断关闭状态(Closed)
    服务没有故障时,熔断器所处的状态,对调用方的调用不做任何限制。
  2. 熔断开启状态(Open)
    在固定时间窗口内(Hystrix 默认是 10 秒),接口调用出错比率达到一个阈值(Hystrix 默认为 50%),会进入熔断开启状态。进入熔断状态后,后续对该服务接口的调用不再经过网络,直接执行本地的 fallback 方法。
  3. 半熔断状态(Half-Open)
    在进入熔断开启状态一段时间之后(Hystrix 默认是 5 秒),熔断器会进入半熔断状态。所谓半熔断就是尝试恢复服务调用,允许有限的流量调用该服务,并监控调用成功率。如果成功率达到预期,则说明服务已恢复,进入熔断关闭状态;如果成功率仍旧很低,则重新进入熔断关闭状态。

Hystrix 使用 HystrixCircuitBreaker 这个类来维持状态:
AbstractCommand.applyHystrixSemantics
-> HystrixCircuitBreaker.attemptExecution 是否已开启熔断(OPEN),如果超过时间窗口则切换到半熔断状态(HALF_OPEN)
AbstractCommand.executeCommandAndObserve
-> HystrixCircuitBreaker.markSuccess / markNonSuccess

降级(Fallback)

降级是一种状态,当一个接口被降级后,再对这个接口进行调用会直接走降级逻辑。可以触发降级的条件一般是调用超时(比如网络故障、超时)或资源不足(线程或信号量)。但是代码中会更复杂一些,具体地说,触发降级的条件包括:

  1. run()方法抛出非 HystrixBadRequestException 异常
    AbstractCommand.executeCommandAndObserve
    -> handleFallback 是一个回调接口匿名实现类的对象,会在出现异常时被调用
    HystrixBadRequestException 表示一类可以被忽略的异常,在 AbstractHystrixCommand 和 GenericObservableCommand 可以抛出,可以在 HystrixCommand 注解上的 ignoreExceptions 中声明这些可以被忽略的异常。
  2. run()方法调用超时
    对超时的异常检测和上边的一样,但是抛出的位置不一样。
    AbstractCommand.executeCommandAndObserve
    -> Observable.lift 添加超时操作 HystrixObservableTimeoutOperator
    -> HystrixTimer.addTimerListener 添加一个 TimerListener 用于判断执行时间是否达到阈值,若超过则抛出 HystrixTimeoutException
    -> ScheduledThreadPoolExecutor.scheduleAtFixedRate 定时触发滴答(tick)
    可以看到超时检测最终还是通过 Scheduled 线程池来实现的。
  3. 熔断器开启拦截调用
    AbstractCommand.applyHystrixSemantics
    -> HystrixCircuitBreaker.attemptExecution
  4. 线程池/队列/信号量是否跑满
    在《隔离》那一节中已经说明了代码的位置。
  5. 没有实现 getFallback 的 Command 将直接抛出异常,fallback 降级逻辑调用成功直接返回,降级逻辑调用失败抛出异常
    HystrixCommand.getFallback 直接抛出了一个 UnsupportedOperationException 异常。

缓存

提供了请求缓存、请求合并实现。支持实时监控、报警、控制(修改配置)
结果缓存这个功能我觉得应该由幂等框架来做。

参考

  1. 漫画:什么是服务熔断?
  2. Hystrix 使用入门手册(中文)
  3. 从源码看 hystrix 的工作原理
  4. hystrix 适用场景
  5. Github - star2478/java-hystrix
  6. Netflix / Hystrix

Java 代码编译机制

JVM 规范中定义了 class 文件的格式,但并未定义 Java 源码如何被编译为 class 文件,各厂商在实现 JDK 时通常会将符合 Java 语言规范的源码编译为 class 文件的编译器,例如在 Sun JDK 中就是 javac,javac 将 Java 源码编译为 class 文件的步骤如下图所示:
Java代码编译机制

分析和输入到符号表(Parse and Enter)

Parse 过程所做的为此法和语法分析。此法分析(com.sun.tools.javac.parser.Scanner)要完成的是将代码字符串转变为 token 序列(例如 Token.EQ(name:=));语法分析(com.sun.tools.javac.parser.Parser)要完成的是根据语法由 token 序列生成抽象语法树。
Enter(com.sun.tools.javac.comp.Enter)过程为将符号输入到符号表,通常包括确定类的超类型和接口、根据需要添加默认构造器、将类中出现的符号输入类自身的符号表中等。

注解处理(Annotation Processing)

该步骤主要用于处理用户自定义的 annotation,可能带来的好处是基于 annotation 来生成附加的代码或进行一些特殊的检查,从而节省一些共用的代码的编写,例如当采用 Lombok 时,可编写如下代码:

1
2
3
public class User {
private @Getter String username;
}

编译时引入 Lombok 对 User.java 进行编译后,再通过 javap 查看 class 文件可看到自动生成了 public String getUsername()方法。
此功能基于 JSR 269,在 Sun JDK 6 中提供了支持,在 Annotation Processing 进行后,再次进入 Parse and Enter 步骤。

语义分析和生成 class 文件(Analyse and Generate)

Analyse 步骤基于抽象语法树进行一系列的语义分析,包括:

  • 将语法树中的名字、表达式等元素与变量、方法、类型等联系到一起;
  • 检查变量使用前是否已声明;
  • 推导泛型方法的类型参数;
  • 检查类型匹配性;
  • 进行常量折叠;
  • 检查所有语句都可到达;
  • 检查所有 checked exception 都被捕获或抛出;
  • 检查变量的确定性赋值(例如有返回值的方法必须确定有返回值);
  • 检查变量的确定性不重复赋值(例如声明为 final 的变量等);
  • 解除语法糖(消除 if(false) { … }形式的无用代码;将泛型 Java 转为普通 Java;将含有语法糖的语法树改为含有简单语言结构的语法树,例如 foreach 循环、自动装箱/拆箱等);
  • 等…

在完成了语义分析后,开始生成 class 文件(com.sun.tools.javac.jvm.Gen),生成的步骤为:

  • 首先将实例成员初始化器收集到构造器中,将静态成员初始化器收集为();
  • 接着将抽象语法树生成字节码,采用的方法为后序遍历语法树,并进行最后的少量代码转换(例如 String 相加转变为 StringBuilder 操作);
  • 最后从符号表生成 class 文件。

参考

  1. 深入浅出 JIT 编译器

搭建源码调试环境

源码下载地址:unofficial-openjdk/openjdk

  1. OpenJDK9 Hotspot Ubuntu 编译和调试
    mac 环境下搭建:mac 下编译 openjdk1.9 及集成 clion 动态调试

遇到的问题

  1. g++和 gcc 版本不匹配
  2. 一些 Warning 被当作错误报错了
    大部分 Warning 不会影响最终结果,可以关掉:https://blog.csdn.net/desiyonan/article/details/80802066

阅读

  1. 如何找 JDK 中 native 代码的位置?
  2. OpenJDK 源码阅读导航

编译 openjdk

  1. 下载
    代码的下载见 building 文档,使用一个 Mercurial 工具进行源码控制。
  2. 准备工具
    找到这种开放源码的软件,第一步当然是查看 README 了,openjdk 的 README 中告知了 building 文档的位置,相当详细。
    硬件要求
    操作系统要求
    编译器要求,需要 gcc 和 clang,他们的区别见Clang 比 GCC 好在哪里?
    JDK 要求,虽然挺矛盾的,但是一部分模块是用 JDK 写的,所以 Java 编译器也是需要的,一般编译 JDK8 时需要的 JDK 版本是 7 就够了,但是 openjdk 需要 8 才行(不知道为什么),这个 jdk 可以直接使用 apt-get 安装。
    外部库要求,
    其他工具,make、bash、autoconf
  3. configure
    准备所有配置文件
  4. make
    编译源码,默认下只会编译一些必要的部分,如果编译成功可以在 openjdk/build 文件夹下找到目标文件,我的成品所在目录是${openjdk}/build/linux-x86_64-normal-server-release/jdk/bin
  5. run test
    openjdk 使用了一个叫 jtreg 的测试框架,文档有这里还有这里,我设置了一个愚蠢的环境变量 JAVA18HOME 然后尝试着去make -C make编译它,但是还是提示缺少什么org.testing,可能还有什么依赖库没装,这个框架可能是给 oracle 的内部员工或者外星人用的吧?

定位 Object 的 native 方法

  1. 搜索“Object”打开 jdk 下的 Object.c 文件
    看到下面这个方法数组:
    1
    2
    3
    4
    5
    6
    7
    static JNINativeMethod methods[] = {
    {"hashCode", "()I", (void *)&JVM_IHashCode},
    {"wait", "(J)V", (void *)&JVM_MonitorWait},
    {"notify", "()V", (void *)&JVM_MonitorNotify},
    {"notifyAll", "()V", (void *)&JVM_MonitorNotifyAll},
    {"clone", "()Ljava/lang/Object;", (void *)&JVM_Clone},
    };
  2. 然后搜索“JVM_MonitorWait”打开 hotspot 下的 jvm.cpp 文件
    里面有下面这条语句:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    JVM_ENTRY(void, JVM_MonitorWait(JNIEnv* env, jobject handle, jlong ms))
    JVMWrapper("JVM_MonitorWait");
    Handle obj(THREAD, JNIHandles::resolve_non_null(handle));
    JavaThreadInObjectWaitState jtiows(thread, ms != 0);
    if (JvmtiExport::should_post_monitor_wait()) {
    JvmtiExport::post_monitor_wait((JavaThread *)THREAD, (oop)obj(), ms);

    // The current thread already owns the monitor and it has not yet
    // been added to the wait queue so the current thread cannot be
    // made the successor. This means that the JVMTI_EVENT_MONITOR_WAIT
    // event handler cannot accidentally consume an unpark() meant for
    // the ParkEvent associated with this ObjectMonitor.
    }
    ObjectSynchronizer::wait(obj, ms, CHECK);
    JVM_END
    我们暂时不知道这几个宏到底什么意思,不过核心大概是最后的那个ObjectSynchronizer::wait吧,这个类定义在 C++文件 synchronizer.cpp 中。
  3. 综上所述
    可以看到如果一个类定义了 native 方法,那么基本可以在 jdk 目录下找到该类的定义(类所在包结构和其在 openjdk 中的定义的路径有什么关联???),至于其实现,从知乎上可以找到一段说明,我不知道对错:

    如果间接调用了 hotspot 的实现(jvm 会以动态库的形式被加载,prims 文件夹里定义了 hotspot 与其他模块的接口及其实现),那么从 hotspot/src/share/vm/prims/jvm.cpp 中可以找到 JVM_Xxx 函数的实现。

为 Object 类添加 native 方法 nop()

根据这里的说法,openjdk 有两种方式来定义 native 函数,一种是按一种特别的方式命名函数,这样加载后就可以和 Java 中的 native 方法对上,另一种是使用 registerNatives()的方式,观察 Object.c 文件可以发现,这种方法先定义了一个 JNINativeMethod 类型的静态数组来做 Java 方法和本地函数的对应,然后使用 JNI 方法注册了一个叫 Java_java_lang_Object_registerNatives 的本地方法来实施这种映射。
当使用第一种方式在 Object.c 中定义一个特殊命名的 nop()函数时,编译不通过,报错显示Incompatible definition of java.lang.Object,我定义的 nop 函数:

1
2
3
4
JNIEXPORT void JNICALL
Java_java_lang_Object_nop(JNIEnv *env, jobject this)
{
}

然后下面是我使用第二种方式定义 nop()函数的过程(虽然也不行):

  1. 修改 Object.c
    methods 数组中添加一行:

    1
    "nop", "()V", (void*)&

    这种定义方法参照了这里

  2. 修改 Object.java
    其所在目录为${openjdk}/jdk/src/java.base/share/classes/java/lang/Object.java,为其添加一个 nop()方法:

    1
    2
    3
    4
    5
    /**
    *
    */
    @HotSpotIntrinsicCandidate
    public native void nop();

    为什么要加注释?为什么不能加 final?

  3. 重编译 make
    虽然只是添加了一个方法,但是几乎所有包下的都要重新编译了,所以编译过程会稍微有点长。

  4. 测试
    使用javap命令反编译可以看到 Object 类中确实已经有了一个 nop()方法:

    1
    javap ${openjdk}/build/linux-x86_64-normal-server-release/jdk/modules/java.base/java/lang/Object.class

    但是实际编译时却报错了,错误大概是Object类不兼容???

  5. native 方法的实现机制
    参见 JNI 原理。

JNI

生成.h头文件

使用 javah 命令,在项目的 src 目录下运行,其中-d 指定目标目录,-jni 是默认选项,生成 jni 头文件,可以加-classpath 指定 class 路径,但必须是绝对路径

1
javah -d ../jni -jni com.tallate.HelloJNI

接下来编译.c 文件,-c 可以生成目标文件(未链接),-I 可以指定#include 查找的目录,不然 jni 有些头文件找不到

1
gcc -c -I "/usr/lib/jvm/java-8-openjdk-amd64/include" -I "/usr/lib/jvm/java-8-openjdk-amd64/include/linux"  HelloJNI.c 

接下来编译成一个.so 链接库

1
gcc HelloJNI.c -I "/usr/lib/jvm/java-8-openjdk-amd64/include" -I "/usr/lib/jvm/java-8-openjdk-amd64/include/linux" -fPIC -shared -o libhello.so

接下来可以在Java中加载

1
2
// 在linux下动态库必须有前缀lib
System.loadLibrary("hello");

调用 native 方法的流程

  1. native 方法编译后多一个 ACC_NATIVE 标志
  2. loadLibrary 方法调用 JVM 的 load 本地方法
  3. load 方法最终调用系统调用 dlopen 加载动态链接库
  4. 调用本地方法时,实际上调用了 JVM 中加载的函数,栈帧压入本地方法栈中(HotSpot 中本地方法栈=虚拟机栈)

修改 Object 类,为其添加一个 native 方法 nop()

锁(内置锁)

  1. 锁的存储结构?
  2. 获取锁的时机?
  3. 释放锁的时机?

线程

和 JDK 的线程操作相关的类有 Object 和 Thread,Object 中包括 wait、notify 和 notifyAll,Thread 中包括 yield、sleep、exit、interrupt、join、start,run 的主要任务是执行用户定义的逻辑,还有一些方法已经被 Deprecated,不必再提

源码中和线程操作相关的一些数据结构

  1. ObjectMonitor
    对象监视器,定义和声明在 objectMonitor.*中,主要包含

  2. ObjectWaiter

  3. ObjectSynchronizer

wait

wait 是 native 方法,实现在 synchronizer.cpp 中,ObjectSynchronizer::wait:

  1. 通过ObjectSynchronizer::inflate方法找到 object 中的ObjectMonitor monitor

  2. 调用 monitor.wait
    wait 方法比较复杂,但是核心过程只有 3 步:

    • ObjectMonitor::AddWaiter(..)将新建立的 ObjectWaiter 对象插入_WaitSet 队列的末尾
    • ObjectMonitor::exit(..)释放锁
    • Self->_ParkEvent->park等待

参考

JNI

  1. 介绍
  2. 生成.so 方法
  3. 修改 java.library.path 方法
  4. Java JNI 实现原理初探
  5. Java Native Interface (JNI) 工作原理
  6. dlopen

线程

  1. java sleep 和 wait 的区别的疑惑?
  2. java 中的 wait 和 notify 实现的源码分析
  3. JVM Thread stop 的源码分析

JVM结构
JVM 特指用于运行由 Java 虚拟机规范规定的字节码的运行时系统,它具有以下特征:

  • 平台无关性
    JVM 本身是平台相关的,只是对不同平台都有实现,所以我们编译成的字节码才可以在各个平台上执行,因此称 Java 平台无关指的是字节码是平台无关的。
  • 高度抽象
    JVM 提供了编译器、类加载、动态内存管理、线程等一套工具,作为虚拟机,程序经编译后运行在 JVM 上时,几乎完全不必担心平台的兼容性。
    本地方法接口只是作为一个扩展功能提供,并不是必须的,实际上除了对性能严格要求的场合,本地方法一般不会被使用。

    编译器严格来说和虚拟机并无太大关联,JVM 可以供 Groovy、Kotlin、Scala 等一系列语言作为运行时环境,

下图表示 JVM 的系统结构,包括与 Class 文件打交道的类装载系统、进行运行时数据管理的系统、进行方法执行的系统、本地方法接口(JNI)和本地方法库这些部分。

类装载子系统

类装载子系统负责查找并装载类型,类装载器主要包含两种:启动类装载器(Java 虚拟机实现的一部分)用户自定义类装载器(Java 程序的一部分)

启动类装载器 – Java 虚拟机必须有一个启动类装载器,用于装载受信任的类,如 Java API 的 class 文件。
用户自定义类装载器 – 继承自 ClassLoader 类,ClassLoader 的如下四个方法,是通往 Java 虚拟机的通道。

  • protected final Class defineClass(String name, byte data[], int offset, int length); // data 为二进制的 Java Class 文件数据,表示一个新的可用类型,之后把这个类型导入到方法区中
  • protected final Class defineClass(String name, byte data[], int offset, int length, ProtectionDomain protectionDomain);
  • protected final Class findSystemClass(String name); // 参数为全限定名,通过类装载器进行装载
  • protected final void resolveClass(Class c); // 参数为 Class 实例,完成连接初始化操作

类装载子系统负责定位和导入二进制 class 文件,并且保证导入类的正确性,为类变量分配并初始化内存,以及帮助解析符号引用。类装载器必须严格按照如下顺序进行类的装载。

  1. 装载 – 查找并装载类型的二进制数据
  2. 连接 – 执行验证,准备,以及解析(可选),连接分为如下三个步骤
    验证 – 确保被导入类型的正确性
    准备 – 为类变量(static)分配内存,并将其初始化为默认值
    解析 – 把类型中的符号引用转换为直接引用
  3. 初始化 – 把类变量初始化为正确初始值

内存管理系统

方法区

方法区是线程共享的内存区域,用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据,当方法区无法满足内存分配需求时,将抛出 OutOfMemoryError。
类信息包括:

  1. 类型全限定名。
  2. 类型的直接超类的全限定名(除非这个类型是 java.lang.Object,它没有超类)。
  3. 类型是类类型还是接口类型。
  4. 类型的访问修饰符(public、abstract、final 的某个子集)。
  5. 任何直接超接口的全限定名的有序列表。
  6. 类型的常量池。
  7. 字段信息。
  8. 方法信息。
  9. 除了常量以外的所有类(静态)变量。
  10. 一个到类 ClassLoader 的引用。
  11. 一个到 Class 类的引用。

着重介绍常量池,虚拟机必须要为每个被装载的类型维护一个常量池。常量池就是该类型所用常量的一个有序集合,包括直接常量和对其他类型、字段和方法的符号引用。它在 Java 程序的动态连接(运行时解析字节码中使用到的符号引用)中起着核心作用。
除了以上结构外,JVM 的实现者还可以添加一些其他的数据结构,如方法表:对每个加载的非抽象类的类信息中都添加一个方法表,方法表是一组对类实例方法的直接引用——包括从父类继承的方法,JVM 可以通过方法表快速找到实例方法。

C++中 virtual 函数是通过虚函数表实现的,Java 中所有方法都是 virtual 的,因此也就不需要再标注虚拟了,正像 Java 虽然宣称没有指针,但是 Java 里其实全是指针。Java 中类似的检查机制不少,这体现了 Java 的一种设计思想:始终将安全放在效率之上。

元空间

JDK1.8 的一大改动是方法区变成了元空间,其实就是放开了类空间的大小,容量取决于是 32 位或是 64 位操作系统的可用虚拟内存大小,32 位系统本地最多有 4G 虚拟内存空间,物理内存取决于操作系统的配置。新参数MaxMetaspaceSize用于限制本地内存分配给类元数据的大小,如果没有指定这个参数,元空间会在运行时根据需要动态调整,大小随意。对于没用的类及类加载器的垃圾回收将在元数据使用达到 MaxMetaspaceSize 参数的设定值时进行。

一个虚拟机实例只对应一个堆空间,堆是线程共享的,当然还有可能会划分出多个线程私有的分配缓冲区(TLAB)。堆空间是存放对象实例的地方,几乎所有对象实例都在这里分配。堆也是垃圾收集器管理的主要区域,因此也被称为“GC 堆”。
堆可以处于物理上不连续的内存空间中,只要逻辑上连续即可,就像磁盘空间一样。
当堆中没有足够的内存进行对象实例分配时,并且堆也无法扩展时,会抛出 OutOfMemoryError 异常。
对 OOM 的排查通常是先通过内存映像分析工具对 Dump 出来的堆转储快照进行分析,重点是确认内存中的对象是否是必要的,也就是要先分清楚到底是出现了内存泄漏还是内存溢出

  • 如果是内存泄漏,可进一步通过工具查看泄露对象到 GC Roots 的引用链。于是就能找到泄露对象的类型信息及 GC Roots 引用链的信息,就可以比较准确地定位出泄露代码的位置。
  • 如果不存在泄露,就是内存中的对象确实都还必须存活着,那就应当检查虚拟机的堆参数(-Xmx 与 -Xms),与机器物理内存对比看是否还可以调大,从代码上检查是否存在某些对象生命周期过长、持有状态时间过长的情况,尝试减少程序运行期的内存消耗。

程序计数器

每个线程拥有自己的程序计数器,线程之间的程序计数器互不影响,在任何一个确定的时刻,一个处理器(对于多核处理器来说是一个内核)都只会执行一条线程中的指令。
PC 寄存器的内容总是下一条将被执行指令的”地址”,这里的”地址”可以是一个本地指针,也可以是在方法字节码中相对于该方法起始指令的偏移量。

  • 如果该线程正在执行一个本地方法,则程序计数器内容为 undefined。
  • 此区域在 Java 虚拟机规范中没有规定任何 OutOfMemoryError 情况的区域。

虚拟机栈

我们一般会把 Java 内存区分为堆内存和栈内存,而所指的“栈”就是这里的虚拟机栈,或者说是虚拟机栈中局部变量表部分。
Java 栈也是线程私有的,虚拟机只会对栈进行两种操作,以帧为单位的入栈出栈。每个方法在执行时都会创建一个栈帧,并入栈,成为当前帧,每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。栈帧由三部分组成:局部变量区、操作数栈、帧数据区。
局部变量区被组织为一个以字长为单位、从 0 开始计数的数组。字节码指令通过从 0 开始的索引来使用其中的数据。类型为 int、float、reference 和 returnAddress 的值在数组中只占据一项,而类型为 byte、short 和 char 的值存入时都会转化为 int 类型,也占一项,而 long、double 则连续占据两项。
操作数栈也常被称为操作栈,它是一个后入先出栈。当一个方法刚刚执行的时候,这个方法的操作数栈是空的,在方法执行的过程中,会有各种字节码指令向操作数栈中写入和提取值,也就是入栈与出栈操作。在使用操作数栈时需要注意实例方法和类方法:

1
2
3
4
5
6
7
8
9
10
public class Example {
// 类方法
public static int classMethod(int i, long l, float f, double d, Object o, byte b) {
return 0;
}
// 实例方法
public int instanceMethod(char c, double d, short s, boolean b) {
return 0;
}
}

类方法和实例方法栈帧结构有所不同,从图中可以看到它们之间的区别:

  • 类方法帧里没有隐含的 this 引用,而实例方法帧中会隐含一个 this 引用;

并且注意:

  • byte,char,short,boolean 类型存入局部变量区的时候都会被转化成 int 类型值,当被存回堆或者方法区时,才会转化回原来的类型;
  • returnAddress 类型数据是指向字节码的指针(方法区的字节码指令),它只存在于字节码层面,与编程语言无关,我们在 Java 语言中是不会直接与 returnAddress 类型数据打交道的。

    每个线程所持有的程序计数器(pc)实际上就是 returnAddress 类型的数据,当线程执行 native 方法时,pc 中的值为 undefied。

  • 操作数栈被组织成一个以字长为单位的数组,它是通过标准的栈操作——即入栈和出栈来进行访问,而不是通过索引访问。入栈和出栈也会存在类型的转化;
  • 当一个方法执行完毕之后,要返回之前调用它的地方,因此在栈帧中必须保存一个方法返回地址。方法退出的过程实际上等同于把当前栈帧出栈,因此退出时可能执行的操作有:恢复上层方法的局部变量表和操作数栈,把返回值(如果有的话)压入调用都栈帧的操作数栈中,调用 PC 计数器的值以指向方法调用指令后面的一条指令等。
  • 每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接。在 Class 文件的常量池中存有大量的符号引用,字节码中的方法调用指令就以常量池中指向方法的符号引用为参数。这些符号引用一部分会在类加载阶段或第一次使用的时候转化为直接引用,这种转化称为静态解析。另外一部分将在每一次的运行期期间转化为直接引用,这部分称为动态连接。

栈数据区存放一些用于支持常量池解析、正常方法返回以及异常派发机制的信息。即将常量池的符号引用转化为直接地址引用、恢复发起调用的方法的帧进行正常返回,发生异常时转交异常表进行处理。

  • 虚拟机规范允许具体的虚拟机实现增加一些规范里没有描述的信息到栈帧中,例如与高度相关的信息,这部分信息完全取决于具体的虚拟机实现。在实际开发中,一般会把动态连接,方法返回地址与其它附加信息全部归为一类,称为栈帧信息

在 Java 虚拟机规范中,规定了两种异常状况:如果线程请求的栈深度大于虚拟机所允许的深度,将抛出 StackOverflowError 异常;如果虚拟机栈可以动态扩展,当扩展时无法申请到足够的内存,就会抛出 OutOfMemoryError 异常。

系统分配给每个进程的内存是有限制的,除去 Java 堆、方法区、程序计数器,如果虚拟机进程本身耗费的内存不计算在内,剩下内存就由虚拟机栈和本地方法栈“瓜分”了。每个线程分配到的栈容量越大,可以建立的线程数量自然就越少,建立线程时就越容易把剩下的内存耗尽,出现虚拟机栈溢出的情况。

  • 如果线程请求的栈深度大于虚拟机所允许的最大深度,将抛出 StackOverflowError 异常,出现 StackOverflowError 异常时有错误栈可以阅读,栈深度在大多数情况下达到 1000~2000 完全没有问题,对于正常的方法调用(包括递归),这个深度在一半情况下完全够用。
  • 如果虚拟机在扩展栈(建立更多的线程)时无法申请到足够的内存空间,则抛出 OutOfMemoryError 异常,在不能减少线程数或者更换 64 位虚拟机的情况下,就只能通过减少最大堆和减少栈容量来换取更多的线程。

一般可以认为 Java 中的一切变量都是引用,实际的对象存储在堆中,但是基础类型比较特殊,它们可以直接使用操作符进行算数运算,不过基础类型也带来了一些问题,比如装箱拆箱过程中可能产生空指针异常。Java 中引入基础类型的动机应该是性能与空间利用率,毕竟使用引用时还得通过指针碰撞空闲列表方法(根据垃圾收集器不同而不同)来找到实际对象,而且基础类型本身占用的空间不多,如果用引用势必占用的空间会加倍。
基础类型并不是都分配在栈上,具体有以下几种情况:

  1. 在方法中声明的变量,即该变量是局部变量,每当程序调用方法时,系统都会为该方法建立一个方法栈,其所在方法中声明的变量就放在方法栈中,当方法结束系统会释放方法栈,其对应在该方法中声明的变量随着栈的销毁而结束,这就局部变量只能在方法中有效的原因
    在方法中声明的变量可以是基本类型的变量,也可以是引用类型的变量。
    • 当声明是基本类型的变量的时,其变量名及值(变量名及值是两个概念)是放在方法栈中
    • 当声明的是引用变量时,所声明的变量(该变量实际上是在方法中存储的是内存地址值)是放在方法的栈中,该变量所指向的对象是放在堆类存中的。
  2. 在类中声明的变量是成员变量,也叫全局变量,放在堆中的(因为全局变量不会随着某个方法执行结束而销毁)。
    同样在类中声明的变量即可是基本类型的变量 也可是引用类型的变量
    • 当声明的是基本类型的变量其变量名及值是放在堆中的
    • 引用类型时,其声明的变量仍然会存储一个内存地址值,该内存地址值指向所引用的对象。引用变量名和对应的对象仍然存储在相应的堆中

堆外内存

一个例子

1
2
3
4
5
// 分配一块1024字节的堆外内存
ByteBuffer buffer = ByteBuffer.allocateDirect(1024);
System.out.println(buffer.capacity());
buffer.putInt(0, 2018);
System.out.println(buffer.getInt(0));

为什么要使用堆外内存

主要是因为堆外内存在 IO 操作方面的优势,举一个例子:在通信中,将存在于堆内存中的数据 flush 到远程时,需要首先将堆内存中的数据拷贝到堆外内存中,然后再写入 Socket 中;如果直接将数据存到堆外内存中就可以避免上述拷贝操作,提升性能。类似的例子还有读写文件。
但是需要注意的是,直接访问堆外内存并不会比访问 Java 堆更快:哪个更快:Java 堆还是本地内存。堆外内存更适合用于操作大块的数据(>2G)、可以直接使用操作系统提供的本地 IO 进行操作。

可用的堆外内存额度

  1. 可以通过设定 -XX:MaxDirectMemorySize 来显式指定最大的堆外内存。
  2. 设定 -Dsun.nio.MaxDirectMemorySize 来显式指定:如果该属性为-1,则取directMemory = Runtime.getRuntime().maxMemory(),即 JVM 运行时的最大内存;否则,如果指定这个属性等于-1,则默认为 64M。
    如果是 JDK8,具体代码见VM.saveAndRemoveProperties(),如果是 JDK8 之前,则代码见VM.maxDirectMemory()
    另外,Runtime.getRuntime().maxMemory()是一个 native 方法,HotSpot 中对应的 C++代码如下所示:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    JNIEXPORT jlong JNICALL
    Java_java_lang_Runtime_maxMemory(JNIEnv *env, jobject this)
    {
    return JVM_MaxMemory();
    }

    JVM_ENTRY_NO_ENV(jlong, JVM_MaxMemory(void))
    JVMWrapper("JVM_MaxMemory");
    size_t n = Universe::heap()->max_capacity();
    return convert_size_t_to_jlong(n);
    JVM_END
    其中 max_capacity() 实际返回的是 –Xmx 设置值减去一个 survivor space 的预留区大小,与堆内大小存很接近。

源码

  1. 分配
    ByteBuffer.allocateDirect
    DirectByteBuffer(int)
    -> Bits.reserveMemory 在系统中保存总分配内存(按页分配)的大小和实际内存的大小
    -> Bits.tryReserveMemory 判断系统内存(堆外内存)是否足够,Bits 有一个全局 totalCapacity 变量记录当前已经使用的堆外内存量
    -> JavaLangRefAccess.tryHandlePendingReference 非阻塞,将已经被 JVM 垃圾回收的 DirectBuffer 对象的堆外内存释放。
    -> System.gc 触发一次 FullGC,System.gc并不能保证 FullGC 被马上执行,如果设置了-XX:+DisableExplicitGC则这种显式的 GC 会被禁用
    -> Bits.tryReserveMemory 为了等待 FullGC 释放足够的空间,之后还会重试最多 9 次 tryReserveMemory,每次重试会 sleep 1, 2, 4, 8, 16, 32, 64, 128, 256ms,也就是说最多可以等 0.5s
    -> throw new OutOfMemoryError 分配空间失败,抛出 OOM 异常
    -> Unsafe.allocateMemory native 方法,通过 JNI 调用 C++函数分配堆外内存,并返回内存基地址
    -> Unsafe.setMemory(base, size, (byte) 0) 将分配的内存清零
    -> Cleaner.create 创建一个 Cleaner 用于释放堆空间
  2. 释放
    Cleaner.create Cleaner 内部维护了一个 static 的 Cleaner 对象的链表,create 调用创建了一个 Cleaner 对象并将其加入到该链表中
    -> new Cleaner(DirectByteBuffer, new Deallocator(base, size, cap))

Cleaner 实现 GC 回收堆外内存的原理是PhantomReference(虚引用),虚引用必须和引用队列(ReferenceQueue)一起使用,一般用于实现追踪垃圾收集器的回收动作。虚引用不会影响 JVM 是否要 GC 这个对象的判断,当 GC 某个对象时,如果有此对象上还有虚引用,会将虚引用对象插入 ReferenceQueue 队列。
对于 Cleaner 对象,当 GC 时发现它除了虚引用外已不可达(持有它的 DirectByteBuffer 对象在 GC 中被回收了,此时,只有 Cleaner 对象唯一保存了堆外内存的数据),就会把它放进 Reference 类的 pending 静态变量里。
PhantomReference 的父类是 Reference,Reference 类内部的 static 块会启动 ReferenceHandler 线程,线程优先级很高,这个线程是用来处理 JVM 在 GC 过程中交接过来的 reference(pending)。
下面是该线程的大致执行流程:
ReferenceHandler.run() 死循环处理 JVM 提交的 reference,如果是 Clearner 则调用其 clean 方法
-> Cleaner.clean
-> Cleaner.remove(Cleaner) 首先将 Cleaner 从链表中移除,以便 Cleaner 自身可被 GC 回收掉
-> DirectByteBuffer.Deallocator.run()
-> Unsafe.freeMemory 释放分配的堆外内存
-> Bits.unreserveMemory 更新 Bits.totalCapacity

手动释放堆外内存

如前面所述,可以通过编码调用 DirectByteBuffer 的 cleaner 的 clean 方法来释放堆外内存。但需要注意:cleaner 是 private 访问权限,所以,需使用反射来实现。

MappedByteBuffer

MappedByteBuffer 是用来访问大文件的,其实是利用操作系统的 mapedfile 的机制,把一个大文件映射到物理内存,然后用户进程像访问内存一样访问文件,背后操作系统会把磁盘文件映射到内存中来,这是操作系统预估你想存取哪块提前为你准备好的。

本地方法栈

访问本地方式时使用到的栈,为本地方法服务,与虚拟机栈一样,本地方法区域也会抛出 StackOverflowError 和 OutOfMemoryError 异常。

方法执行引擎

用户所编写的程序如何表现正确的行为需要执行引擎的支持,执行引擎执行字节码指令,完成程序的功能。

本地方法接口(JNI)

本地方法接口称为 JNI,是为可移植性准备的。

参考

  1. 《Hotspot 实战》
  2. GC 性能优化
  3. JVM 菜鸟进阶高手之路
  4. The Java® Virtual Machine Specification
  5. The Java® Language Specification
  6. Java 和操作系统交互细节

了解 JVM 中内存管理的原理后,下一步就是如何调优了。

JVM参数

-D、-X、-XX 区别

其一是标准参数(-),所有的 JVM 实现都必须实现这些参数的功能,而且向后兼容;(包括-D 等)JVM 的标准参数都是以”-“开头,通过输入”java -help”或者”java -?”,可以查看 JVM 标准参数列表
其二是非标准参数(-X),默认 jvm 实现这些参数的功能,但是并不保证所有 jvm 实现都满足,且不保证向后兼容;但是列出来的都是当前可用的。
其三是非 Stable 参数(-XX),此类参数各个 jvm 实现会有所不同,将来可能会随时取消,需要慎重使用;

栈大小

  • Xss512k:用来设置每个线程的堆栈大小;

堆空间结构调整

堆空间结构调整

  • Xmx4g:JVM 最大允许分配的堆内存,按需分配;
  • Xms4g:JVM 初始分配的堆内存,一般和 Xmx 配置成一样以避免每次 gc 后 JVM 重新分配内存;
  • XX:MetaspaceSize=64m 初始化元空间大小;
  • XX:MaxMetaspaceSize=128m 最大化元空间大小。

Metaspace 建议不要设置,一般让 JVM 自己启动的时候动态扩容就好了,没必要自己去设置。如果不动态加载 class ,当启动起来的时候,一般是很少有变化的。
从这个角度我们可以认为我们的 JVM 内存的大小是堆+Metaspace+io(运行时产生的大小)。

  • -Xms[g|m|k]
  • -Xmx[g|m|k] 堆大小
  • -Xmn[g|m|k] 年轻代大小
  • -XX:NewRatio=老年代/新生代 比例
  • -XX:MaxMetaspaceSize=[unit] 元空间
  • -XX:NewSize=[unit] 初始年轻代大小
  • -XX:SurvivorRatio= # 代表分代回收算法新生代中 Eden:Survivor 的比例,注意 Survivor 是有两块的,如果 Eden:Survivor = 3,则新生代将被分割为 5 份,Eden 占 3 份

堆的最大和最小限制,网上很多资料都将二者设置成一样的值,我觉得这是不好的习惯,因为 JVM 只有在内存使用量达到-Xms 的值时才会开始 gc,设置成一样的值也就是说只有在 JVM 使用完内存后才会开始 gc,这会导致最大暂停时间偏长,用户体验不好,当然设置成一样也可以减轻堆伸缩带来的压力。当然这些都是直观的看法,根据 [3] 的说法,这个调整策略和 JVM 中使用的垃圾回收算法相关,如果是 IBM JVM(采用 sweep-compact),设置不一样较好;如果是 Sun JVM(采用分代回收),设置成一样较好。

选择哪个 GC 收集器

  • -XX:+UseSerialGC
  • -XX:+UseParallelGC
  • -XX:+USeParNewGC
  • -XX:+UseG1GC
  • -XX:-UseConcMarkSweepGC
    对老生代采用并发标记交换算法进行 GC

GC 的时候生成文件

  • -XX:+UseGCLogFileRotation 用文件循环策略
  • -XX:NumberOfGCLogFiles=10 最大文件数
  • -XX:GCLogFileSize=50M 文件大小
  • -Xloggc:/home/user/log/gc.log 位置

OOM后排错

  • -XX:+HeapDumpOnOutOfMemoryError 打出 dump 文件当发生 out of memory
  • -XX:HeapDumpPath=./java_pid.hprof 文件名
  • -XX:OnOutOfMemoryError=”< cmd args >;< cmd args >” 发生异常的时候执行的命令
  • -XX:+UseGCOverheadLimit 在 GC 之前

Java Management Extensions

Java 管理扩展,打开 JMX 端口,就可以用标准的端口来监控 server 端的 jvm 了。
-Djava.rmi.server.hostname=
-Dcom.sun.management.jmxremote.port=
-Dcom.sun.management.jmxremote.authenticate=false
-Dcom.sun.management.jmxremote.ssl=false

-XX:+PrintFlagsFinal
-XX:+PrintFlagsInitial

生产环境参数设置

  1. 实际生产中很少去设置 gc 相关的详细参数,一般只要把 thread dump 处理好(及异常的时候生成 demp 文件)和 jmx 端口打开;
  2. 能设置好几个分代的内存空间就不错了。这个可以通过 jvm 的监控来设置根据 cpu 和 gc 的情况;
  3. 因为随着 JVM 的版本的升级,jvm 垃圾回收会也来越智能,但是我们必须要了解这些,因为面试的时候大牛为了显摆自己会问这些问题。
  4. 根据我们上面介绍的就算你设置+XX 有的时候也不一定有用,说不定哪个小版本里面就失灵了。jdk1.8 关于 gc 的最多开启+XX:+UseConcMarkSweepGC。

调优工具

对 JVM 的监控可以分为以下几个方面:

  • 内存状况分析(GC)
  • 线程状态分析

关于 GC 的监控,比较重要的有三个方面:

  • 各个区的容量,主要是堆中新生代与老年代的内存分配。
  • Full GC、Young GC 发生的次数,原则上尽量避免发生 Full GC,Young GC 能少则少。
  • 当前系统的内存比、CPU 使用率。

jps

jps 列出正在运行的虚拟机进程
包括 PID、主类、jar 包等

1
jps -ml

jinfo

Java 配置信息工具,并且支持运行时动态修改部分参数
查看某些配置值或开关是否打开:

1
2
jinfo -flag MaxTenuringThreshold 8737
jinfo -flag PrintGCDetails 8737

打开开关

1
2
jinfo -flag MaxTenuringThreshold=10 8737
jinfo -flag +PrintGCDetails 8737

查看虚拟机的默认配置参数还可以在运行时打开虚拟机的 PrintFlagsFinal 开关

1
java -XX:+PrintFlagsFinal Test

jstat

常用选项

1
jstat -gcutil # 垃圾收集统计数据

统计数据列含义:

数据列 描述 支持的 jstat 选项
S0C Survivor0 的当前容量 -gc -gccapacity -gcnew -gcnewcapacity
S1C S1 的当前容量 -gc -gccapacity -gcnew -gcnewcapacity
S0U S0的使用量 -gc-gcnew
S1U S1 的使用量 -gc-gcnew
EC Eden 区的当前容量 -gc -gccapacity -gcnew -gcnewcapacity
EU Eden 区的使用量 -gc -gcnew
OC old 区的当前容量 -gc -gccapacity -gcnew -gcnewcapacity
OU old区的使用量 -gc-gcnew
PC 方法区的当前容量 -gc-gccapacity -gcold -gcoldcapacity -gcpermcapacity
PU 方法区的使用量 -gc -gcold
YGC Young GC 次数 -gc -gccapacity -gcnew -gcnewcapacity -gcold -gcoldcapacity -gcpermcapacity -gcutil -gccause
YGCT Young GC 累积耗时 -gc -gcnew -gcutil -gccause
FGC Full GC次数 -gc -gccapacity -gcnew -gcnewcapacity -gcold -gcoldcapacity -gcpermcapacity -gcutil -gccause
FGCT Full GC 累积耗时 -gc-gcold -gcoldcapacity -gcpermcapacity -gcutil -gccause
GCT GC 总的累积耗时 -gc -gcold -gcoldcapacity -gccapacity -gcpermcapacity -gcutil -gccause
NGCMN 新生代最小容量 -gccapacity -gcnewcapacity
NGCMX 新生代最大容量 -gccapacity -gcnewcapacity
NGC 新生代当前容量 -gccapacity -gcnewcapacity
OGCMN 老年代最小容量 -gccapacity -gcoldcapacity
OGCMX 老年代最大容量 -gccapacity -gcoldcapacity
OGC 老年代当前容量 -gccapacity -gcoldcapacity
PGCMN 方法区最小容量 -gccapacity -gcpermcapacity
PGCMX 方法区最大容量 -gccapacity -gcpermcapacity
PGC 方法区当前容量 -gccapacity -gcpermcapacity
PC 方法区的当前容量 -gccapacity -gcpermcapacity
PU 方法区使用量 -gccapacity -gcold
LGCC 上一次 GC 发生的原因 -gccause
GCC 当前 GC 发生的原因 -gccause
TT 存活阀值,如果对象在新生代移动次数超过此阀值,则会被移到老年代 -gcnew
MTT 最大存活阀值,如果对象在新生代移动次数超过此阀值,则会被移到老年代 -gcnew
DSS survivor 区的理想容量 -gcnew

jmap

1
2
jmap -histo pid -F
jmap -dump:format=b, file=heap.bin pid

JConsole

JVisualVM

GC日志

关于输出 GC 日志的参数有以下几种:

  • -XX:+PrintGC 输出 GC 日志
  • -XX:+PrintGCDetails 输出 GC 的详细日志
  • -XX:+PrintGCTimeStamps 输出 GC 的时间戳(以基准时间的形式)
  • -XX:+PrintGCDateStamps 输出 GC 的时间戳(以日期的形式,如 2013-05-04T21:53:59.234+0800)
  • -XX:+PrintHeapAtGC 在进行 GC 的前后打印出堆的信息
  • -Xloggc:../logs/gc.log 日志文件的输出路径

比如对 PrintGCDetails 这个参数:

1
2
3
4
5
6
7
8
9
public class GCLogTest {
public static void main(String[] args) {
int _1m = 1024 * 1024;
byte[] data = new byte[_1m];
// 将data置null让其可被回收
data = null;
System.gc();
}
}

在 IDE 中设置 VM 参数-XX:+PrintGCDetails,再运行,可以得到:

1
2
3
4
5
6
7
8
9
10
11
12
[GC (System.gc()) [PSYoungGen: 5051K->776K(38400K)] 5051K->784K(125952K), 0.0014035 secs] [Times: user=0.01 sys=0.00, real=0.00 secs] 
[Full GC (System.gc()) Disconnected from the target VM, address: '127.0.0.1:55472', transport: 'socket'
[PSYoungGen: 776K->0K(38400K)] [ParOldGen: 8K->684K(87552K)] 784K->684K(125952K), [Metaspace: 2980K->2980K(1056768K)], 0.0040080 secs] [Times: user=0.01 sys=0.00, real=0.00 secs]
Heap
PSYoungGen total 38400K, used 333K [0x0000000795580000, 0x0000000798000000, 0x00000007c0000000)
eden space 33280K, 1% used [0x0000000795580000,0x00000007955d34a8,0x0000000797600000)
from space 5120K, 0% used [0x0000000797600000,0x0000000797600000,0x0000000797b00000)
to space 5120K, 0% used [0x0000000797b00000,0x0000000797b00000,0x0000000798000000)
ParOldGen total 87552K, used 684K [0x0000000740000000, 0x0000000745580000, 0x0000000795580000)
object space 87552K, 0% used [0x0000000740000000,0x00000007400ab0d0,0x0000000745580000)
Metaspace used 2988K, capacity 4568K, committed 4864K, reserved 1056768K
class space used 318K, capacity 392K, committed 512K, reserved 1048576K

第一行是 YoungGC,其结构如下图所示:
JVM-YoungGC日志
第二行是 FullGC,其结构如下图所示:
JVM-FullGC日志

  • GC日志开头的[GC[Full GC说明了这次垃圾收集的停顿类型,如果有Full,说明这次 GC 发生了Stop-The-World。因为是调用了System.gc()方法触发的收集,所以会显示[Full GC (System.gc()),不然是没有后面的(System.gc())的。
  • [PSYoungGen[ParOldGen是指 GC 发生的区域。
  • 在方括号中PSYoungGen:后面的5051K->776K(38400K)代表的是GC前该内存区域已使用的容量->GC后该内存区域已使用的容量(该内存区域总容量)
  • 在方括号之外的5051K->784K(125952K)代表的是GC前Java堆已使用容量->GC后Java堆已使用容量(Java堆总容量),注意已使用容量是减掉一个 Servivor 区、线程栈等区域后的大小。
  • 再往后的0.0014035 secs代表该内存区域 GC 所占用的时间,单位是秒。
  • 再后面的[Times: user=0.01 sys=0.00, real=0.00 secs],user 代表进程在用户态消耗的 CPU 时间,sys 代表进程在内核态消耗的 CPU 时间、real 代表程序从开始到结束所用的时钟时间。这个时间包括其他进程使用的时间片和进程阻塞的时间(比如等待 I/O 完成)。
  • 至于后面的eden代表的是 Eden 空间,还有fromto代表的是 Survivor 空间。

问题排查

OOM

有两种情况可能导致 OOM:

  1. 内存泄露(Memory Leak),某些对象是需要被释放的但是却由于某些原因释放不了,查看泄露对象对 GC Roots 的引用链,再追本溯源进行分析;
  2. 内存溢出(Memory Overflow),对象太多了导致堆放不下了,查看堆是否可以调大,或者某些对象活太久了。

产生 OutOfMemoryError 错误的具体原因有以下几种:

  • java.lang.OutOfMemoryError: Java heap space 表示 Java 堆空间不足。当应用程序申请更多的内存时,若 Java 堆内存已经无法满足应用程序的需要,则将抛出这种异常。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    /*
    VM Args: -Xms20m -Xmx20m -XX:+HeapDumpOnOutOfMemoryError
    */
    public class HeapOOM {
    static class OOMObject {}

    public static void main(String[] args) {
    List<OOMObject> list = new ArrayList<>();
    while(true) {
    list.add(new OOMObject());
    }
    }
    }
  • java.lang.OutOfMemoryError: PermGen space,表示 Java 永久代(方法区)的空间不足。永久代用于存放类的字节码和常量池,类的字节码被加载后存放在这个区域,这和存放对象实例的堆区是不同的。大多数 JVM 的实现都不会对永久代进行垃圾回收,因此,只要类加载过多就会出现这个问题。一般的应用程序都不会产生这个错误,然而,对于 Web 服务器会产生大量的 JSP,JSP 在运行时被动态地编译为 Java Servlet 类,然后加载到方法区,因此,有很多 JSP 的 Web 工程可能会产生这个异常。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    /*
    VM Args: -XX:PermSize=10M -XX:MaxPermSize=10M
    */
    public class RuntimeConstantPoolOOM {
    public static void main(String[] args) {
    // 使用List保持对常量池的引用,避免Full GC回收常量池
    List<String> list = new ArrayList<String>();
    for(int i = 0;; i++) {
    list.add(String.valueOf(i).intern());
    }
    }
    }
    使用 intern 测试运行时常量池是“永久代”的还是“元空间”的:
    1
    2
    3
    4
    String str1 = new StringBuilder("计算机").append("软件").toString();
    System.out.println(str1.intern() == str1);
    String str2 = new StringBuilder("ja").append("va").toString();
    System.out.println(str2.intern() == str2);
    方法区溢出测试(使用 CGLib 动态生成类):
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    /*
    VM Args: -XX:PermSize=10M -XX:MaxPermSize=10M
    */
    public class MethodAreaOOM {
    static class OOMObject {}
    public static void main(String[] args) {
    while(true) {
    Enhancer enhancer = new Enhancer();
    enhancer.setSuperclass(HeapOOM.OOMObject.class);
    enhancer.setUseCache(false);
    enhancer.setCallback(new MethodInterceptor() {
    @Override
    public Object intercept(Object o, Method method,
    Object[] objects, MethodProxy proxy) throws Throwable {
    return proxy.invokeSuper(o, args);
    }
    });
    enhancer.create();
    }
    }
    }
    jdk1.6 及之前版本,因为 HotSpot 实行“永久代”(PermGen),方法区保存到永久代中,虽然逻辑上属于堆,但是在这块空间上并没有实行 GC。在这种情况下,上面代码发生堆溢出是必然的。
    jdk1.7 及之后版本,因为“去永久代”,引入了“元空间”(Metaspace),这种策略使得大部分类元数据都保存在本地内存中,元空间使用一个全局空闲组块列表(一个大数组)表示,每创建一个类加载器都会从这个列表中获取一个自己的组块,用处当然是存储元信息(指针碰撞方式分配),当一个类加载器不再活动后,其所持有的组块列表也就返还给全局组块列表了,也就是说,类也是可能会被 GC 回收掉的。
    运行时常量池是分配于方法区的,所以可以这么认为:1.6 及之前常量是分配在一个“全局静态区”的,而 1.7 及之后则在堆中分配。
    运行时常量池导致的溢出不常见,上面的例子感觉也有点极端。
    方法区导致的溢出在实际应用中常见:一些框架 Spring、Hibernate 在对类进行增强时会使用到 CGLib 这类字节码技术;JVM 上的动态语言(Groovy)会持续创建类来实现语言的动态特性;拥有大量 JSP 页面或会动态生成 JSP 文件的应用(JSP 第一次运行时会被编译为 Servlet);基于 OSGi 的应用(即使是同一个类文件,被不同的加载器加载也会视为不同的类)等。
  • java.lang.OutOfMemoryError: unable to create new native thread,本质原因是创建了太多的线程,而系统允许创建的线程数是有限制的。
  • java.lang.OutOfMemoryError:GC overhead limit exceeded,是并行(或者并发)垃圾回收器的 GC 回收时间过长、超过 98%的时间用来做 GC 并且回收了不到 2%的堆内存时抛出的这种异常,用来提前预警,避免内存过小导致应用不能正常工作。
  • 栈溢出
    可以先使用-Xoss 参数设置本地方法栈大小(在 HotSpot 中无效,因为它将两个栈合并了),-Xss 参数设置栈容量大小,设得稍微小一些都没有问题。
    实验非常简单,就是定义并调用一个无限递归的方法,在调用深度到达一定程度后就会报错,并且使用一个 stackLength 成员变量记录栈深度
    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
    /*
    VM Args: -Xss228k
    */
    public class StackSOF {
    private int stackLength = 1;

    public void stackLeak() {
    stackLength++;
    stackLeak();
    }
    public static void main(String[] args) {
    StackSOF oom = new StackSOF();
    try {
    oom.stackLeak();
    } catch (Throwable e) {
    System.out.println("stack length:" + oom.stackLength);
    throw e;
    }
    }
    }
    /*
    VM Args: -Xss2M
    */
    public class StackOOM {
    private void dontstop() {
    while(true) {}
    }
    public void stackLeakByThread() {
    while(true) {
    new Thread(new Runnable() {
    @Override
    public void run() {
    dontstop();
    }
    }).start();
    }
    }
    public static void main(String[] args) {
    new StackOOM().stackLeakByThread();
    }
    }
    有两种可能的错误,第一种是线程请求的栈深度超出了虚拟机的允许范围,会产生 StackOverflowError 异常,第二种是虚拟机在扩展栈时无法申请到足够空间,会产生 OutOfMemoryError 异常。
    注意,一个 java 进程的内存容量是由操作系统决定的,Windows 下限制为 2GB,减去 Xmx(最大堆容量)、MaxPermSize(最大方法区容量)、程序计数器(很小)、虚拟机本身耗费的内存,剩下的就由虚拟机栈和本地方法栈瓜分了。
    前者比较容易找出错误,因为会有错误堆栈可以分析;
    后者往往是因为线程分配过多了,导致操作系统分配的内存用尽,事实上,每个线程的主要空间都被栈(虚拟机栈和本地方法栈)占用了,所以为了可以分配更多的线程,可以减少最大堆容量或者减少栈容量。
  • 本机直接内存溢出
    这个例子比较复杂,首先-XX:MaxDirectMemorySize 指定了直接内存大小(默认是-Xmx),然后越过了 DirectByteBuffer,反射获取 Unsafe 实例进行内存分配,allocateMemory 等价于 malloc
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    /*
    VM Args: -Xmx20M -XX:MaxDirectMemorySize=10M
    */
    public class DirectMemoryOOM {
    private static final int _1MB = 1024 * 1024;

    public static void main(String[] args) throws IllegalAccessException {
    Field unsafeField = Unsafe.class.getDeclaredFields()[0];
    unsafeField.setAccessible(true);
    Unsafe unsafe = (Unsafe) unsafeField.get(null);
    while(true) {
    unsafe.allocateMemory(_1MB);
    }
    }
    }
    直接内存,或者说堆外内存,不是在 java 虚拟机规范中定义的存储区域,一般是不受虚拟机控制的,但是 NIO 提供了 Native 函数库可以直接分配堆外内存。
    由 DirectMemory 导致的内存溢出在 Heap Dump 中不会有明显的异常,如果 OOM 之后 Dump 文件很小,且程序中又直接或间接使用了 NIO,就可以考虑是这方面的问题。

分析内存

为排查内存泄露、young gc耗时过长等问题,我们需要分析内存结构。

判断死锁

使用 jstack 判断死锁,下面是一段测试用的死锁代码:

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
public class DeadLockTest {

private static Object obj1 = new Object();
private static Object obj2 = new Object();

public static void main(String[] args) {
new Thread(new Thread1()).start();
new Thread(new Thread2()).start();
}

private static class Thread1 implements Runnable {

@Override
public void run() {
synchronized (obj1) {
System.out.println("Thread1 拿到了 obj1 的锁!");
try {
// 停顿2秒的意义在于,让Thread2线程拿到obj2的锁
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (obj2) {
System.out.println("Thread1 拿到了 obj2 的锁!");
}
}
}
}

private static class Thread2 implements Runnable {

@Override
public void run() {
synchronized (obj2) {
System.out.println("Thread2 拿到了 obj2 的锁!");
try {
// 停顿2秒的意义在于,让Thread1线程拿到obj1的锁
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (obj1) {
System.out.println("Thread2 拿到了 obj1 的锁!");
}
}
}
}
}

使用 jps 查看 Java 进程:

1
2
3
4
5
6
7
# jps

4098 Jps
2644
3991 Launcher
3992 DeadLockTest
2859 RemoteMavenServer

使用 jstack 查看 Java 进程中的所有线程:

1
2
3
# jstack 3992

结果见下图

JVM死锁

服务器 CPU 打满怎么排查

CPU 打满会导致服务器响应速度变慢甚至夯住,一般查看内存没有明显问题后我们就可以怀疑是有线程运行将 CPU 打满了。
1、查 CPU 占用率较高的进程
top 命令查进程占用 CPU
2、查该进程占用 CPU 最高的线程
top -H -p <查出的进程号>

1
2
3
4
5
6
7
8
9
10
11
12
root@app02:~# top -H -p 1153
top - 21:04:21 up 227 days, 6:52, 1 user, load average: 0.78, 0.75, 0.85
Threads: 1007 total, 0 running, 1007 sleeping, 0 stopped, 0 zombie
%Cpu(s): 3.2 us, 0.8 sy, 0.0 ni, 95.8 id, 0.1 wa, 0.0 hi, 0.0 si, 0.0 st
KiB Mem : 49049564 total, 295072 free, 42779556 used, 5974936 buff/cache
KiB Swap: 8191996 total, 431488 free, 7760508 used. 4009280 avail Mem

PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND
1487 root 20 0 19.158g 1.113g 6664 S 6.2 2.4 0:36.42 java
1153 root 20 0 19.158g 1.113g 6664 S 0.0 2.4 0:00.00 java
1155 root 20 0 19.158g 1.113g 6664 S 0.0 2.4 0:00.57 java
1160 root 20 0 19.158g 1.113g 6664 S 0.0 2.4 2:01.54 java

转成 16 进制:

1
2
hero@app02:~$ printf "%x\n" 1487
5cf

之后我们会用到这个值,因为 jstack 输出的 log 中使用十六进制表示线程编号。
3、输出

1
root@app02:~# jstack 1153 > test_jstack.txt

从结果中可以搜到上面给出的线程编码5cf

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
11179 "New I/O worker #168" #299 daemon prio=5 os_prio=0 tid=0x00007f3a75127000 nid=0x5cf runnable [0x00007f37278f7000]
11180 java.lang.Thread.State: RUNNABLE
11181 at sun.nio.ch.EPollArrayWrapper.epollWait(Native Method)
11182 at sun.nio.ch.EPollArrayWrapper.poll(EPollArrayWrapper.java:269)
11183 at sun.nio.ch.EPollSelectorImpl.doSelect(EPollSelectorImpl.java:79)
11184 at sun.nio.ch.SelectorImpl.lockAndDoSelect(SelectorImpl.java:86)
11185 - locked <0x00000000e4669320> (a sun.nio.ch.Util$2)
11186 - locked <0x00000000e4669310> (a java.util.Collections$UnmodifiableSet)
11187 - locked <0x00000000e46691e8> (a sun.nio.ch.EPollSelectorImpl)
11188 at sun.nio.ch.SelectorImpl.select(SelectorImpl.java:97)
11189 at org.jboss.netty.channel.socket.nio.SelectorUtil.select(SelectorUtil.java:68)
11190 at org.jboss.netty.channel.socket.nio.AbstractNioSelector.select(AbstractNioSelector.java:434)
11191 at org.jboss.netty.channel.socket.nio.AbstractNioSelector.run(AbstractNioSelector.java:212)
11192 at org.jboss.netty.channel.socket.nio.AbstractNioWorker.run(AbstractNioWorker.java:89)
11193 at org.jboss.netty.channel.socket.nio.NioWorker.run(NioWorker.java:178)
11194 at org.jboss.netty.util.ThreadRenamingRunnable.run(ThreadRenamingRunnable.java:108)
11195 at org.jboss.netty.util.internal.DeadLockProofWorker$1.run(DeadLockProofWorker.java:42)
11196 at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1142)
11197 at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:617)
11198 at java.lang.Thread.run(Thread.java:745)

SWAP 影响 GC

SWAP 和 GC 同时发生会导致 GC 时间很长,JVM 严重卡顿,甚至导致服务崩溃。
JVM 进行 GC 时,需要对相应堆分区的已用内存进行遍历;假如 GC 的时候,有堆的一部分内容被交换到 SWAP 中,遍历到这部分的时候就需要将其交换回内存,同时由于内存空间不足,就需要把内存中堆的另外一部分换到 SWAP 中去;于是在遍历堆分区的过程中,(极端情况下)会把整个堆分区轮流往 SWAP 写一遍。

QA

栈溢出和由栈引起的OOM有什么关系?

虽然都是由递归调用引起的,但是这两种异常引起的条件并不相同:

  1. 栈溢出(StackOverflowError)
    方法调用栈深度超出了虚拟机的允许范围。
  2. 栈引起的OOM
    虚拟机在扩展栈时无法申请到足够空间。

类文件结构

类文件结构比较繁琐,我暂时没有整理的兴趣,看一下《深入理解 Java 虚拟机上》上的介绍就可以了。

类加载器

Java 类结构是在运行时而不是在编译时确定的,而是由类加载器在运行期间加载的,因此称类的加载过程是动态加载,当调用某个类型对象的方法时,具体调用哪个方法是在运行期间决定的,因此称之为动态连接
类加载器(ClassLoader)不是 Java 虚拟机的组成部分,它由外部调用,执行加载过程。
类加载器用于加载类,任何类都需要由加载它的类加载器和这个类一同确立其在 Java 虚拟机中的唯一性,每一个类加载器,都有一个独立的类名称空间,因此由不同类加载器加载的类、就算它们真的是一个 class 出来的,也不算是同一个类型的,也不能直接进行交互(可以通过反射进行交互)。实现上,实际上 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
34
35
36
37
38
39
40
41
42
43
44
45
46
47
import java.io.ByteArrayOutputStream;
import java.io.FileInputStream;

public class MyClassLoader extends ClassLoader {

private String rootPath;

public MyClassLoader(String rootPath) {
this.rootPath = rootPath;
}

@Override
protected Class<?> findClass(String name) {
Class<?> clz = findLoadedClass(name);
if (clz != null) {
return clz;
}
byte[] classData = getData(name);
clz = defineClass("Hello", classData, 0, classData.length);
return clz;
}

private byte[] getData(String className) {
String pathName = rootPath + className.replace(".", "/") + ".class";
System.out.println("加载的类路径: " + pathName);
byte[] bytes = null;
try (FileInputStream fis = new FileInputStream(pathName);
ByteArrayOutputStream bos = new ByteArrayOutputStream()) {
byte[] flush = new byte[1024 * 1024];
int len;
while (-1 != (len = fis.read(flush))) {
bos.write(flush, 0, len);
}
bytes = bos.toByteArray();
} catch (Exception e) {
e.printStackTrace();
}
return bytes;
}

public static void main(String[] args) throws IllegalAccessException, InstantiationException {
MyClassLoader classLoader = new MyClassLoader("/tmp/");
Class<?> helloClass = classLoader.findClass("Hello");
Object hello = helloClass.newInstance();
System.out.println(hello);
}
}

这个类加载器:

  1. findClass的时候,先判断之前是否已经加载过这个类,如果加载过就直接返回了(双亲委派);
  2. /tmp目录下面读类文件,对读进的二进制流数据使用defineClass转换为类对象。

测试类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import java.util.StringJoiner;

public class Hello {

private int a = 10;

static {
System.out.println("123");
}

@Override
public String toString() {
return new StringJoiner(", ", Hello.class.getSimpleName() + "[", "]")
.add("a=" + a)
.toString();
}
}

使用javac命令编译后,将Hello.class文件移动到/tmp目录下。

类从哪里来

除了从文件加载以外,Java 的类加载机制还支持从网络读取,其实只要根据自己的需要实现类加载器,就能将任何二进制数据作为字节码数据进行加载。

  • -XX:+TraceClassLoading
    跟踪类加载过程,结果形如:[Loaded java.lang.invoke.MethodHandleImpl$Lazy from D:\programme\jdk\jdk8U74\jre\lib\rt.jar]

  • mvn dependency:tree > ~/dependency.txt
    打出所有依赖

  • mvn dependency:tree -Dverbose -Dincludes=groupId:artifactId
    只打出指定 groupId 和 artifactId 的依赖关系

  • -XX:+TraceClassLoading
    vm 启动脚本加入。在 tomcat 启动脚本中可见加载类的详细信息

  • -verbose
    vm 启动脚本加入。在 tomcat 启动脚本中可见加载类的详细信息

  • greys:sc
    greys 的 sc 命令也能清晰的看到当前类是从哪里加载过来的

  • tomcat-classloader-locate
    通过以下 url 可以获知当前类是从哪里加载的
    curl http://localhost:8006/classloader/locate?class=org.apache.xerces.xs.XSObject

类加载器的种类

从虚拟机角度看,只存在两种类加载器:1. 启动类加载器。2. 其他类加载器。
从开发人员角度看,包括如下类加载器:1. 启动类加载器。2. 扩展类加载器。3. 应用程序类加载器。4. 自定义类加载器。

  • 启动类加载器,用于加载 Java API,加载<JAVA_HOME>/lib 目录下的类库。
  • 扩展类加载类,由 sun.misc.Launcher$ExtClassLoader 实现,用于加载<JAVA_HOME>/lib/ext 目录下或者被 java.ext.dirs 系统变量指定路径下的类库。
  • 应用程序类加载器,也成为系统类加载器,由 sun.misc.Launcher$AppClassLoader 实现,用于加载用户类路径(ClassPath)上所指定的类库。
  • 自定义类加载器,继承系统类加载器,实现用户自定义加载逻辑。

双亲委派模型

类加载器执行过程
Java 设计者推荐所有自定义加载器都组合一个父加载器,加载类时先委派父加载器去尝试加载,若不成,再由自身去加载。所以一些基类总是由基加载器去加载,可以避免一个程序中有多个 java.lang.Object 类的情况
注意各个类加载器之间是组合关系,并非继承关系
当一个类加载器收到类加载的请求,它将这个加载请求委派给父类加载器进行加载,每一层加载器都是如此,最终,所有的请求都会传送到启动类加载器中。只有当父类加载器自己无法完成加载请求时,子类加载器才会尝试自己加载。
双亲委派模型可以确保安全性,可以保证所有的 Java 类库都是由启动类加载器加载。如用户编写的 java.lang.Object,加载请求传递到启动类加载器,启动类加载的是系统中的 Object 对象,而用户编写的 java.lang.Object 不会被加载。如用户编写的 java.lang.virus 类,加载请求传递到启动类加载器,启动类加载器发现 virus 类并不是核心 Java 类,无法进行加载,将会由具体的子类加载器进行加载,而经过不同加载器进行加载的类是无法访问彼此的。由不同加载器加载的类处于不同的运行时包。所有的访问权限都是基于同一个运行时包而言的。

类的加载时机

Java 虚拟机规范没有强制规定类的加载时机,但是严格规定了以下 5 种情况必须立即对类进行“初始化”(而加载、验证、准备自然需要在此之前开始)

  1. 遇到字节码指令 new(实例化对象时)、getstatic、putstatic(读取或设置一个类的静态字段)、invokestatic(调用一个类的静态方法);
  2. 遇到反射调用 java.lang.reflect 对类进行反射调用;
  3. 初始化一个类时,其父类还未初始化,则先初始化其父类(对接口不适用,即初始化子接口并不会导致父接口初始化);
  4. 主类(main);
  5. JDK7 的动态语言支持,java.lang.invoke.MethodHandle 实例最后的解析结果 REF_getStatic、REF_putStatic、REF_invokeStatic 方法句柄,并且这个方法句柄对应的类没有进行初始化,则需要先进行初始化。

上边的情况统称为主动引用,其他情况都是被动引用,被动引用都不会触发类的初始化

  • 被动引用的示例如下,主要是使用 类初始化块 进行验证的(即 static 块),只输出了”Super!”,原因是不满足上边的条件,子类是否被加载完全由虚拟机实现说了算
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    class SuperClass {
    public static int value = 123;
    static {
    System.out.println("Super!");
    }
    }
    class SubClass {
    static {
    System.out.println("Sub!");
    }
    public static void main(String[] args) {
    System.out.println(SubClass.value);
    }
    }
  • 书上举了另外一个例子说明 数组类初始化 的问题,下面代码并不会触发 SuperClass 类的初始化,而是初始化了一个对应的数组类,可以通过加-XX:+TraceClassLoading 的运行时参数来跟踪类的加载过程
    1
    2
    3
    4
    5
    public class NotInitialization {
    public static void main(String[] args) {
    SuperClass[] sca = new SuperClass[10];
    }
    }
  • 还有一个例子来说明 常量传播优化 会导致的混淆情况,即使用常量字段(static final)时不会触发初始化,编译时会将常量值保存到调用者的类常量池中,所以在调用时就和被调用者没关系了,下面的代码并不会触发 ConstClass 类的加载
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    class ConstClass {
    static {
    System.out.println("ConstClass!");
    }
    public static final String HELLOWORLD = "hello world";
    }
    public class NotInitialization {
    public static void main(String[] args) {
    System.out.println(ConstClass.HELLOWORLD);
    }
    }

类的加载过程

类的加载过程

加载

  1. 通过类的完全限定名获取表示类的二进制流;
  2. 转换为方法区中的类结构;
  3. 在 Java 堆中生成一个代表这个类的 java.lang.Class 对象,作为方法区这些数据的访问入口

对于数组类而言,数组类由 java 虚拟机直接创建,不通过类加载器创建。数组类的创建过程如下:

  1. 如果数组元素类型是引用类型,就采用双亲委派模型进行加载(之后会介绍),数组类将在加载该元素类型的类名称空间上被标识。
  2. 如果数组元素类型为基本类型,数组类被标记为与引导类加载器关联。
  3. 数组类的可见性与其元素类型可见性一致,如果元素类型不是引用类型,那数组类的可见性默认为 public。

验证

此阶段的主要目的是确保 class 文件的字节流包含的信息符合虚拟机的要求,进行一部分的语义分析,主要是防止字节码中存在一些危险操作(数组越界、错误转型、跳转过头等),后来的 Java 虚拟机规范还规定了文件格式、符号引用等的验证。
不同的虚拟机,对类验证的实现可能有所不同,但大致都会完成下面四个阶段的验证:

  1. 文件格式验证,是要验证字节流是否符合 Class 文件格式的规范,并且能被当前版本的虚拟机处理。如验证魔数是否为 0xCAFEBABE;主、次版本号是否正在当前虚拟机处理范围之内;常量池的常量中是否有不被支持的常量类型。
    该验证阶段的主要目的是保证输入的字节流能正确地解析并存储于方法区中,经过这个阶段的验证后,字节流才会进入内存的方法区中存储,所以后面的三个验证阶段都是基于方法区的存储结构进行的。
  2. 元数据验证,对字节码描述的信息进行语义分析,以保证其描述的信息符合 Java 语言规范的要求。可能包括的验证如:这个类是否有父类;这个类的父类是否继承了不允许被继承的类;如果这个类不是抽象类,是否实现了其父类或接口中要求实现的所有方法……
  3. 字节码验证,主要工作是进行数据流和控制流分析,保证被校验类的方法在运行时不会做出危害虚拟机安全的行为。如果一个类方法体的字节码没有通过字节码验证,那肯定是有问题的;但如果一个方法体通过了字节码验证,也不能说明其一定就是安全的。
  4. 符号引用验证,发生在虚拟机将符号引用转化为直接引用的时候,这个转化动作将在“解析阶段”中发生。验证符号引用中通过字符串描述的权限定名是否能找到对应的类;在指定类中是否存在符合方法字段的描述符及简单名称所描述的方法和字段;符号引用中的类、字段和方法的访问性(private、protected、public、default)是否可被当前类访问。

验证阶段对于虚拟机的类加载机制来说,不一定是必要的阶段。如果所运行的全部代码确认是安全的,可以使用-Xverify:none 参数来关闭大部分的类验证措施,以缩短虚拟机类加载时间

准备

为类变量(static 变量)分配内存和并初始化为默认值,它们被分配在方法区中。
准备阶段不分配类中的实例变量的内存,实例变量将会在对象实例化时随着对象一起分配在 Java 堆中。
比如static int a = 1;在准备阶段后 a 的值为 0,在初始化阶段后才变成 1,但是对常量字段(static final),在准备阶段会直接赋予用户设定的值。

解析

将常量池内的符号引用替换为直接引用,类似于将一个字面量 hash 到一个确定的槽中。
符号引用(Symbolic Reference):符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。符号引用与虚拟机实现的内存布局无关,引用的目标并不一定已经加载到内存中。
直接引用(Direct Reference):直接引用可以是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。直接引用是与虚拟机实现的内存布局相关的,如果有了直接引用,那么引用的目标必定已经在内存中存在。指向类型、类变量、类方法的直接引用可能是指向方法区的本地指针。

常见的符号引用包括类或接口的全限定名、字段名和描述符、方法名和描述符。类型的直接引用可能简单地指向保存类型数据的方法区中的与实现相关的数据结构。类变量的直接引用可以指向方法区中保存的类变量的值。类方法的直接引用可以指向方法区中的一段数据结构方法区中包含调用方法的必要数据。指向实例变量和实例方法的直接引用都是偏移量。实例变量的直接引用可能是从对象的映像开始算起到这个实例变量位置的偏移量。实例方法的直接引用可能是到方法表的偏移量。

为了加快解析效率,可以对解析结果进行缓存,之后再解析符号引用时直接返回即可,但是对于 invokedynamic 则不能进行缓存。解析主要是针对 CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info、CONSTANT_InterfaceMethodref_info、CONSTANT_MethodType_info、CONSTANT_MethodHandle_info、CONSTANT_InvokeDynamic_info 七种常量类型。

  1. 类或接口的解析
    将符号引用替换为直接引用包括如下几步。假设符号引用记为 S,当前类记为 C,S 对应的类或接口记为 I。
    • 若 S 不是数组类型,则把 S 传递给当前类 C 的类加载器进行加载,这个过程可能会触发其他的加载,这个过程一旦出现异常,则解析失败。
    • 若 S 是数组类型,并且数组元素类型为对象,则 S 的描述符会形如[java/lang/String,按照第一条去加载该类型,如果 S 的描述符符合,则需要加载的类型就是 java.lang.String,接着有虚拟机生成一个代表此数组唯独和元素的数组对象。
    • 若以上两个步骤没有出现异常,即 I 已经存在于内存中了,但是解析完成时还需要进行符号引用验证,确认 C 是否具备对 I 的访问权限。若不具备,则抛出 java.lang.IllegalAccessError 异常。
  2. 字段解析
    首先将 CONSTANT_Fieldref_info 中的 class_index 索引的 CONSTANT_Class_info 符号引用进行解析,即解析字段所在类或接口,若解析出现异常,则字段解析失败。如解析成功,则进行下面的解析步骤。假设该字段所属的类或接口标记为 C。
    • 如果 C 包含了字段的简单名和描述符与目标相匹配的字段,则返回这个字段的直接引用,查找结束。
    • (字段解析对接口优先搜索)否则,如果 C 实现了接口,按照继承关系从下往上递归搜索各个接口和它的父接口,看是否存在相匹配的字段。存在,则返回直接引用,查找结束。
    • 否则,如果 C 不是 Object 对象,按照继承关系从下往上递归搜索父类,看是否存在相匹配的字段。存在,则返回直接引用,查找结束。
    • 否则,查找失败,抛出 java.lang.NoSuchFieldError 异常。
  3. 类方法解析
    首先将 CONSTANT_Methodref_info 中的 class_index 索引的 CONSTANT_Class_info 符号引用进行解析,即解析方法所在的类或接口,若解析出现异常,则方法解析失败;如解析成功,则进行下面解析步骤。假设该方法所属的类标记为 C。
    • 如果在方法表中发现 CONSTANT_Class_info 中索引的 C 是一个接口而不是一个类,则抛出 java.lang.IncompatibleClassChangeError 异常。
    • 否则,如果 C 中包含了方法的简单名和描述符与目标相匹配的字段,则返回这个方法的直接引用,查找结束。
    • (方法解析对父类优先搜索)否则,在 C 的父类中递归搜索,看是否存在相匹配的方法,存在,则返回直接引用,查找结束。
    • 否则,在 C 实现的接口列表及父接口中递归搜索,看是否存在相匹配的方法,存在,说明 C 是一个抽象类(没有实现该方法,否则,在第一步就查找成功),抛出 java.lang.AbstractMethodError 异常。
    • 否则,查找失败,抛出 java.lang.NoSuchMethodError 异常。
    • 若查找过程成功,则对方法进行权限验证,如果发现不具备对此方法的访问权限,则抛出 java.lang.lllegalAccessError 异常。
  4. 接口方法解析
    首先将 CONSTANT_InterfaceMethodref_info 中的 class_index 索引的 CONSTANT_Class_info 符号引用进行解析,即解析方法所在的类或接口,若解析出现异常,则方法解析失败;如解析成功,则进行下面解析步骤。假设该方法所属的类标记为 C。
    • 如果在方法表中发现 CONSTANT_Class_info 中索引的 C 是一个类而不是接口,则抛出 java.lang.IncompatibleClassChangeError 异常。
    • 否则,如果 C 中包含了方法的简单名和描述符与目标相匹配的字段,则返回这个方法的直接引用,查找结束。
    • 否则,在 C 的父接口中递归搜索,直到 Object 类,看是否存在相匹配的方法,存在,则返回直接引用,查找结束。
    • 否则,查找失败,抛出 java.lang.NoSuchMethodError 异常。
    • 若查找过程成功,不需要进行权限验证,因为接口方法为 public,不会抛出 java.lang.IllegalAccessError 异常。

初始化

类初始化是类加载过程的最后一步,前面的类加载过程,除了在加载阶段用户应用程序可以通过自定义类加载器参与之外,其余动作完全由虚拟机主导和控制,到了初始化阶段,才真正开始执行类中定义的 Java 程序代码。
初始化是执行类构造器<clinit>()的过程,()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static{}块/类初始化块)中的语句合并产生的。具体地说,应该有以下几条规则:

  • 由编译器收集类中的所有类变量的赋值动作(如果仅仅只是声明,不会被收集)和静态语句块中的语句合并产生的,收集顺序按照语句在源文件中出现的顺序所决定;在静态语句块中只能访问定义在静态语句之前的变量;而对于定义在静态语句块之后的变量,可以进行赋值,但是不能够访问。
  • 不需要显示调用父类构造器,虚拟机会保证在子类的()方法执行之前,父类的()方法已经执行完毕,所以,第一个被执行的()方法的类肯定是 java.lang.Object。
  • 父类中定义的静态语句块优先于子类的静态语句。
  • 此方法对类和接口都不是必须的,若类中没有静态语句块和静态变量赋值操作,则不会生成()方法。
  • 接口会生成此方法,因为对接口的字段可以进行赋值操作。执行接口的()方法不需要先执行父接口的()方法,只有在使用父接口的变量时,才会进行初始化;接口的实现类在初始化时也不会执行接口的()方法。
  • 此方法在多线程环境中会被正确的加锁、同步。

使用

完成了初始化阶段后,我们就可以使用对象了,在程序中可以随意进行访问,只要类还没有被卸载。

卸载

GC 能够对方法区内无用对象进行回收,启动类加载的类型永远是可触及的,回收的是由用户自定义加加载器加载的类,具体内容等到 GC 部分再说。

接口的加载过程

接口的加载和类的加载是类似的,只是接口要初始化时并不会连带父接口一块初始化,只有在真正用到父接口时才会执行初始化。

定义类加载器和初始类加载器

我们知道不同类加载器加载的类位于不同的命名空间,它们之间是相互隔离的,这里说的隔离仅仅指它们存储位置隔离,并不是说一个自定义的类 A 使用了 java.util.List 类就会报错。
自定义的类 A 一般会使用系统类加载器加载,而 java.util.List 则会由启动类加载器加载,当加载类 A 时如果遇到了 java.util.List,会首先尝试通过系统类加载器加载,在它发现自己无法加载后,通过双亲委派模型交给父加载器加载。
初始类加载器和定义类加载器
如上图所示:

  • A 是由系统类加载器加载的,因此系统类加载器是其定义类加载器兼初始类加载器;
  • 系统类加载器加载过 java.util.List,因此是其初始类加载器;
  • 启动类加载器实际加载了 java.util.List,因此是其定义类加载器。

QA

  1. 为什么下面的执行结果为 0?
    说明类加载器在加载一个类时,父类的成员变量就算被覆盖,其存储空间依然还存在。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    class A {
    int a;
    }
    public class JavaTest extends A {
    int a;
    @Test
    public void test() {
    a = 1;
    System.out.println(super.a);
    }
    }
  2. 为什么下面的报错?
    类的初始化阶段有一个细节:类初始化块不能访问定义在其之后的变量
    1
    2
    3
    4
    5
    6
    7
    public class JavaTest {
    static {
    i = 0;
    System.out.println(i); // 报错
    }
    static int i = 1;
    }
  3. 为什么输出两个’A’?
    当我们 new A()时,首先为 A 分配内存空间,此时 A 已经存在了,只是还未初始化,然后调用 A 的构造函数,A 的构造函数又隐式调用了父类的构造函数。
    在父类构造函数中使用 this 调用 draw(),this 实际上指向了 a 对象,平常调用方法时 this 也是隐含的。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    class B {
    int a;
    B() {
    this.draw();
    }
    void draw() {
    System.out.println("B");
    }
    }
    class A extends B {
    int a;
    A() {
    draw();
    }
    @Override
    void draw() {
    System.out.println("A");
    }
    public static void main(String[] args) {
    A a = new A();
    }
    }
  4. 为什么最后输出的 count2 为 0?
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    class SingleTon {
    private static SingleTon singleTon = new SingleTon();
    public static int count1;
    public static int count2 = 0;
    private SingleTon() {
    count1++;
    count2++;
    }
    public static SingleTon getInstance() {
    return singleTon;
    }
    }
    public class Test {
    public static void main(String[] args) {
    SingleTon singleTon = SingleTon.getInstance();
    System.out.println("count1=" + singleTon.count1);
    System.out.println("count2=" + singleTon.count2);
    }
    }
    这里有问题的应该是 static 变量的初始化和构造方法被调用的顺序,实际上构造方法是先被调用的。
  5. SingleTon singleTon = SingleTon.getInstance();调用了类的 SingleTon 调用了类的静态方法,触发类的初始化(主动引用)
  6. 类加载的时候在准备过程中为类的静态变量分配内存并初始化默认值 singleton=null count1=0,count2=0(准备)
  7. 类初始化,为类的静态变量赋值和执行静态代码快。singleton 赋值为 new SingleTon()调用类的构造方法(初始化)
  8. 调用类的构造方法后 count=1;count2=1
  9. 继续为 count1 与 count2 赋值,此时 count1 没有赋值操作,所有 count1 为 1,但是 count2 执行赋值操作就变为 0
  10. 读下面的类加载器应用代码,为什么输出 false?
    myLoader 加载的类和虚拟机的默认类加载器(Bootstrap ClassLoader)将加载的类保存在不同的命名空间中,它们相当于不同的类。
    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
    public class JavaTest {
    @Test
    public void test() throws ClassNotFoundException, IllegalAccessException, InstantiationException {
    ClassLoader myLoader = new ClassLoader() {
    @Override
    public Class<?> loadClass(String name) throws ClassNotFoundException {
    String fileName = name.substring(
    name.lastIndexOf(".") + 1) + ".class";
    InputStream is = getClass().getResourceAsStream(fileName);
    if(is == null) { return super.loadClass(name); }
    try {
    byte[] b = new byte[is.available()]; // 进行验证
    is.read(b);
    return defineClass(name, b, 0, b.length);
    } catch (IOException e) {
    throw new ClassNotFoundException(name);
    }
    }
    };
    Object obj = myLoader.loadClass("com.tallate.JavaTest")
    .newInstance();
    System.out.println(obj.getClass());
    System.out.println(obj instanceof com.tallate.JavaTest); // false?
    }
    }
  11. JVM 怎么知道哪些类应该委托给父加载器加载?
    每个类装载器都有一个 URLClassPath 对象用于保存类路径,在加载时会先在这个路径下查找该类,找不到再返回 null。
  12. 不同命名空间的类为什么能互相使用?
    双亲委派模型中,一个类装载器总是会先委托父类去进行装载,所有这些被委托的类装载器都被称为初始类装载器,而实际装载的被称为定义类装载器,所有初始装载器间的类型是共享的
  13. 可以不可以自己写个 String 类?
    不能,因为根据类加载的双亲委派机制,会去加载父类,父类发现冲突了 String 就不再加载了。
  14. Tomcat 的应用隔离原理是什么?
    Tomcat 实现了两种隔离技术:用于线程隔离的线程池和用于代码隔离的 WebAppClassLoader。
    前者不必赘述,对于后者,大家比较感兴趣的是 Tomcat 中对双亲委派模型的违背,因为它不是先委托父加载器去加载目标类,因为 Tomcat 一个进程可以运行多个 web 服务器,两个 web 项目中可能会出现两个声明完全一致的类,它们必须所处的命名空间必须隔离开,不然可能会发生一个项目启动后调到另一个项目中的代码的情况,这样就乱套了。

参考

类文件结构

  1. 【JVM】JVM 系列之 Class 文件(三)

对象分配

  1. What do Java objects look like in memory during run-time?
  2. What does a Java array look like in memory?

栈帧结构

栈帧主要包括了局部变量表、操作数栈、动态连接、方法返回地址等信息,在内存结构章节中我们已经探讨过栈结构,但是还未从实现层面来讨论过。

  1. 局部变量表
    用于存放方法参数和方法内部的局部变量。局部变量表的大小在方法的 Code 属性中就已经定义好了,为max_locals的值,局部变量表的单位为slot,32位以内的类型只占用一个slot(包括 returnAddress 类型),64 位的类型占用两个 slot。
    • 对于实例方法而言,索引为 0 的 slot 存放的是 this 引用,之后再依次存放方法参数、局部变量;
    • slot 可以被重用,当局部变量已经超出了作用域时,在作用域外再定义局部变量时,可以重用之前的 slot 空间。
    • 同时,局部变量没有赋值是不能够使用的——会产生编译错误,这和类变量和实例变量是有不同的,如下面代码:
      1
      2
      3
      4
      public void test() {
      int i;
      System.out.println(i);
      }
  2. 操作数栈
    执行方法时,存放操作数的栈,栈的深度在方法的 Code 属性中已经定义好了,为max_stack的值,32 位以内的类型占用一个栈单位,64 位的类型占用两个栈单位。操作数栈可以与其他栈的局部变量表共享区域,这样可以共用一部分数据。
  3. 动态连接
    动态连接是为了支持在运行期间将符号引用转化为直接引用的操作。我们知道,每一个方法对应一个栈帧,而每一个栈帧,都包含指向对应方法的引用,这个引用就是为了支持动态连接,如 invokedynamic 指令。动态连接与静态解析对应,静态解析是在类加载(解析阶段)或者第一次使用时将符号引用转化为直接引用,动态连接则是每一次运行的时候都需要进行转化(invokedynamic 指令)。
  4. 方法返回地址
    正常方法返回,返回地址为到调用该方法的指令的下一条指令的地址;异常返回,返回地址由异常表确定。方法返回时,需要恢复上层方法的局部变量表、操作数栈、将返回值压入调用者栈帧的操作数栈、设置 PC 值。

方法的调用和执行

方法调用决定了调用哪个方法,并创建对应的栈帧,接下来会开始方法的执行

解析

在程序执行前就已经确定了方法调用的版本,即编译期就确定了调用方法版本,这个版本在运行时是不可变的。

  • 静态方法私有方法final方法在编译时就可以确定具体的调用版本,静态方法直接与类型相关、私有方法在外部不可访问、final 不可被继承,也可唯一确定,这些方法称为非虚方法,翻译成字节码是 invokestatic(调用静态方法)、invokespecial(调用实例构造器方法、私有方法、父类方法),在类加载的解析阶段就可以进行解析,如下方法调用在编译期就可以确定方法调用的版本。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    class Father {
    public static void print(String str) {
    System.out.println("father " + str);
    }
    private void show(String str) {
    System.out.println("father " + str);
    }
    }
    class Son extends Father {
    }
    public class Test {
    public static void main(String[] args) {
    Son.print("coder"); // 调用的是Father的print()方法
    //Father fa = new Father();
    //fa.show("cooooder"); // 私有方法无法调用
    }
    }
  • 其他方法称为虚方法

分派

分派调用与多态密切相关,分为静态分派动态分派单分派多分派

静态分派

与静态分派相关的就是方法的重载,重载时根据参数的静态类型引用类型而非实际类型决定调用哪个版本。
选取的过程共分为三个阶段:

  1. 在不考虑对基本类型自动装拆箱(auto-boxing,auto-unboxing),以及可变长参数的情况下选取重载方法;
  2. 如果在第 1 个阶段中没有找到适配的方法,那么在允许自动装拆箱,但不允许可变长参数的情况下选取重载方法;
  3. 如果在第 2 个阶段中没有找到适配的方法,那么在允许自动装拆箱以及可变长参数的情况下选取重载方法。

如果 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
/**
* 重载方法在编译器就可以进行确定,不需要等到运行期间
*/
public class StaticDispatch {
static class Human {
}
static class Women extends Human {
}
static class Men extends Human {
}

public void sayHello(Human human) {
System.out.println("say human");
}
public void sayHello(Women women) {
System.out.println("say women");
}
public void sayHello(Men men) {
System.out.println("say men");
}

public static void main(String[] args) {
StaticDispatch ds = new StaticDispatch();
Human women = new Women();
Human men = new Men();
// 编译时确定方法的调用版本是以Human作为参数的方法
ds.sayHello(women);
ds.sayHello(men);
}
}

动态分派

与动态分派相关的就是方法的重写,在子类中我们会重写父类的方法,而在调用的时候根据实际类型来选择适合的调用版本。

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
public class DynamicDispatch {
abstract static class Human {
abstract public void sayHello();
}

static class Women extends Human {
@Override
public void sayHello() {
System.out.println("say women");
}
}

static class Men extends Human {
@Override
public void sayHello() {
System.out.println("say men");
}
}

public static void main(String[] args) {
Human women = new Women();
Human men = new Men();
women.sayHello(); // 实际类型是Women
men.sayHello(); // 实际类型是Men
}
}

单分派与多分派

方法的接收者(方法的所有者)与方法的参数统称为方法的宗量,根据分派基于多少种宗量,可以将分派划分为单分派多分派
单分派根据一个宗量确定调用方法的版本;多分派根据多个宗量确定调用方法的版本。

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 Dispatch {
static class QQ {};
static class _360{};

public static class Father {
public void hardChoice(QQ arg) {
System.out.println("father choose qq");
}

public void hardChoice(_360 arg) {
System.out.println("father choose 360");
}
}

public static class Son extends Father {
public void hardChoice(QQ arg) {
System.out.println("son choose qq");
}

public void hardChoice(_360 arg) {
System.out.println("son choose 360");
}
}

public static void main(String[] args) {
Father father = new Father();
Father son = new Son();
father.hardChoice(new _360());
son.hardChoice(new QQ());
}
}

静态分派过程如下,在编译期阶段,会根据静态类型与参数类型确定调用版本,产生两条分别指向 Father.hardChoice(QQ)和 Father.hardChoice(_360)的指令,可以知道,在编译期,是由多个宗量确定调用版本,是静态多分派。
动态分派过程如下,在运行期,在执行 hardChoice(QQ)或者 hardChoice(_360)时,已经确定了参数必须为 QQ、_360,方法签名确定,静态类型和实际类型此时都不会对方法本身产生任何影响,而虚拟机会根据实际类型来确定调用版本,只根据一个宗量进行确定,因此,在运行时,是动态单分派。
在面向对象编程中我们会很频繁地使用到动态分配,虚拟机采用在类的方法区建立一个虚方法表(非虚方法不会出现在表中)来实现。
动态分派的实现

  • 只有虚方法才会出现在虚方法表中,也就是说静态方法私有方法final 方法是不会出现在这张表中的。
  • 从 Object 类继承的方法都会指向 Object 类型数据中各方法的实际入口地址。
  • 类自身的方法会指向类的数据类型中方法的实际入口地址。
  • 父类的没有被重写的方法在虚方法表中的索引与子类方法表中的索引相同,这样,当类型变化时,只需要改变方法表就行,索引还是相同。
  • 方法表一般在类加载的连接阶段进行初始化,准备了类变量的初始值后,方法表也初始化完毕。

方法退出

当一个方法开始执行后,只有两种方式可以退出:

  1. 第一种方式是执行引擎遇到任意一个方法返回的字节码指令,这种方式称为正常完成出口
  2. 另外一种退出方式是,在方法执行过程中遇到异常,且该异常没有被被捕获,称为异常完成出口

无论是哪种退出方式,在方法退出后,都需要返回到该方法被调用的位置(地址),让程序继续执行。一般来说,方法执行前,会保存调用者当前的 PC 计数器中的值,当方法正常退出时,将该 PC 计数器的值会作为返回地址,返回给调用者。在方法异常退出时,返回地址是通过异常处理器表来确定的。

方法退出的过程实际上就等于把当前栈帧出栈,一般过程为:

  1. 恢复上层方法的局部变量表和操作数栈
  2. 把返回值压入调用者栈帧的操作数栈中
  3. 调整 PC 计数器的值,以指向方法调用指令后面的一条指令

动态类型语言支持

Java 是一种静态类型语言,它与 Python、JavaScript 等动态类型语言的主要区别是:

  • 静态类型语言的类型检查主要过程是在编译期进行而不是运行期。

静态类型语言与动态类型语言的比较如下:

  • 静态类型语言在编译期确定类型,最显著的好处是编译器可以提供严谨的类型检查,这样与类型相关的问题能在编码的时候就及时发现,利于稳定性及代码达到更大规模。
  • 动态类型语言在运行期确定类型,这可以为开发人员提供更大的灵活性,某些在静态类型语言中需用大量“臃肿”代码来实现的功能,由动态类型语言来实现可能会更加清晰和简洁,从而提升开发效率。

在 JDK1.7 以前的字节码指令集中,4 条方法调用指令(invokevirtual、invokespecial、invokestatic、invokeinterface)的第一个参数都是被调用的方法的符号引用(CONSTANT_Methodref_info 或者 CONSTANT_InterfaceMethodref_info 常量),前面已经提到过,方法的符号引用在编译时产生,而动态类型语言只有在运行期才能确定接收者类型。
Java 不像 C/C++那样有 Function Pointer 或者 C#里的 Delegate。在 Java 中要实现类似的功能可以有以下两种方式:

  1. 实现一个函数接口,比如 Comparator
  2. MethodHandle,它的实现原理是第 5 条方法调用的字节码指令 invokedynamic,与其他 invoke*指令的最大差别是它的分派逻辑不是由虚拟机决定的,而是由程序员决定的。

MethodHandle 的例子:

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.lang.invoke.MethodHandle;
import java.lang.invoke.MethodType;

import static java.lang.invoke.MethodHandles.lookup;

/**
* @author hgc
* @date 2/16/20
*/
public class MethodHandleTest {

static class ClassA {
public void println(String s) {
System.out.println(s);
}
}

private static MethodHandle getPrintlnMH(Object reveiver) throws NoSuchMethodException, IllegalAccessException {
// MethodType代表方法类型,包含了方法的返回值(methodType()的第一个参数)和具体参数(methodType()第二个及之后的参数)
MethodType mt = MethodType.methodType(void.class, String.class);
// lookup()方法来自于MethodHandles.lookup,这句的作用是在指定类中查找符合给定方法名称、方法类型,并且符合调用权限的方法句柄
// 因为这里调用的是一个虚方法,按照Java语言的规则,方法第一个参数是隐式的,代表该方法的接收者,也即是this指向的对象,这个参数以前是放在参数列表中进行传递的,而现在提供了bindTo()方法来完成这件事
return lookup().findVirtual(reveiver.getClass(), "println", mt).bindTo(reveiver);
}

public static void main(String[] args) throws Throwable {
Object obj = System.currentTimeMillis() % 2 == 0 ? System.out : new ClassA();
getPrintlnMH(obj).invokeExact("icyfenix");
}
}

MethodHandle 与反射(Reflection)的区别是:

  • Reflection API 的设计初衷是只为 Java 服务,而 MethodHandle 则设计为可服务于所有 Java 虚拟机之上的语言,当然也包括 Java 语言。
  • MethodHandle 与 Reflection 机制都是在模拟方法调用,但 Reflection 是在模拟代码层次的方法调用,而 MethodHandle 则是在模拟字节码层次的方法调用。
    MethodHandles.lookup 中的 3 个方法——findStatic()、findVirtual()、findSpecial()对应了 invokestatic、invokevirtual + invokeinterface 和 invokespecial 这几条字节码指令的执行权限校验行为,而这些底层细节在使用 Reflection API 时无需考虑。
  • Reflection 中的 Method 比 MethodHandle 对象包含的信息多,Reflection 是重量级的,MethodHandle 是轻量级的。
  • MethodHandle 模拟了字节码的方法指令调用,所以理论上虚拟机在这方面做的各种优化(如方法内联)在 MethodHandle 上也可以采用类似思路来支持,而通过反射去调用方法则不行。

至于 MethodHandle 是如何实现的,可以参考《深入理解 Java 虚拟机》,大致上就是运行期去常量表里根据用户指定的参数找方法。

###基于栈的字节码解释执行引擎
JVM 的指令都是基于栈的,比如iadd表示弹出栈顶的两个元素,然后求出二者的和后重新压入栈中。
基于栈的指令集与基于寄存器的指令集各有优势:

  • 基于栈的指令集的主要优点就是可移植。
    寄存器是由硬件直接提供的,基于寄存器的指令集由于直接依赖这些硬件寄存器则不可避免地要受到硬件的约束。
    如果使用栈架构的指令集,用户程序不会直接使用这些寄存器,就可以由虚拟机实现来自行决定把一些访问最频繁的数据(程序计数器、栈顶缓存等)放到寄存器中以获取尽量好的性能,这样实现起来也更加简单一些。
  • 栈架构的指令集还有一些其他优点,比如代码相对来说更加紧凑(字节码中每个字节就对应一条指令,而多地址指令集中还需要存放参数)、编译器实现更加简单(不需要考虑空间分配的问题,所需空间都在栈上操作)等。
  • 栈架构指令集的主要缺点是执行速度相对来说会稍慢一些。所有主流物理机的指令集都是寄存器架构也从侧面印证了这一点。
    一方面,虽然栈架构指令集的代码非常紧凑,但是完成相同功能所需的指令数量一般会比寄存器架构多。比如出栈入栈操作本身就产生了相当多的指令数量。
    另一方面,栈实现在内存之中,频繁的栈访问也就意味着频繁的内存访问,相对于处理器来说,内存始终是执行速度的瓶颈。尽管虚拟机可以采取栈顶缓存的手段,把最常用的操作映射到寄存器中避免直接内存访问,但这也只能是优化措施而不是解决本质问题的方法。

QA

  1. 哪个方法会被调用?
    重载会触发静态分派,会根据传参的静态类型来决定调用哪个方法,因此会调用 print(Father),但输出时调用了 Child 类的 toString 方法,因为方法被重写了,会触发方法的动态分派,根据传参的实际类型来决定调用哪个方法。
    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
    public class DynamicDispatchTest {

    public void print(Father father) {
    System.out.println(father);
    }

    public void print(Child child) {
    System.out.println(child);
    }

    public static class Father {

    @Override
    public String toString() {
    return "Father";
    }
    }

    public static class Child extends Father {

    @Override
    public String toString() {
    return "Child";
    }
    }

    public static void main(String[] args) {
    Father father = new Child();
    DynamicDispatchTest dynamicDispatchTest = new DynamicDispatchTest();
    dynamicDispatchTest.print(father);
    }
    }

环境准备

搭建环境

1
2
docker run --name myredis -d -p6379:6379 redis
docker exec -it myredis redis-cli

使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# exists 查看某个key是否存在
exists aa
SET 创建一个key;
GET 获取一个key的值;
DEL ***一个key;
TYPE 获取一个key的类型;
EXISTS 判断一个key是否存在,0:存在,1,不存在;
# KEYS 获取给定模糊匹配的key,但要谨慎使用,因为线上的key一般非常多
keys *
keys a?
EXPIRE 设置一个key过期的秒数;
PERSTST ***一个key过期的秒数;
PEXPIRE 设置一个key过期的毫秒数;
RENAME 将一个key重命名;
RENAMENX 将一个key重命名,且新的key必须是不存在的可以;
TTL 获取key的有效时间,以秒为单位,-1表示永不过期,-2表示已过期、已转移
# dbsize 查看当前库中key数量
dbsize
# flushdb 清除数据库(内存)
flushdb
# move 移动key到另一个库
move aa 2

容量预估

在实际部署前一般都会先对所需容量进行一个评估,这样可以尽量避免在上线后容量不够还要扩容、或者容量过大造成浪费。
官方提供了一个容量预估工具,一些博客比如Redis 容量评估模型贴近 Redis 底层数据结构给出了容量的评估分析,可以作为一个参考,但是业务架构一直在变,实际的容量监控还是必须的,我们下面还会谈到这方面的工具。

使用Redis Cluster

搭建Redis Cluster集群

redis集群搭建

1、安装
到官网找到:

1
2
wget http://download.redis.io/releases/redis-4.0.8.tar.gz
make && make install # 默认安装目录为/usr/local/bin

ruby

1
2
yum install ruby
yum install rubygems

还有gem文件在此处下载,安装:

1
gem install /usr/local/redis-3.0.0.gem

2、创建 redis 节点
在一个目录(比如编译目录)下创建 redis_cluster 目录,再在这个目录下创建 7001、7002、7003、7004、7005、7006 的子目录,拷贝配置文件 redis.conf 到各个这些子目录中,并编辑以下内容

1
2
3
4
5
6
7
8
9
port 7001 //端口7001,7002,7003        
bind 本机ip //默认ip为127.0.0.1 需要改为其他节点机器可访问的ip 否则创建集群时无法访问对应的端口,无法创建集群
daemonize yes //redis后台运行
pidfile /var/run/redis_7001.pid //pidfile文件对应7001,7002,7003
logfile /tmp/redis_7001.log // 日志文件
cluster-enabled yes //开启集群 把注释#去掉
cluster-config-file nodes_7001.conf //集群的配置 配置文件首次启动自动生成 7001,7002,7003
cluster-node-timeout 15000 //请求超时 默认15秒,可自行设置
appendonly yes //aof日志开启 有需要就开启,它会每次写操作都记录一条日志

3、创建集群
先安装 ruby,因为 redis 的集群协调程序是用 ruby 写的

1
yum -y install ruby ruby-devel rubygems rpm-build

再安装gem,在编译目录下执行

1
gem install redis

运行每个redis实例:

1
redis-server redis.conf

复制编译目录下的src目录中的redis-trib.rb到/usr/local/bin,然后运行
在编译目录的src子目录下执行,其中host为各redis节点的绑定ip(如果绑定的ip是0.0.0.0则必须指定为对外开放的ip,否则会默认绑定127.0.0.1,在slot重定向时会报错),设置每个主分片有一个副本分片

1
./redis-trib.rb create --replicas 1 host1:port1 host2:port2 ...

4、测试
为了连到集群上,需要在 redis-cli 请求后加上-c 参数,比如

1
redis-cli -h 192.168.31.245 -c -p 7002

在普通set和get时,redis会自动计算出目标地址。
5、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
public class RedissonTest {

private static final Random random = new Random();

static int succeed = 0;

static int failed = 0;

public static void main(String[] args) throws InterruptedException {
Config config = new Config();
config.useClusterServers()
.setScanInterval(2000)
.addNodeAddress("redis://127.0.0.1:7001", "redis://127.0.0.1:7002", "127.0.0.1:7003")
.addNodeAddress("redis://127.0.0.1:7004", "redis://127.0.0.1:7005", "127.0.0.1:7006");
RedissonClient redissonClient = Redisson.create(config);
while (true) {
try {
RBucket<Object> bucket = redissonClient.getBucket(Integer.toString(random.nextInt()));
bucket.get();
succeed++;
log.info("调用成功, 当前 succeed:{}, failed:{}", succeed, failed);
} catch (Exception e) {
failed++;
log.info("调用失败, 当前 succeed:{}, failed:{}", succeed, failed, e.getMessage());
}
Thread.sleep(500);
}
}
}

info 命令中涉及集群部署的部分

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# Replication
# 当前副本角色,如果实例不是任何节点的从节点,则该值是“master”,如果实例从某个节点同步数据,则是“slave”
role:master
# 已连接的从节点数
connected_slaves:2
# 每个从节点的信息,包括ID、地址、端口号、状态
slave0:ip=10.32.140.18,port=6222,state=online,offset=1745391794554,lag=0
slave1:ip=10.32.140.15,port=6212,state=online,offset=1745391807778,lag=0
master_replid:6a64bcbbcae91324e72e24745392275e2f1382ea
master_replid2:0000000000000000000000000000000000000000
master_repl_offset:1745391878919
second_repl_offset:-1
repl_backlog_active:1
repl_backlog_size:16777216
repl_backlog_first_byte_offset:1745375101704
repl_backlog_histlen:16777216

cluster info 命令

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
10.32.140.15:6212> cluster info
# ok状态表示集群可以正常接受查询请求。fail 状态表示,至少有一个哈希槽没有被绑定(说明有哈希槽没有被绑定到任意一个节点),或者在错误的状态(节点可以提供服务但是带有FAIL 标记),或者该节点无法联系到多数master节点。
cluster_state:ok
# 已分配到集群节点的哈希槽数量(不是没有被绑定的数量)。16384个哈希槽全部被分配到集群节点是集群正常运行的必要条件.
cluster_slots_assigned:16384
# 哈希槽状态不是FAIL 和 PFAIL 的数量
cluster_slots_ok:16384
# 哈希槽状态是 PFAIL的数量。只要哈希槽状态没有被升级到FAIL状态,这些哈希槽仍然可以被正常处理。PFAIL状态表示我们当前不能和节点进行交互,但这种状态只是临时的错误状态
cluster_slots_pfail:0
# 哈希槽状态是FAIL的数量。如果值不是0,那么集群节点将无法提供查询服务,除非cluster-require-full-coverage被设置为no
cluster_slots_fail:0
# 集群中节点数量,包括处于握手状态还没有成为集群正式成员的节点
cluster_known_nodes:9
# 至少包含一个哈希槽且能够提供服务的master节点数量
cluster_size:3
# 集群本地Current Epoch变量的值。这个值在节点故障转移过程时有用,它总是递增和唯一的
cluster_current_epoch:3
# 当前正在使用的节点的Config Epoch值. 这个是关联在本节点的版本值
cluster_my_epoch:2
cluster_stats_messages_ping_sent:27870482
cluster_stats_messages_pong_sent:27072640
cluster_stats_messages_meet_sent:4
cluster_stats_messages_sent:54943126
cluster_stats_messages_ping_received:27072636
cluster_stats_messages_pong_received:27865273
cluster_stats_messages_meet_received:4
cluster_stats_messages_fail_received:4
cluster_stats_messages_publish_received:7514286
cluster_stats_messages_received:62452203

cluster nodes

1
2
3
4
5
6
7
8
# 节点ID,IP地址:端口号,标识,上一次发送 ping 包的时间,上一次收到 pong 包的时间,连接状态,节点使用的哈希槽
127.0.0.1:7001> cluster nodes
fcdcafe5482daa80d0a382f675e8caced2d6ce63 127.0.0.1:7001@17001 myself,slave b2d0be87492d75bce14d3e50f687ce8a7872ef73 0 1603765953000 7 connected
75d6d1e3d4d64eb6fb85d1ac7d883ecef4ac5e7e 127.0.0.1:7002@17002 master - 0 1603765955026 2 connected 5461-10922
b2d0be87492d75bce14d3e50f687ce8a7872ef73 127.0.0.1:7004@17004 master - 0 1603765952012 7 connected 0-5460
8bf573a595a3955293e37f2e34e20c4cfb469060 127.0.0.1:7003@17003 master - 0 1603765954021 3 connected 10923-16383
5d599dea56108003633cd27d13abf87a9ef07d52 127.0.0.1:7005@17005 slave 75d6d1e3d4d64eb6fb85d1ac7d883ecef4ac5e7e 0 1603765953000 2 connected
68e6ddbf689bc6d51ebfadd24064fc6cc8204210 127.0.0.1:7006@17006 slave 8bf573a595a3955293e37f2e34e20c4cfb469060 0 1603765954000 3 connected

测试结果预期

测试一些Cluster宕机的情况,预期会有以下结论:

  1. 关闭任意一主,会导致部分写操作失败,是由于从节点不能执行写操作,在Slave升级为Master期间会有少量的失败。
  2. 关闭从节点对于整个集群没有影响。
  3. 如果半数以上 Master 处于关闭状态那么整个集群处于不可用状态。
    原因:Redis Cluster的选举需要有Master参与,如果多半的Master都挂掉了,也就不能再支持选举新Master了。
  4. 关闭任意一对主从节点会导致部分(大约为整个集群的1/3)失败。
    Master宕机了,且没有替补的Slave,则分配给这个Master的slot就不可用了。

测试 - 压测

测试 - 宕机1台Master

下面的命令将7001干掉后测试集群的主从迁移情况。
刚开始集群7001、7002、7003是Master:

1
2
3
4
5
6
7
127.0.0.1:7001> cluster nodes
5d599dea56108003633cd27d13abf87a9ef07d52 127.0.0.1:7005@17005 slave 75d6d1e3d4d64eb6fb85d1ac7d883ecef4ac5e7e 0 1603615592617 2 connected
fcdcafe5482daa80d0a382f675e8caced2d6ce63 127.0.0.1:7001@17001 myself,master - 0 1603615591000 1 connected 0-5460
b2d0be87492d75bce14d3e50f687ce8a7872ef73 127.0.0.1:7004@17004 slave fcdcafe5482daa80d0a382f675e8caced2d6ce63 0 1603615591614 1 connected
8bf573a595a3955293e37f2e34e20c4cfb469060 127.0.0.1:7003@17003 master - 0 1603615591000 3 connected 10923-16383
75d6d1e3d4d64eb6fb85d1ac7d883ecef4ac5e7e 127.0.0.1:7002@17002 master - 0 1603615592000 2 connected 5461-10922
68e6ddbf689bc6d51ebfadd24064fc6cc8204210 127.0.0.1:7006@17006 slave 8bf573a595a3955293e37f2e34e20c4cfb469060 0 1603615593620 3 connected

将7001 kill掉后,请求7001服务器的请求都会失败,大约20秒后请求恢复,且观察日志可以发现,原来7001的Slave-7004现在替补上来成为了Master:

1
2
3
4
5
6
7
8
9
10
11
18:29:28.186 [main] INFO  c.t.l.r.RedissonTest - 调用成功, 当前 succeed:15, failed:0
18:29:33.475 [main] INFO c.t.l.r.RedissonTest - 调用失败, 当前 succeed:15, failed:1
18:29:38.750 [main] INFO c.t.l.r.RedissonTest - 调用失败, 当前 succeed:15, failed:2
18:29:39.253 [main] INFO c.t.l.r.RedissonTest - 调用成功, 当前 succeed:16, failed:2
18:29:39.756 [main] INFO c.t.l.r.RedissonTest - 调用成功, 当前 succeed:17, failed:2
18:29:40.259 [main] INFO c.t.l.r.RedissonTest - 调用成功, 当前 succeed:18, failed:2
18:29:40.763 [main] INFO c.t.l.r.RedissonTest - 调用成功, 当前 succeed:19, failed:2
18:29:45.271 [redisson-netty-2-4] INFO o.r.c.MasterSlaveEntry - master 127.0.0.1/127.0.0.1:7004 used as slave
18:29:45.274 [redisson-netty-2-14] INFO o.r.c.p.PubSubConnectionPool - 1 connections initialized for 127.0.0.1/127.0.0.1:7004
18:29:45.280 [redisson-netty-2-4] WARN o.r.c.ClusterConnectionManager - slave: redis://127.0.0.1:7001 has down for slot ranges: [[0-5460]]
18:29:45.285 [redisson-netty-2-28] INFO o.r.c.p.SlaveConnectionPool - 24 connections initialized for 127.0.0.1/127.0.0.1:7004

之后重启7001后,发现7001重新加入到了集群中:

1
2
3
4
18:34:18.539 [redisson-netty-2-29] INFO  o.r.c.p.PubSubConnectionPool - 1 connections initialized for 127.0.0.1/127.0.0.1:7001
18:34:18.542 [redisson-netty-2-4] INFO o.r.c.MasterSlaveEntry - master 127.0.0.1/127.0.0.1:7004 excluded from slaves
18:34:18.542 [redisson-netty-2-4] INFO o.r.c.ClusterConnectionManager - slave: redis://127.0.0.1:7001 has up for slot ranges: [[0-5460]]
18:34:18.544 [redisson-netty-2-6] INFO o.r.c.p.SlaveConnectionPool - 24 connections initialized for 127.0.0.1/127.0.0.1:7001

测试 - 宕机2台Master

把两台Master干掉后,两个Master均进入fail状态,这时集群也会进入fail状态,选举不会成功。

1
2
3
4
5
6
7
localhost:7001> cluster nodes
fcdcafe5482daa80d0a382f675e8caced2d6ce63 127.0.0.1:7001@17001 myself,slave b2d0be87492d75bce14d3e50f687ce8a7872ef73 0 1603622378000 7 connected
75d6d1e3d4d64eb6fb85d1ac7d883ecef4ac5e7e 127.0.0.1:7002@17002 master - 0 1603622381510 2 connected 5461-10922
b2d0be87492d75bce14d3e50f687ce8a7872ef73 127.0.0.1:7004@17004 master - 0 1603622379504 7 connected 0-5460
8bf573a595a3955293e37f2e34e20c4cfb469060 127.0.0.1:7003@17003 master - 0 1603622380507 3 connected 10923-16383
5d599dea56108003633cd27d13abf87a9ef07d52 127.0.0.1:7005@17005 slave 75d6d1e3d4d64eb6fb85d1ac7d883ecef4ac5e7e 0 1603622378498 2 connected
68e6ddbf689bc6d51ebfadd24064fc6cc8204210 127.0.0.1:7006@17006 slave 8bf573a595a3955293e37f2e34e20c4cfb469060 0 1603622380000 3 connected

集群fail后,客户端之后的请求也都失败了。

分布式锁

1
2
3
set key value ex nx
do sth...
del key
  1. 为什么要加过期时间?
    怕中间遇到异常退出,del 没有执行,导致陷入死锁。
    set 命令现在支持加 ex 和 nx 参数,既保证原子性,又支持加过期时间。
  2. 过早过期释放别人的锁
    业务执行时间过长,锁过期了,可能导致其他线程先获取到了锁,这样当前线程 del 的时候相当于将别人的锁释放掉了。
    可以给 value 设置一个随机数,释放前检查一下,这是个“读后写”的过程,为了保证其原子性,一般会使用 Lua 脚本来实现(类似 Redisson 中的实现)。
    Redisson 中的解决方案是加一个“看门狗”,定时刷新过期时间。
  3. 重入性
    为了实现可重入性,一般是在客户端使用 ThreadLocal 存储当前持有锁的计数。
    在 Redisson 中,可重入锁是通过在 Lua 脚本中对客户端线程 ID 进行计数来实现的。

延时队列

异步消息队列

可以使用 list 数据结构来实现异步消息队列,使用 rpush/lpush 操作入队列,使用 lpop 和 rpop 来出队列。

1
2
3
4
5
6
rpush notify-queue a b
llen notify-queue
lpop notify-queue
llen notify-queue
lpop notify-queue
...
  1. 如果队列空了怎么办?
    客户端通过轮询队列 pop 获取消息处理,如果队列空了,那么就会陷入 pop 的死循环。
    一般的解决办法是sleep,每次 pop 后可以暂停个 1 秒。
    但是sleep同样会带来延迟增大的问题,因此,更好的解决办法是blpop / brpop,即阻塞读:
    1
    2
    // 等待一秒若还没有数据则直接返回
    blpop notify-queue 1
  2. 空闲连接
    如果线程一直阻塞,则 Redis 的客户端连接将成为闲置连接,闲置过久,服务器一般会主动断开连接,减少闲置资源占用。这个时候 blpop/brpop 会抛出异常来,这时要注意对异常的捕获和重试
  3. 锁冲突处理
    加锁失败一般有 3 种处理方式:
    • 直接抛出异常,由用户稍后重试;
      适合由用户直接发起的请求,比如商城下单,下单失败后由用户决定是否重新请求。
    • sleep
      sleep 会阻塞消息处理线程,会导致后续消息处理出现延迟,不适合高并发(锁冲突频繁)或队列消息较多的情况,并且,如果是由于死锁导致的加锁不成功,sleep 将导致线程一直处于阻塞状态、后续的消息永远得不到处理。
    • 延时队列
      将当前冲突的请求扔到另一个队列延后处理以避开冲突,适合异步消息处理的场景。

下面的实现例子来自《Redis 深度历险》:

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
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
public class RedisDelayingQueue<T> {

static class TaskItem<T> {
public String id;
public T msg;
}

// fastjson 序列化对象中存在 generic 类型时,需要使用 TypeReference
private Type TaskType = new TypeReference<TaskItem<T>>() {
}.getType();

private Jedis jedis;
private String queueKey;

public RedisDelayingQueue(Jedis jedis, String queueKey) {
this.jedis = jedis;
this.queueKey = queueKey;
}

public void delay(T msg) {
TaskItem<T> task = new TaskItem<T>();
task.id = UUID.randomUUID().toString();
task.msg = msg;
String s = JSON.toJSONString(task);
// key是当前时间
jedis.zadd(queueKey, System.currentTimeMillis() + 5000, s); // 塞入延时队列 ,5s 后再试
}

public void loop() {
while (!Thread.interrupted()) {
// 取时间范围是0到当前时间内的一条记录
// 第3个参数0表示不记分数,
Set<String> values = jedis.zrangeByScore(queueKey, 0, System.currentTimeMillis(), 0, 1);
if (values.isEmpty()) {
try {
// 暂停一会,避免浪费CPU
Thread.sleep(500);
} catch (InterruptedException e) {
break;
}
continue;
}
String s = values.iterator().next();
// 说明抢到了
if (jedis.zrem(queueKey, s) > 0) {
TaskItem<T> task = JSON.parseObject(s, TaskType);
this.handleMsg(task.msg);
}
}
}

public void handleMsg(T msg) {
System.out.println(msg);
}

public static void main(String[] args) {
Jedis jedis = new Jedis();
RedisDelayingQueue<String> queue = new RedisDelayingQueue<>(jedis, "q-demo");
Thread producer = new Thread() {

public void run() {
for (int i = 0; i < 10; i++) {
queue.delay("codehole" + i);
}
}

};
Thread consumer = new Thread() {

public void run() {
queue.loop();
}

};
producer.start();
consumer.start();
try {
producer.join();
Thread.sleep(6000);
consumer.interrupt();
consumer.join();
} catch (InterruptedException e) {
}
}
}

一些优化点:

  1. 注意zrangeByScorezrem不是一个原子操作,可能会有多个线程争抢同一个 key,这可以通过 lua 脚本来优化。
  2. 使用 Redis 作为消息队列并不能保证 100%的可靠性,因为 rem 执行后如果客户端崩溃了消息就丢失了,或者 Redis 崩溃了消息也有可能丢失(这个可以通过主备来避免)。

阻塞队列

使用Redis实现队列可以利用lpop/rpush或rpop/lpush,但是这两组命令在遇到队列为空或满时还是会直接返回,如果要实现阻塞队列的阻塞等待能力,可以:
1、使用lua脚本轮询
lua脚本中先检测队列是否为空/满,在不空/不满的情况下才执行后续的操作。
如果为空/满,则客户端先等待一会再执行一次该lua脚本。
缺点是会有很多空轮询。
2、使用blpop命令
在list结构尚不存在元素的情况下,blpop命令会先将客户端挂起,等待:

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
void blockForKeys(redisClient *c, robj **keys, int numkeys, mstime_t timeout, robj *target) {

...

// 关联阻塞客户端和键的相关信息
for (j = 0; j < numkeys; j++) {

/* If the key already exists in the dict ignore it. */
// c->bpop.keys 是一个集合(值为 NULL 的字典)
// 它记录所有造成客户端阻塞的键
// 以下语句在键不存在于集合的时候,将它添加到集合
if (dictAdd(c->bpop.keys,keys[j],NULL) != DICT_OK) continue;

incrRefCount(keys[j]);

/* And in the other "side", to map keys -> clients */
// c->db->blocking_keys 字典的键为造成客户端阻塞的键
// 而值则是一个链表,链表中包含了所有被阻塞的客户端
// 以下程序将阻塞键和被阻塞客户端关联起来
de = dictFind(c->db->blocking_keys,keys[j]);
if (de == NULL) {
// 链表不存在,新创建一个,并将它关联到字典中
int retval;

/* For every key we take a list of clients blocked for it */
l = listCreate();
retval = dictAdd(c->db->blocking_keys,keys[j],l);
incrRefCount(keys[j]);
redisAssertWithInfo(c,keys[j],retval == DICT_OK);
} else {
l = dictGetVal(de);
}
// 将客户端填接到被阻塞客户端的链表中
listAddNodeTail(l,c);
}
blockClient(c,REDIS_BLOCKED_LIST);
}

执行指令结束后,处理解除了阻塞的键。
redis.c

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
int processCommand(redisClient *c) {

...

/* Exec the command */
if (c->flags & REDIS_MULTI &&
c->cmd->proc != execCommand && c->cmd->proc != discardCommand &&
c->cmd->proc != multiCommand && c->cmd->proc != watchCommand)
{
// 在事务上下文中
// 除 EXEC 、 DISCARD 、 MULTI 和 WATCH 命令之外
// 其他所有命令都会被入队到事务队列中
queueMultiCommand(c);
addReply(c,shared.queued);
} else {
// 执行命令
call(c,REDIS_CALL_FULL);

c->woff = server.master_repl_offset;
// 处理那些解除了阻塞的键
if (listLength(server.ready_keys))
handleClientsBlockedOnLists();
}

return REDIS_OK;
}

可以看到,执行命令的末尾需要处理解除了阻塞的键,遍历这些键然后唤醒等待的客户端。
t_list.c

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
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
/* 这个函数会在 Redis 每次执行完单个命令、事务块或 Lua 脚本之后调用。
*
* 对所有被阻塞在某个客户端的 key 来说,只要这个 key 被执行了某种 PUSH 操作
* 那么这个 key 就会被放到 serve.ready_keys 去。
*
* 这个函数会遍历整个 serve.ready_keys 链表,
* 并将里面的 key 的元素弹出给被阻塞客户端,
* 从而解除客户端的阻塞状态。
*
* 函数会一次又一次地进行迭代,
* 因此它在执行 BRPOPLPUSH 命令的情况下也可以正常获取到正确的新被阻塞客户端。
*/
void handleClientsBlockedOnLists(void) {

// 遍历整个 ready_keys 链表
while(listLength(server.ready_keys) != 0) {
list *l;

/* Point server.ready_keys to a fresh list and save the current one
* locally. This way as we run the old list we are free to call
* signalListAsReady() that may push new elements in server.ready_keys
* when handling clients blocked into BRPOPLPUSH. */
// 备份旧的 ready_keys ,再给服务器端赋值一个新的
l = server.ready_keys;
server.ready_keys = listCreate();

while(listLength(l) != 0) {

// 取出 ready_keys 中的首个链表节点
listNode *ln = listFirst(l);

// 指向 readyList 结构
readyList *rl = ln->value;

/* First of all remove this key from db->ready_keys so that
* we can safely call signalListAsReady() against this key. */
// 从 ready_keys 中移除就绪的 key
dictDelete(rl->db->ready_keys,rl->key);

/* If the key exists and it's a list, serve blocked clients
* with data. */
// 获取键对象,这个对象应该是非空的,并且是列表
robj *o = lookupKeyWrite(rl->db,rl->key);
if (o != NULL && o->type == REDIS_LIST) {
dictEntry *de;

/* We serve clients in the same order they blocked for
* this key, from the first blocked to the last. */
// 取出所有被这个 key 阻塞的客户端
de = dictFind(rl->db->blocking_keys,rl->key);
if (de) {
list *clients = dictGetVal(de);
int numclients = listLength(clients);

while(numclients--) {
// 取出客户端
listNode *clientnode = listFirst(clients);
redisClient *receiver = clientnode->value;

// 设置弹出的目标对象(只在 BRPOPLPUSH 时使用)
robj *dstkey = receiver->bpop.target;

// 从列表中弹出元素
// 弹出的位置取决于是执行 BLPOP 还是 BRPOP 或者 BRPOPLPUSH
int where = (receiver->lastcmd &&
receiver->lastcmd->proc == blpopCommand) ?
REDIS_HEAD : REDIS_TAIL;
robj *value = listTypePop(o,where);

// 还有元素可弹出(非 NULL)
if (value) {
/* Protect receiver->bpop.target, that will be
* freed by the next unblockClient()
* call. */
if (dstkey) incrRefCount(dstkey);

// 取消客户端的阻塞状态
unblockClient(receiver);

// 将值 value 推入到造成客户端 receiver 阻塞的 key 上
if (serveClientBlockedOnList(receiver,
rl->key,dstkey,rl->db,value,
where) == REDIS_ERR)
{
/* If we failed serving the client we need
* to also undo the POP operation. */
listTypePush(o,value,where);
}

if (dstkey) decrRefCount(dstkey);
decrRefCount(value);
} else {
// 如果执行到这里,表示还有至少一个客户端被键所阻塞
// 这些客户端要等待对键的下次 PUSH
break;
}
}
}

// 如果列表元素已经为空,那么从数据库中将它删除
if (listTypeLength(o) == 0) dbDelete(rl->db,rl->key);
/* We don't call signalModifiedKey() as it was already called
* when an element was pushed on the list. */
}

/* Free this item. */
decrRefCount(rl->key);
zfree(rl);
listDelNode(l,ln);
}
listRelease(l); /* We have the new list on place at this point. */
}
}

位图

场景

位图数据结构与 java 中的 Set 类似,占用空间小,但是并不能防止冲突,适合数据离散性比较大且对数据准确性不高的场景。

  1. 统计月活
    统计月活的时候我们需要对 userId 进行去重,每个用户就可以定位到这个位图上的一个确定的位置上,0 表示不活跃,1 表示活跃,遍历一次就可以知道月活用户数有多少。

使用

1
2
3
4
set s a
getbit s 2
setbit s 6 1
get s

注意下标并不是从低位到高位递增的,而是反过来的,a 的 ASCII 码值是01100001setbit s 6 1设置第 7 位为 1 后值就变成了 c。

1
2
3
4
5
6
// 指定范围内1的个数
bitcount s 0 7
// 第一个1的位置
bitpos s 1
// 下标0到7范围内第一个1的位置
bitpos s 1 0 7

比如用位数组记录用户登录的日期,bitcount 可以用于统计用户一共签到了多少天,bitpos 可以用于查找用户从哪一天开始第一次签到。

1
2
3
4
5
6
// 从第一个位(0)开始取4个位,结果是无符号数(u)
bitfield s get u4 0
// 从第三个位(2)开始取3个位,结果是有符号数(i)
bitfield s get i3 2
// 一次性执行多个子命令
bitfield s get u4 0 get u3 2 get i4 0 get i3 2

如果 s 的值是 c,二进制值是0110 0011,取前 4 位结果是 6(0110)。

1
2
3
4
5
6
// +1,结果为0,因为溢出了
bitfield s incrby u2 1 1
// 不执行,返回nil
bitfield s overflow fail incrby u2 1 1
// 结果为3
bitfield s overflow sat incrby u2 1 1

位的增加操作有 3 种策略:

  1. 默认的 wrap:
  2. fail:失败报错不执行
  3. sat:饱和截断,如果有溢出就停留在最大最小值。

HyperLogLog

HyperLogLog 主要用于大数据量的计数,比如访问频繁的页面需要统计 UV(一天内访问的用户数),不同于 PV,UV 需要去重。
HyperLogLog 只能粗略统计,理论上会有不到 1%的误差。

使用

1
2
3
4
5
6
7
8
pfadd s user1
pfcount s
pfadd s user1
pfadd s user2
// 结果仍然为2
pfcount s
// 将两个计数器累加
pfmerge s b

布隆过滤器

HyperLogLog 可以用于计数,但是不能用于判断一个值是否存在于该结构里,这时最好使用布隆过滤器(Bloom Filter):

  1. 去重;
  2. 支持 contains 判断;
  3. 节省空间;
  4. 不精确,存在误判的可能(当布隆过滤器说某个值存在时,这个值可能不存在,当它说不存在时,那就肯定不存在,这个问题是由 hash 函数的冲突引起的);
  5. 不支持计数。

使用

插入值并且判断该值是否存在于 bf 对象内:

1
2
3
4
5
bf.add s user1
bf.add s user2
bf.madd s user3 user4
bf.exists s user1
bf.mexists s user1 user5

第一次 bf.add 会创建一个默认参数的布隆过滤器,也可以显式创建:

1
2
// 创建一个名为key的布隆过滤器,错误率0.1,预计放入的元素数量为10
bf.reserve key 0.1 10
  • 错误率越低,需要的空间越大,默认值为 0.01
    对于不需要非常精确的场合,错误率设置得稍大一点也无伤大雅。
  • 当实际数量超出预计放入数量时,误判率会上升,默认值为 100
    该值设置得过大,会浪费存储空间,估计得过小,就会影响准确率,最理想的情况下是略大于实际元素数量。

当实际元素数超出初始化大小时,应该对布隆过滤器进行重建,重新分配一个 size 更大的过滤器,再将所有的历史元素批量 add 进去(比如如果布隆过滤器保存的是 userId,那么就需要把历史 userId 重新保存到一个新的布隆过滤器中)。

原理

布隆过滤器本身是一个大型的位数组和几个不同的无偏 hash 函数。

无偏指的是该 hash 函数能把值分布得比较均匀。

  • add:使用这些 hash 函数计算 hash(key)%length(bit_arr),每个 hash 函数都可以得到一个位置,将这些位置置为 1;
  • exists:同样用这些 hash 函数计算位置,如果有一个不为 1 说明 key 不存在,如果都为 1 也不一定说明 key 一定存在,因为这些位置可能会被其他 key 设置到。

GeoHash

一种简单的方法

找一个节点附近的所有节点,可以近似看做以该节点为中心的一个矩形范围内有哪些节点,可以用类似下面的 SQL 来查询:

1
select id from positions where x0-r < x < x0+r and y0-r < y < y0+r

对 x 和 y 字段加索引后该 SQL 的性能也不会太差。

GeoHash

GeoHash 的基本思想是“降维”,将二维坐标映射到直线上的一个点,要寻找二维平面上的“附近的人”就相当于在直线上找相邻的点。
GeoHash 本质是一个 zset(带 score 的 set),底层结构是 skiplist:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
geoadd company 116.489033 40.007669 meituan
geoadd company 116.562108 39.787602 jd 116.334255 40.027400 xiaomi
// geoadd指令添加一个节点,因为geo存储结构上使用的是zset,因此可以使用zset相关的指令来操作,比如可以使用zrem指令来删除
zrem company jd
geoadd company 116.562108 39.787602 jd
// 计算两个节点之间的距离
geodist company meituan jd
// 单位km
geodist company meituan jd km
// 获取节点位置
geopos company meituan jd
// 获取元素的hash值
geohash company meituan
// 查询指定元素附近的其他元素
// 范围20公里内最多3个元素按距离正序排列,
georadiusbymember company meituan 20 km count 3 asc

GeoHash 算法的执行流程如下:

  1. 将坐标编码为一个 52 位整数,这个整数也可以还原为原坐标;
  2. zset 的 value 是元素的 key(即上面的 meituan、jd 等),score 是 GeoHash 的 52 位整数值,通过 zset 的 score 排序即可得到坐标附近的其它元素。

其中主要问题是坐标是如何编码的,编码后为什么可以通过比较大小来判断是否相邻?
GeoHash 算法的原理是按经纬度区间对半分来进行编码,比如,维度的区间是(-90, 90),如果坐标的维度值大于 0,则记 1,否则记 0,进一步的,如果坐标维度大于 45,则记 1,否则记 0,以此类推,对于39.918118来说,最后的编码值就是10111000110001011011

一般实现中还会进行 BASE64 编码,本质是一样的,只是编码后占用的空间更小了。

Scan

扫描大量 key 找到目标 key 的需求有两种实现方法:

  1. keys 命令
    不支持分页,一次性吐出所有满足条件的 key,如果数据量过大、耗时过长,可能导致服务端卡顿。

    因为 Redis 是单线程模型。

  2. scan
    和 keys 命令一样提供模式匹配,但它是通过游标扫描的,每次只扫描指定数量的数据,并将其中匹配的结果返回。

使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 从cursor=0开始,匹配"key99*",共扫描1000条
scan 0 match key99* count 1000
1) "11928"
2) 1) "key9956"
2) "key9993"
3) "key9911"
4) "key996"
5) "key9933"
6) "key9962"
7) "key991"
8) "key9981"
9) "key9990"
10) "key9946"
11) "key9971"
12) "key99"
13) "key9951"

返回值中,第一个”11928”是结果中最后一条的 cursor,下一次遍历时可以使用该值作为初始 cursor。

1
2
3
scan 11928 match key99* count 10
1) "3480"
2) (empty list or set)

结果为空集合并不意味着遍历结束了,只有 cursor=0 才是遍历结束的标识。

1
2
3
4
5
6
7
8
scan 11928 match key99* count 10000
1) "0"
2) 1) "key9952"
2) "key9961"
3) "key9988"
4) "key9931"
5) "key9998"
...忽略更多的

原理

Redis 中所有 key 都存储在一个非常大的字典中,这个字典的结构和 Java 的 HashMap 类似:
Redis-字典

  1. 一维数组大小为 2^n(n >= 0),扩容一次大小翻倍,保存的是所有 key 的下标,或者称为槽(slot);
  2. 二维链表保存的是所有的 key,不同 key 是有可能被 hash 到同一个槽上的,这时这个槽里所有元素都会被模式匹配过滤后一次性返回。

scan 的遍历并不是从 0 开始递增,而是通过二进制高位进位加法来遍历,比如遍历顺序:

  1. 0000 -> 0
  2. 1000 -> 8
  3. 0100 -> 4
  4. 1100 -> 12
  5. 以此类推

Redis-高位递增遍历
这种遍历方式的好处是可以避免扩容缩容后相同元素被反复遍历到。
因为槽数组的长度总是 2 的 n 次方,因此取模运算等价于位与操作,比如原来数组长度为 8,15(1111)会被 hash 到 7 号槽,而扩容后数组长度变成 16,增加了一个高位的 1,15(1111)会被 hash 到 15 号槽。
因此从低到高的遍历方式可能会导致重复遍历,而从高到低的方式则可以避免。

Redis 的 rehash 是渐进式的,在 rehash 的过程中,操作需要同时访问新旧的两个数组结构,如果旧的找不到就需要再到新的下面去找,scan 同理。

大 key 问题

大 key 会导致数据迁移和扩容时需要分配更大的一块内存空间,导致卡顿,删除时,同样需要一次性回收大 key,卡顿会再一次产生。
为了定位大 key,可以使用 redis-cli 的扫描功能

1
2
3
4
redis-cli -h 127.0.0.1 -p 7001 –-bigkeys
# 如果担心这个指令会大幅抬升 Redis 的 ops 导致线上报警,还可以增加一个休眠参数
# 每隔100条scan指令就会休眠0.1s
redis-cli -h 127.0.0.1 -p 7001 –-bigkeys -i 0.1
0%