Spring Security 6 配置方法,废弃 WebSecurityConfigurerAdapter


一、背景

最近阿里云的项目迁回本地运行,数据库从阿里云的RDS(即Mysql5.6)换成了本地8.0,Redis也从古董级别的2.x换成了现在6,忍不住,手痒,把jdk升级到了17,用zgc垃圾回收器,源代码重新编译重新发布,结果碰到了古董的SpringBoot不支持jdk17,所以有了这篇日志。记录一下SpringBoot2+SpringSecurity+JWT升级成SpringBoot3+SpringSecurity+JWT,就像文章标题所说的,SpringSecurity已经废弃了继承WebSecurityConfigurerAdapter的配置方式,那就的从头来咯。

在Spring Security 5.7.0-M2中,Spring就废弃了WebSecurityConfigurerAdapter,因为Spring官方鼓励用户转向基于组件的安全配置。本文整理了一下新的配置方法。

在下面的例子中,我们使用Spring Security lambda DSL和HttpSecurity#authorizeHttpRequests方法来定义我们的授权规则,从而遵循最佳实践。

二、配置成功后根据配置的情况整理的类图

​​​​​​​

三、配置详情

3.1. 启用WebSecurity配置

@Configuration
@EnableWebSecurity
public class SecurityConfig {

}
以下的配置在SecurityConfig内完成。

3.1.1. 注册SecurityFilterChain Bean,并完成HttpSecurity配置

在HttpSecurity中注意使用了lambda写法,使用这种写法之后,每个设置都直接返回HttpSecurity对象,避免了多余的and()操作符。每一步具体的含义,请参考代码上的注释。

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http
            // 禁用basic明文验证
            .httpBasic().disable()
            // 前后端分离架构不需要csrf保护
            .csrf().disable()
            // 禁用默认登录页
            .formLogin().disable()
            // 禁用默认登出页
            .logout().disable()
            // 设置异常的EntryPoint,如果不设置,默认使用Http403ForbiddenEntryPoint
            .exceptionHandling(exceptions -> exceptions.authenticationEntryPoint(invalidAuthenticationEntryPoint))
            // 前后端分离是无状态的,不需要session了,直接禁用。
            .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(authorizeHttpRequests -> authorizeHttpRequests
                    // 允许所有OPTIONS请求
                    .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
                    // 允许直接访问授权登录接口
                    .requestMatchers(HttpMethod.POST, "/web/authenticate").permitAll()
                    // 允许 SpringMVC 的默认错误地址匿名访问
                    .requestMatchers("/error").permitAll()
                    // 其他所有接口必须有Authority信息,Authority在登录成功后的UserDetailsImpl对象中默认设置“ROLE_USER”
                    //.requestMatchers("/**").hasAnyAuthority("ROLE_USER")
                    // 允许任意请求被已登录用户访问,不检查Authority
                    .anyRequest().authenticated())
            .authenticationProvider(authenticationProvider())
            // 加我们自定义的过滤器,替代UsernamePasswordAuthenticationFilter
            .addFilterBefore(authenticationJwtTokenFilter(), UsernamePasswordAuthenticationFilter.class);

    return http.build();
}

3.1.2. 注册自定义用户登录信息查询Bean

@Autowired
private UserDetailsService userDetailsService;

@Bean
public UserDetailsService userDetailsService() {
    // 调用 JwtUserDetailService实例执行实际校验
    return username -> userDetailsService.loadUserByUsername(username);
}

这里关联到一个自定义的子类UserDetailsService,代码逻辑如下,注意需要根据实际情况改造数据库查询逻辑:

@Component
public class SecurityUserDetailsService implements UserDetailsService {

@Autowired
private SqlSession sqlSession;

@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
    try {
        // 查询数据库用户表,获得用户信息
        sqlSession.xxx
        // 使用获得的信息创建SecurityUserDetails
        SecurityUserDetails user = new SecurityUserDetails(username, 
                password,
                // 以及其他org.springframework.security.core.userdetails.UserDetails接口要求的信息
                );

        logger.info("用户信息:{}", user);
        return user;
    } catch (Exception e) {
        String msg = "Username: " + username + " not found";
        logger.error(msg, e);
        throw new UsernameNotFoundException(msg);
    }
}

3.1.3. 注册密码加密Bean

@Bean
public PasswordEncoder passwordEncoder() {
    return new BCryptPasswordEncoder();
}

3.1.4. 注册用户授权检查执行Bean

这里设定使用DaoAuthenticationProvider执行具体的校验检查,并且将自定义的用户登录查询服务Bean,和密码生成器都注入到该对象中

/**
 * 调用loadUserByUsername获得UserDetail信息,在AbstractUserDetailsAuthenticationProvider里执行用户状态检查
 *
 * @return
 */
@Bean
public AuthenticationProvider authenticationProvider() {
    DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider();
    // DaoAuthenticationProvider 从自定义的 userDetailsService.loadUserByUsername 方法获取UserDetails
    authProvider.setUserDetailsService(userDetailsService());
    // 设置密码编辑器
    authProvider.setPasswordEncoder(passwordEncoder());
    return authProvider;
}

3.1.5. 注册授权检查管理Bean

/**
 * 登录时需要调用AuthenticationManager.authenticate执行一次校验
 *
 * @param config
 * @return
 * @throws Exception
 */
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception {
    return config.getAuthenticationManager();
}

3.1.6. 注册每次请求的jwt检查拦截器

在拦截器中检查jwt是否能够通过签名验证,是否还在有效期内。如果通过验证,使用jwt中的信息生成一个不含密码信息的SecurityUserDetails对象,并设置到SecurityContext中,确保后续的过滤器检查能够知晓本次请求是被授权过的。具体代码逻辑看3.2. jwt请求过滤器。

@Bean
public JwtTokenOncePerRequestFilter authenticationJwtTokenFilter() {
    return new JwtTokenOncePerRequestFilter();
}

3.1.7. SecurityConfig 对象完整内容

@Configuration
@EnableWebSecurity
public class SecurityConfig {
 
    @Autowired
    private UserDetailsService userDetailsService;
 
    @Autowired
    private InvalidAuthenticationEntryPoint invalidAuthenticationEntryPoint;
 
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
 
    @Bean
    public JwtTokenOncePerRequestFilter authenticationJwtTokenFilter() {
        return new JwtTokenOncePerRequestFilter();
    }
 
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
                // 禁用basic明文验证
                .httpBasic().disable()
                // 前后端分离架构不需要csrf保护
                .csrf().disable()
                // 禁用默认登录页
                .formLogin().disable()
                // 禁用默认登出页
                .logout().disable()
                // 设置异常的EntryPoint,如果不设置,默认使用Http403ForbiddenEntryPoint
                .exceptionHandling(exceptions -> exceptions.authenticationEntryPoint(invalidAuthenticationEntryPoint))
                // 前后端分离是无状态的,不需要session了,直接禁用。
                .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .authorizeHttpRequests(authorizeHttpRequests -> authorizeHttpRequests
                        // 允许所有OPTIONS请求
                        .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
                        // 允许直接访问授权登录接口
                        .requestMatchers(HttpMethod.POST, "/web/authenticate").permitAll()
                        // 允许 SpringMVC 的默认错误地址匿名访问
                        .requestMatchers("/error").permitAll()
                        // 其他所有接口必须有Authority信息,Authority在登录成功后的UserDetailsImpl对象中默认设置“ROLE_USER”
                        //.requestMatchers("/**").hasAnyAuthority("ROLE_USER")
                        // 允许任意请求被已登录用户访问,不检查Authority
                        .anyRequest().authenticated())
                .authenticationProvider(authenticationProvider())
                // 加我们自定义的过滤器,替代UsernamePasswordAuthenticationFilter
                .addFilterBefore(authenticationJwtTokenFilter(), UsernamePasswordAuthenticationFilter.class);
 
        return http.build();
    }
 
    @Bean
    public UserDetailsService userDetailsService() {
        // 调用 JwtUserDetailService实例执行实际校验
        return username -> userDetailsService.loadUserByUsername(username);
    }
 
    /**
     * 调用loadUserByUsername获得UserDetail信息,在AbstractUserDetailsAuthenticationProvider里执行用户状态检查
     *
     * @return
     */
    @Bean
    public AuthenticationProvider authenticationProvider() {
        DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider();
        // DaoAuthenticationProvider 从自定义的 userDetailsService.loadUserByUsername 方法获取UserDetails
        authProvider.setUserDetailsService(userDetailsService());
        // 设置密码编辑器
        authProvider.setPasswordEncoder(passwordEncoder());
        return authProvider;
    }
 
    /**
     * 登录时需要调用AuthenticationManager.authenticate执行一次校验
     *
     * @param config
     * @return
     * @throws Exception
     */
    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception {
        return config.getAuthenticationManager();
    }
}

3.2. JWT请求过滤检查

/**
 * 每次请求的 Security 过滤类。执行jwt有效性检查,如果失败,不会设置 SecurityContextHolder 信息,会进入 AuthenticationEntryPoint
 */
public class JwtTokenOncePerRequestFilter extends OncePerRequestFilter {
 
    @Autowired
    private JwtTokenProvider jwtTokenProvider;
 
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {
        try {
            String token = jwtTokenProvider.resolveToken(request);
            if (token != null && jwtTokenProvider.validateToken(token)) {
                Authentication auth = jwtTokenProvider.getAuthentication(token);
                
                if (auth != null) {
                    SecurityContextHolder.getContext().setAuthentication(auth);
                }
            }
        } catch (Exception e) {
            logger.error("Cannot set user authentication!", e);
        }
 
        filterChain.doFilter(request, response);
    }
 
}

3.3. 登录校验

考虑到jwt签名验签的可靠性,以及jwt的有效载荷并未被加密,所以jwt中放置了UserDetails接口除密码字段外其他所有字段以及项目所需的业务字段。两个目的,1. 浏览器端可以解码jwt的有效载荷部分的内容用于业务处理,由于不涉及到敏感信息,不担心泄密;2.服务端可以通过jwt的信息重新生成UserDetails对象,并设置到SecurityContext中,用于请求拦截的授权校验。

代码如下:

@RestController
@RequestMapping("/web")
public class AuthController {
 
    @PostMapping(value="/authenticate")
    public ResponseEntity<?> authenticate(@RequestBody Map<String, String> param) {
        logger.debug("登录请求参数:{}", param);
 
        try {
            String username = param.get("username");
            String password = param.get("password");
            String reqType = param.get("type");
 
            // 传递用户密码给到SpringSecurity执行校验,如果校验失败,会进入BadCredentialsException
            Authentication authentication = authenticationManager.authenticate(new UsernamePasswordAuthenticationToken(username, password));
            // 验证通过,设置授权信息至SecurityContextHolder
            SecurityContextHolder.getContext().setAuthentication(authentication);
 
            // 如果验证通过了,从返回的authentication里获得完整的UserDetails信息
            SecurityUserDetails userDetails = (SecurityUserDetails) authentication.getPrincipal();
 
            // 将用户的ID、名称等信息保存在jwt的token中
            String token = jwtTokenProvider.createToken(userDetails.getUsername(), userDetails.getCustname(), new ArrayList<>());
 
            // 设置cookie,本项目中非必需,因以要求必须传head的Authorization: Bearer 参数
            ResponseCookie jwtCookie = jwtTokenProvider.generateJwtCookie(token);
            Map<String, Object> model = new HashMap<>();
            model.put("username", username);
            model.put("token", token);
            return ok().body(RespBody.build().ok("登录成功", model));
        } catch (BadCredentialsException e) {
            return ok(RespBody.build().fail("账号或密码错误!"));
        }
    }
}

四、总结

经过以上步骤,对SpringSecurity6结合jwt机制进行校验的过程就全部完成了,jwt的工具类可以按照项目自己的情况进行编码。近期会修改我位于github的示例工程,提供完整的SpringBoot3+SpringSecurity6+jwt的示例工程。目前有一个SpringBoot2的老版本在这里。jwt的工具类也可以参考老版本的这个

Nginx 配置汇总


Nginx配置代理转发的一些要求如下:
1. 代理转发不同地址
2. 能够按IP限速
3. 日志记录请求body内容
4. 只解析自己配置过的域名

以做一个 java api 服务的转发为例,主配置文件/etc/nginx/nginx.conf,内容如下,仅增加log_format api内容。配置文件中默认不带#号之后的内容,为了方便说明,在配置中使用#号说明

http {
    log_format  main  '$remote_addr - $remote_user [$time_local] "$request" '
                      '$status $body_bytes_sent "$http_referer" '
                      '"$http_user_agent" "$http_x_forwarded_for"';

    # 增加api日志格式,记录请求body内容,设置转换成json,中文正常显示
    log_format  api  escape=json '$remote_addr - $remote_user [$time_local] "$request" '
                      '$status $body_bytes_sent "$http_referer" '
                      '"$http_user_agent" "$http_x_forwarded_for" "$request_body"';

    access_log  /var/log/nginx/access.log  main;

    sendfile            on;
    tcp_nopush          on;
    tcp_nodelay         on;
    keepalive_timeout   65;
    types_hash_max_size 4096;

    include             /etc/nginx/mime.types;
    default_type        application/octet-stream;

    include /etc/nginx/conf.d/*.conf;

    # 禁止非域名访问本机,确保本机只解析明确指定的域名,不被非法指向。注意后面的default_server必须指定,否则不会生效。
    server {
        listen       80 default_server;
        server_name  _;
        return 403;
    }
}

api分发的配置文件放置在 /etc/nginx/conf.d/api.conf,能够被nginx.conf自动包含。

# 配置按照IP地址进行请求限速,nginx也支持server_name限速;同样也支持限制单个IP或server_name的TCP连接数
limit_req_zone $binary_remote_addr zone=noneSend:10m rate=1r/m;
limit_req_zone $binary_remote_addr zone=send:10m rate=1000r/s;

server {
    listen 80;
    server_name api.xxxx.cn;

    # 需要被代理的uri前缀,注意不要以/结尾,避免前端请求不规范的时候无法正常被代理分发;在分发之前做了请求限速检查
    location /v/1.0/sendSms {
        # 使用限速
        limit_req zone=send;
        # 配置超速返回429,不配置默认返回503,可以根据需要修改
        limit_req_status 429;
        # 配置单独的访问日志,记录请求的body内容
        access_log  /var/log/nginx/access_api.log  api;
        # 执行代理分发,请求uri和参数会自动给到后端服务
        proxy_pass http://127.0.0.1:7001;
    }


    # 所有需要被代理的请求配置完后,配置一个默认的分发规则
    location / {
        limit_req zone=noneSend nodelay;
        limit_req_status 429;

        proxy_pass http://127.0.0.1:7001;
    }
}

卸载 macOS 自带的 httpd


使用以下命令在 macOS 中停止内置的 Apache 服务器:

sudo apachectl -k stop

然后输入管理员密码就能停止 httpd。如果提示错误,忽略错误信息,下面的命令可以永久停用 httpd:

sudo launchctl unload -w /System/Library/LaunchDaemons/org.apache.httpd.plist

完成后,使用下面的命令检查80端口监听,确保httpd已经停止。

sudo lsof -i:80

【译】Java 17的特点:版本8和17之间的比较,这些年来有什么变化?


原文:Java 17 features: A comparison between versions 8 and 17. What has changed over the years?

新的Java版本每年发布两次,但每一次新的迭代似乎都只是在前一次的基础上有小的改进。虽然这对Java 17来说可能也是如此,但这个版本具有更深的意义,因为Java 8(目前最常用的Java版本)失去了Oracle高级支持。在这篇文章中,我们将探讨最重要的Java 17功能,这两个版本之间的差异,以及它们对Java软件的影响。你应该把你的应用程序从Java 8迁移到17吗?让我们拭目以待。

免责声明:这篇文章最初发表于2021年10月22日。然而,在2022年12月,它被更新了关于Java 8的Oracle企业性能包的新信息。

2022年3月,Java 8失去了Oracle高级支持。这并不意味着它不会收到任何新的更新,但甲骨文投入到维护它的努力可能会比现在小得多。

这意味着有充分的理由转移到新的版本。特别是在2021年9月14日,Java 17被发布。这是新的长期支持版本,Oracle高级支持将持续到2026年9月(至少)。Java 17会带来什么?迁移会有多困难?它值得吗?我将在本文中尝试回答这些问题。

同样值得注意的是,Java 8仍在得到一些扩展–尽管只针对Oracle Java及其昂贵的Java SE订阅。2022年7月19日,一个针对Java 8的Oracle企业性能包被发布。版本号是8u345-PERF-b31。在文章后面比较Java 8和17的特定功能时,会提到这个版本的新增功能。

Java 8的普及 – 历史的小插曲

2014年3月发布的Java 8,目前有69%的程序员在其主要应用中使用。为什么经过7年多的时间,它仍然是最常用的版本?这有很多原因。

Java 8提供了很多语言功能,使开发者愿意从以前的版本转换过来。Lambdas、流、函数式编程、广泛的API扩展 – 更不用说MetaSpace或G1扩展。这是一个值得使用的Java版本。

3年后的2017年9月,Java 9出现了,对于一个典型的开发者来说,它几乎没有什么变化。一个新的HTTP客户端、进程API、小的钻石运算符和try-with-resources改进。

当然,Java 9确实带来了一个重大的变化,甚至是突破性的–Jigsaw项目。它改变了很多,非常多的东西–但在内部。Java模块化带来了巨大的可能性,解决了很多技术问题,适用于每个人,但实际上只有相对较少的用户群需要深入了解这些变化。由于Jigsaw项目引入的变化,很多库需要额外的修改,新的版本被发布,其中一些不能正常工作。

Java 9的迁移–特别是对于大型的企业应用–往往是困难的、耗时的,并引起回归问题。那么,如果没有什么收获,而且要花费大量的时间和金钱,为什么要这样做呢?

Java开发工具包17(JDK 17)于2021年10月发布。现在是不是从8岁的Java 8转移的好时机?首先,让我们看看Java 17里有什么。与Java 8相比,它能给程序员和管理员或SRE带来什么?

Java 17与Java 8的对比 – 变化

这篇文章只涵盖了我认为足够重要或足够有趣的变化。它们并不是 Java 多年来的所有变化、改进和优化。如果你想看JDK的完整变化列表,你应该知道它们是作为JEP(JDK增强建议)被跟踪的。该列表可以在JEP-0中找到。

另外,如果您想比较不同版本的 Java API,有一个很好的工具叫 Java Version Almanac。Java API有许多有用的、小的补充,如果有人想了解所有这些变化,查看这个网站可能是最好的选择。

至于现在,让我们分析一下Java每次迭代中的变化和新功能,从我们大多数Java开发者的角度来看,这些变化和新功能是最重要的。

新的var关键字

增加了一个新的var关键字,允许以一种更简洁的方式声明局部变量。考虑一下这段代码:

// java 8 way
Map<String, List<MyDtoType>> myMap = new HashMap<String, List<MyDtoType>>();
List<MyDomainObjectWithLongName> myList = aDelegate.fetchDomainObjects();
// java 10 way
var myMap = new HashMap<String, List<MyDtoType>>();
var myList = aDelegate.fetchDomainObjects()

当使用var时,声明要短得多,而且,也许比以前更有可读性。我们必须确保首先考虑到可读性,所以在某些情况下,向程序员隐藏类型可能是错误的。注意正确命名变量。

不幸的是,不可能使用var关键字将lambda分配给一个变量:

// causes compilation error: 
//   method reference needs an explicit target-type
var fun = MyObject::mySpecialFunction;

然而,在lambda表达式中使用var是可能的。请看下面的例子:

boolean isThereAneedle = stringsList.stream()
  .anyMatch((@NonNull var s) -> s.equals(“needle”));

在lambda参数中使用var,我们可以给参数添加注解。

Records

纪录岛
人们可以说Records是Java对Lombok的回应。至少有一部分是这样的。记录是一个用来存储一些数据的类型。让我引用JEP 395中的一个片段,它很好地描述了它。

[……]一个记录会自动获得许多标准成员。

  • 为状态描述的每个组件提供一个私有的最终字段。
  • 为状态描述的每个组件提供一个公共的读访问器方法,其名称和类型与组件相同。
  • 一个公共构造函数,其签名与状态描述相同,它从相应的参数初始化每个字段。
  • equals和hashCode的实现,如果两条记录的类型相同且包含相同的状态,则这两条记录是相等的;
  • 以及toString的实现,包括所有记录组件的字符串表示,以及它们的名称。

换句话说,它大致上相当于Lombok的@Value。就语言而言,它有点类似于一个枚举。然而,你不是声明可能的值,而是声明字段。Java根据该声明生成一些代码,并能够以更好的、优化的方式处理它。像枚举一样,它不能扩展或被其他类扩展,但它可以实现一个接口并拥有静态字段和方法。与枚举相反,记录可以用new关键字进行实例化。

一个记录可能看起来像这样:

record BankAccount (String bankName, String accountNumber) implements HasAccountNumber {}

而这就是它。很简洁。简洁是优秀的!

任何自动生成的方法都可以由程序员手动声明。一组构造函数也可以被声明。此外,在构造函数中,所有肯定未被赋值的字段都被隐式地赋值给它们相应的构造函数参数。这意味着,在构造函数中可以完全跳过赋值!

record BankAccount (String bankName, String accountNumber) implements HasAccountNumber {
  public BankAccount { // <-- this is the constructor! no () !
    if (accountNumber == null || accountNumber.length() != 26) {
      throw new ValidationException(“Account number invalid”);
    }
    // no assignment necessary here!
  }
}

对于所有的细节,如正式的语法,使用和实现的注意事项,请务必参考JEP 359。你也可以查看StackOverflow上关于Java记录的最多投票的问题

扩展的开关表达式

很多语言中都有switch,但由于它的局限性,多年来它的作用越来越小。Java的其他部分在增长,switch却没有。现在,switch案例可以更容易地分组,而且更容易阅读(注意,没有中断!),switch表达式本身实际上返回一个结果。

DayOfWeek dayOfWeek = LocalDate.now().getDayOfWeek();
boolean freeDay = switch (dayOfWeek) {
    case MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY -> false;
    case SATURDAY, SUNDAY -> true;
};

新的yield关键字可以实现更多的功能,它允许从代码块内返回一个值。它实际上是一个在案例块内工作的返回,并将该值设置为其开关的结果。它也可以接受一个表达式而不是一个单一的值。让我们来看看一个例子:

DayOfWeek dayOfWeek = LocalDate.now().getDayOfWeek();
boolean freeDay = switch (dayOfWeek) {
    case MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY -> {
      System.out.println("Work work work");
      yield false;
    }
    case SATURDAY, SUNDAY -> {
      System.out.println("Yey, a free day!");
      yield true;
    }
};

Instanceof模式匹配

虽然不是一个突破性的变化,但在我看来,instanceof解决了Java语言中一个比较恼人的问题。你是否曾经不得不使用这样的语法?

if (obj instanceof MyObject) {
  MyObject myObject = (MyObject) obj;
  // … further logic
}

现在,你不必这样做了。Java现在可以在if里面创建一个局部变量,像这样:

if (obj instanceof MyObject myObject) {
  // … the same logic
}

这只是删除了一行,但就代码流程而言,这是完全不必要的一行。此外,声明的变量可以在同一个if条件下使用,像这样:

if (obj instanceof MyObject myObject && myObject.isValid()) {
  // … the same logic
}

封闭类

这是个很难解释的问题。让我们从这个开始–switch中的 “无默认 “警告是否曾经让你感到恼火?你覆盖了领域所接受的所有选项,但警告仍然存在。封闭类让你摆脱了对instanceof类型检查的这种警告。

如果你有一个像这样的层次结构:

public abstract sealed class Animal
    permits Dog, Cat {
}
public final class Dog extends Animal {
}
public final class Cat extends Animal {
}

你现在将能够做到这一点:

if (animal instanceof Dog d) {
    return d.woof();
} 
else if (animal instanceof Cat c) {
    return c.meow();
}

而且你不会得到一个警告。好吧,让我重新表述一下:如果你得到一个类似序列的警告,那么这个警告将是有意义的!这就是为什么你会得到警告。而更多的信息总是好的。

我对这个变化的感觉很复杂。引入一个循环引用似乎不是一个好的做法。如果我在我的生产代码中使用这个,我会尽力把它藏在一个很深的地方,并且永远不向外界展示它–我的意思是,永远不通过API暴露它,而不是说我会为在有效情况下使用它而感到羞耻。

文本块

在Java编程中,声明长字符串的情况并不常见,但一旦发生,就会让人感到厌烦和困惑。Java 13为此提出了一个修复方案,并在以后的版本中进一步改进。现在,一个多行文本块可以按如下方式声明:

String myWallOfText = ”””
______         _   _           
| ___ \       | | (_)          
| |_/ / __ ___| |_ _ _   _ ___ 
|  __/ '__/ _ \ __| | | | / __|
| |  | | |  __/ |_| | |_| \__ \
\_|  |_|  \___|\__|_|\__,_|___/
”””

不需要转义引号或换行。可以转义换行并保持字符串为单行,像这样:

String myPoem = ”””
Roses are red, violets are blue - \
Pretius makes the best software, that is always true
”””

这就相当于:

String myPoem = ”Roses are red, violets are blue - Pretius makes the best software, that still is true”.

文本块可以用来在你的代码中保持一个合理的可读的json或xml模板。外部文件仍然可能是一个更好的主意,但如果有必要,用纯Java来做仍然是一个不错的选择。

更好的NullPointerExceptions

所以,我的应用程序中曾经有这样一连串的呼叫。我想你也可能对它感到熟悉:

company.getOwner().getAddress().getCity();

我得到了一个NPE,它准确地告诉我在哪一行遇到了null。是的,就是那一行。没有调试器,我无法知道哪个对象是空的,或者说,哪个调用操作实际上导致了这个问题。现在消息会很具体,它会告诉我们,JVM “无法调用Person.getAddress()”。

实际上,这更像是JVM的变化,而不是Java的变化–因为构建详细消息的字节码分析是在运行时JVM进行的–但它确实对程序员有很大的吸引力。

新的HttpClient

有很多库可以做同样的事情,但在Java中拥有一个合适的HTTP客户端是很好的。你可以在Baeldung中找到关于新的API的一个很好的介绍。

新增Optional.orElseThrow()方法

一个关于Optional的get()方法被用来获取Optional下的值。如果没有值,这个方法会抛出一个异常。就像下面的代码:

MyObject myObject = myList.stream()
  .filter(MyObject::someBoolean)
  .filter((b) -> false)
  .findFirst()
  .get();

Java 10在Optional中引入了一个新方法,叫做orElseThrow()。它的作用是什么?完全一样! 但是考虑到程序员的可读性变化。

MyObject myObject = myList.stream()
  .filter(MyObject::someBoolean)
  .filter((b) -> false)
  .findFirst()
  .orElseThrow();

现在,程序员清楚地知道当对象未被找到时将会发生什么。事实上,我们推荐使用这个方法,而不是简单的、无处不在的get()。

JVM 17与JVM 8的变化

Project Jigsaw

JDK 9的Project Jigsaw大大改变了JVM的内部结构。它改变了JLS和JVMS,增加了几个JEP(可在上面的Project Jigsaw链接中找到列表),最重要的是,引入了一些破坏性的变化,这些变化与以前的Java版本不兼容。

Java 9模块被引入,作为一个额外的、最高级别的jar和类组织。关于这个话题有很多介绍性的内容,比如Baeldung上的这个,或者Yuichi Sakuraba的这些幻灯片。

收益很大,虽然肉眼看不出来。所谓的JAR地狱已经不存在了(你去过吗? 我去过……而且真的是一个地狱),尽管现在模块地狱也是一种可能。

从一个典型的程序员的角度来看,这些变化现在几乎是看不见的。只有最大和最复杂的项目可能会受到某种程度的影响。几乎所有常用的库的新版本都遵守新的规则,并在内部考虑到这些规则。

垃圾回收

从Java 9开始,G1是默认的垃圾收集器。与Parallel GC相比,它减少了暂停时间,尽管它的总体吞吐量可能较低。自从成为默认的垃圾收集器后,它经历了一些变化,包括将未使用的承诺内存返回给操作系统的能力(JEP 346)。

在Java 11中引入了ZGC垃圾收集器,并在Java 15中达到产品状态(JEP 377)。它的目的是进一步减少停顿。从Java 13开始,它还能够将未使用的已承诺内存返回给操作系统(JEP 351)。

在JDK 14中引入了Shenandoah GC,并在Java 15中达到产品状态(JEP 379)。它的目的是保持较低的暂停时间,并且与堆的大小无关。

请注意,在Java 8中,你的选择要少得多,如果你没有手动改变你的GC,你仍然使用并行GC。简单地切换到Java 17可能会使你的应用程序工作得更快,方法运行时间更一致。切换到,然后不可用的ZGC或Shenandoah可能会得到更好的结果。

最后,还有一个新的No-Op垃圾收集器可用(JEP 318),尽管它是一个实验性的功能。这个垃圾收集器实际上不做任何工作–因此允许你精确测量你的应用程序的内存使用情况。如果你想尽可能地保持你的内存操作吞吐量,那就很有用。

如果你想了解更多关于可用选项的信息,我推荐你阅读Marko Topolnik的一系列伟大的文章,对GCs进行了比较。

前面提到的G1垃圾收集器是在Oracle企业性能包8u345版本中添加到Oracle Java 8的。与Compact Strings一起,它可以对Java应用程序的内存消耗产生重大影响。

Container感知

如果你不知道,曾经有一段时间,Java不知道它是在一个容器中运行。它没有考虑到容器的内存限制,而是读取可用的系统内存。因此,当你有一台拥有16GB内存的机器,将你的容器的最大内存设置为1GB,并在上面运行一个Java应用程序时,应用程序往往会失败,因为它将试图分配比容器上可用的更多的内存。Carlos Sanchez的一篇好文章更详细地解释了这一点。

这些问题现在已经成为过去。从Java 10开始,容器集成被默认启用。然而,这对你来说可能不是一个明显的改进,因为在Java 8的更新131中也引入了同样的变化,尽管它需要启用实验性选项并使用-XX:+UseCGroupMemoryLimitForHeap。

PS:使用-Xmx参数指定Java的最大内存通常是个好主意。在这种情况下,问题就不会出现。

CDS档案

为了使JVM的启动速度更快,在Java 8发布后的这段时间里,CDS档案经历了一些变化。从JDK 12开始,在构建过程中创建CDS档案是默认启用的(JEP 341)。JDK 13中的一项改进(JEP 350)允许在每个应用程序运行后更新档案。

类数据共享也在Oracle企业性能包8u345版本中为Java 8实现。然而,目前还不清楚这些变化的意义有多大;描述表明,只增加了JEP 310的范围。然而,我无法确认这一点。

Nicolai Parlog的一篇很好的文章演示了如何使用这个功能来改善你的应用程序的启动时间。

Java Flight Recorder and Java Mission Control

Java Flight Recorder(JEP 328)允许以较低的(目标1%)性能成本对运行中的Java应用程序进行监控和分析。Java Mission Control允许摄取和可视化JFR数据。请看Baeldung的教程,大致了解如何使用它以及可以从中得到什么。

你应该从Java 8迁移到Java 17吗?

简而言之:是的,你应该这样做。如果你有一个大型的、高负荷的企业应用,并且仍在使用Java 8,你肯定会在迁移后看到更好的性能、更快的启动时间、更低的内存占用率。从事该应用的程序员也应该更高兴,因为语言本身有很多改进。

然而,这样做的成本是很难估计的,而且根据所使用的应用服务器、库和应用程序本身的复杂性(或者说是它使用/重新实现的低级功能的数量)而有很大差异。

如果你的应用是微服务,很可能你只需要将基础docker镜像改为17-alpine,将maven中的代码版本改为17,一切就能正常工作了。一些框架或库的更新可能会派上用场(但无论如何你都会定期进行更新,对吧?)

现在,所有流行的服务器和框架都支持Java 9的Jigsaw项目。它是生产级的,经过了大量的测试,并在多年后修复了错误。许多产品提供了迁移指南,或者至少为Java 9兼容的版本提供了广泛的发布说明。请看OSGI的一篇不错的文章或Wildfly 15的一些发布说明,其中提到了模块支持。

如果你使用Spring Boot作为你的框架,有一些文章可以提供迁移技巧,比如spring-boot wiki中的这篇,Baeldung上的这篇,以及DZone上的另一篇。infoq也有一个有趣的案例研究。将Spring Boot 1迁移到Spring Boot 2是一个不同的话题,可能也值得考虑。Spring Boot本身有一个教程,Baeldung上也有一篇文章涉及这个话题。

如果你的应用程序没有自定义类加载器,没有严重依赖Unsafe,没有大量使用sun.misc或sun.security,那么你可能会没事。请参考JDEP关于Java依赖性分析工具的这篇文章,了解你可能需要做的一些改变。

有些东西从第8版开始就从Java中删除了,包括Nashorn JS引擎、Pack200 APIs和工具、Solaris/Sparc端口、AOT和JIT编译器、Java EE和Corba模块。有些东西仍然存在,但已被废弃删除,如Applet API或安全管理器。由于有很好的理由将其删除,你应该重新考虑在你的应用程序中使用它们。

我询问了我们Pretius的项目技术负责人关于他们从Java 8到Java 9+迁移的经验。有几个例子,没有一个是有问题的。在这里,一个库不工作,不得不更新;在那里,需要一些额外的库或配置,但总的来说,这根本不是一个糟糕的经历。

总结

Java 17 LTS将在未来几年内得到支持。另一方面,Java 8的支持已经结束。这当然是考虑转移到新版本的Java的坚实理由。在这篇文章中,我介绍了第8版和第17版之间最重要的语言和JVM变化(包括一些关于Java 8到Java 9+迁移过程的信息),这样就更容易理解它们之间的差异–以及评估迁移的风险和收益。

如果你碰巧是你公司的决策者,要问自己的问题是:会不会有 “好时机 “把Java 8留下来?有些钱总是要花的,有些时间总是要消耗的,有些需要做的额外工作的风险总是存在的。如果永远没有 “好时机”,那么现在很可能是一个好时机,就像永远不会有一样。

Java 17 特性的常见问题

Java 8是什么时候发布的?

Java 8 是在 2014 年 3 月发布的。

Java 17是什么时候发布的?

Java 17于2021年9月15日发布。

Java的最新版本是什么?

Java的最新版本是Java 19,于2022年9月发布。

我有什么版本的Java?

您可以在Java控制面板的 “常规 “选项卡中的 “关于 “部分查看您当前的Java版本。您也可以在您的bash/cmd中输入以下命令:

java -version

什么是Java 17?

它是具有长期支持的Java SE platform 的最新版本。

如何更新到Java 17?

安装软件只需运行可执行文件即可,但让您的系统为这一变化做好准备可能更复杂。

JDK 17 有哪些新功能?

JDK 17 是一个大型的 Java 更新,有大量的改进和新东西。它提供了以下新功能:

  1. 增强的伪随机数生成器
  2. 恢复始终严格的浮点运算语义
  3. macOS/AArch64支持
  4. 新的macOS Rendering pipelines
  5. 强化封装的JDK内部结构
  6. 废弃Applet API,以便于将来删除
  7. Switch 的模式匹配(预览)
  8. 封闭的类(Sealed Classes)
  9. 移除RMI的激活
  10. 撤销安全管理器
  11. 外来函数和内存API(孵化)
  12. 移除实验性AOT和JIT编译器
  13. 特定上下文的反序列化过滤器
  14. 矢量API(第二孵化)

Java 17和Java 18之间有什么区别

Java 17是一个长期支持版本–它至少会被支持8年。另一方面,Java 18只是一个较小的更新,有一些额外的功能,支持期为6个月。

JDK 17中包括JRE吗?

是的,与所有 JDK 版本一样,JDK 17 包括 Java 17 JRE。

什么是 Java 8 的企业性能包?

这是一种付费订阅,您可以通过购买它在 Java 8 中获得一些 Java 17 的功能,如 G1 垃圾收集器。

【译】选择最佳垃圾收集算法,以获得更好的Java性能


原文:Choosing the Best Garbage Collection Algorithm for Better Performance In Java

在这篇文章中,我将解释垃圾收集是如何在幕后工作以释放内存的。在过去的几个 Java版本中,Java内存管理已经出现了很多。了解不同的GC算法将帮助你更好地调整它(如果需要的话),这取决于我们在许多基于Java的应用程序性能测试中看到的不同性能问题。当你的Java应用程序运行时,它会创建占用内存空间的对象。只要该对象被使用(即被应用程序引用的地方),它就会占用内存。当对象不再被使用时(例如,当你干净地关闭一个DB连接时),对象所占用的空间就可以被垃圾收集器回收。

对于任何基于Java的应用程序的性能测试,如何为你的用例选择最好的垃圾收集器?在我们进入这个问题之前,让我们先谈谈下面的一些基本概念。

权衡利弊

当我们谈论垃圾收集时,一般需要考虑三件事。

  1. 内存

这是分配给程序的内存量,这被称为HEAP内存。请不要与footprint(GC算法运行所需的内存量)相混淆。

  1. 吞吐量

我们需要了解的第二件事是吞吐量。吞吐量是指代码运行的时间与你的垃圾收集运行的时间相比有多少。例如,如果你的吞吐量是99%,这意味着99%的时间代码在运行,1%的时间垃圾收集在运行。对于任何高容量的应用程序,我们希望在我们运行的任何负载测试中,尽可能地提高吞吐量。

  1. 延迟性

我们需要了解的第三个方面是延迟(LATENCY)。延迟是指每当垃圾收集运行时,我们的程序为垃圾收集的正常运行停止多少时间。所有这些都是以毫秒为单位的,但它们可以达到几秒钟,这取决于内存的大小和我们为负载测试选择的垃圾收集算法。理想情况下,我们希望LATENCY尽可能的低或者尽可能的可预测。

垃圾收集的代际假说

这个假说说,大多数被创建的对象都是早死的。当一个对象不能再被访问时,它就被标记为符合垃圾收集的条件,这可能发生在对象超出范围时。当一个对象的引用变量被分配一个明确的空值或被重新初始化时,也可能发生。如果一个对象不能被访问,这意味着任何活的线程都不能通过程序中使用的任何引用变量访问它。

垃圾收集算法将你的堆内存大小分成YOUNG一代和OLD一代。当我们第一次创建对象时,它们被保留在年轻一代中,大多数对象在年轻时就死亡了,或者它们很快就有资格进行垃圾收集,这就是为什么我们有大量的垃圾收集运行在年轻一代上,这种收集被称为Minor GC。

如果有一些对象,比如类级变量,也被称为实例级变量,它们的寿命会更长,即使经过多次小规模的GC收集,当这些对象仍然不符合垃圾收集的条件时,它们会被提升到OLD一代。每当有大量的对象在OLD代中,比方说,如果OLD代中占用的内存空间超过了阈值,例如60%或70%,那么就会触发Major GC。

垃圾收集算法的步骤

任何垃圾收集算法都有三个基本步骤。

  1. 标记
    • 这是第一步,GC通过内存中的对象图,从所有对多个对象的引用的根节点开始,将可以到达的对象标记为活的。当标记阶段结束时,每个活的对象都被标记。这个标记阶段的持续时间取决于活着的对象的数量,直接增加堆的内存并不影响标记阶段的持续时间。
  2. 扫除
    • 无论哪个对象都是可触及的,没有被触及的对象都会被删除,并重新获得内存。
  3. 压缩
    • 夯实是将所有东西按顺序排列的过程。这一步是通过压缩内存来消除内存碎片,以消除分配的内存区域之间的空隙。

标记和复制算法

在YOUNG世代中,一般来说,空间被分为EDEN空间和两个SURVIVOR SPACE 1和SURVIVOR SPACE 2的幸存者空间。

所有在内存中创建的新对象首先被分配到EDEN空间。每当Minor GC运行时,只有EDEN空间的活对象被标记并复制到SURVIVOR空间,这包括以下步骤

  1. 它首先将所有的对象标记为活的,这意味着这些对象仍在被使用或被引用,不符合垃圾收集的条件。
  2. 将所有活着的对象复制到S1或S2的SURVIVOR空间。

一旦它复制了所有的活对象,现在这个EDEN空间由已经被复制的对象和符合垃圾收集条件的对象组成,整个EDEN空间就被清除掉了。

MARK SWEEP和COMPACT算法

这通常是在旧的一代运行。假设我们有很多被分配的对象,其中有些是活的,有些是符合垃圾收集条件的。首先,我们将只标记活的对象。其次,我们将清扫并删除所有符合垃圾收集条件的对象,然后它将删除空格并使其成为空白,从技术上讲,我们并没有删除空格,数据结构本身被更新,说空格是空的。第三个方面是压缩,我们将把所有仍在使用的活对象移到左边,并把它们集中在一起。这种方法的缺点是增加了GC暂停的时间,因为我们需要把所有的对象复制到一个新的地方,并更新所有对这些对象的引用。

紧凑的好处是,当我们想分配新的对象时,我们所要做的就是保持一个指针和引用,说明左边的所有东西都被利用了,右边的所有东西都被释放了。

串行垃圾收集器(-XX:+UseSerialGC)

串行收集器是所有收集器中占地面积最小的。这个垃圾收集器运行所需的数据结构量的足迹是非常小的。这个收集器使用一个单线程来进行小收集和大收集。串行收集器使用凹凸指针技术进行压缩,这就是为什么分配的速度更快。这种收集器一般最适合在共享CPU上运行的应用程序,其内存量非常小。

让我们想象一下,我们有一个QUAD CORE CPU,四个应用程序在上面运行。如果你的垃圾收集器不是单线程的,而是多线程的,在某个时间点上,我们的垃圾收集器会在CPU的四个核上启动所有的四个线程,并利用整个CPU进行自己的垃圾收集,这时在CPU上运行的其他应用程序会受到影响。如果有多个应用程序运行在一个CPU上,并且我们必须确保我们的垃圾收集器不影响其他核心或应用程序,那么我们可以使用串行垃圾收集器。

并行/吞吐量收集器 (-XX:+UseParallelGC , -XX:+UseParallelOldGC)

下一个需要了解的收集器叫做并行收集器。我们有Parallel Collector和Parallel Old Collector。我们一般只使用Parallel Old Collector,它在Minor GC和Major GC上都使用多个线程。这个收集器并不与应用程序同时运行。它被命名为Parallel是因为它有多个垃圾收集线程,所有这些线程都是平行运行的,但是当垃圾收集器运行时,所有的线程都是停止的,如果我们的应用程序被部署在多核或多处理器系统上,这个收集器会给我们带来最大的吞吐量。

在最短的时间内,它将能够收集尽可能多的垃圾。它可以停止整个应用程序,并且可以停止一段时间,它是最好的收集器,只用于批处理应用程序。在批处理应用中,我们不关心用户和响应时间,因为在前端没有用户,它的批处理应用在幕后运行。对于批处理应用,并行收集器将是最好的选择。

并发标记和扫描收集器 (-XX:+UseConcMarkSweepGC, -XX:+UseParNewGC)

这被称为并发标记和扫除。这个收集器与应用程序同时运行,以标记所有实时对象。应用程序必须停止的时间较少,所以应用程序的延迟也较少。在实际收集中,它仍然有STW暂停。STW也被称为 “停止世界”(Stop the World)的停顿,这意味着它在非常小的时间内停止应用程序来进行实际的垃圾收集。这种CMS收集器比并行收集器需要更多的空间,它有更多的数据结构需要处理。它的吞吐量比并行收集器小,但优点是它的暂停时间比并行收集器小。这种收集器是所有普通Java应用程序中最常用的收集器。

G1收集器(-XX:+UseG1GC)(垃圾优先)

对CMS收集器的改进被称为G1收集器。这种收集器没有为Heap设置特定的年轻一代和老一代,而是使用整个Heap并将其划分为多个区域。它有更多的占用空间,这种收集器的优点是它有最可预测的延迟,这是该收集器的最好的特点。当我们启动我们的应用程序时,我们可以在这个变量上传递我们的应用程序可以承受的最大暂停时间(maxTargetPauseTime),例如10ms。G1收集器将努力确保垃圾收集只在10ms内完成,即使有一些垃圾残留,它也会在下一个周期内处理。如果我们想获得可预测的延迟和暂停时间,G1收集器将是最好的收集器。这是最常用的收集器,可以满足所有的性能测试需求。

Shenandoah收集器(-XX:+UseShenandoahGC)

还有一个收集器叫做Shenandoah收集器。这个收集器是在G1收集器的基础上改进的,它需要更多的占用空间,所以它在幕后需要更多的数据结构,但它的延迟比G1收集器更低。

Shenandoah是一个超低暂停时间的垃圾收集器,通过与运行中的Java程序同时进行更多的垃圾收集工作来减少GC的暂停时间。CMS和G1都对实时对象进行并发标记。Shenandoah增加了并发压缩功能。

Epsilon Collector(-XX:+UseEpsilonGC) – JDK 的无为收集器

JDK 11中引入的Epsilon垃圾收集器是一个实验性的收集器,只分配内存。它不能释放任何已分配的内存,所以应用程序很可能因为OutOfMemoryError而崩溃。Epsilon收集器中的GC不做任何GC循环,因此不关心对象图、对象标记、对象复制等问题。一旦Java堆被耗尽,就不可能进行分配,不可能进行内存回收,因此测试会失败。

最显著的优点是没有GC开销,JVM不会暂停清除内存,因为它甚至不会尝试释放任何内存。Epsilon GC已经被添加为一个基准,用于测试应用程序的性能、内存使用、延迟和吞吐量的改进。Epsilon收集器帮助我们计算出Java虚拟机(JVM)用尽所有内存并关闭所需的时间。Epsilon GC有助于测试原始应用程序的性能,没有GC的干扰,也没有嵌入代码中的GC障碍。在JDK 11中,Epsilon GC功能默认是禁用的,我们必须启用才能使用这个收集器。

对于超延迟敏感的应用程序,要完全了解内存分配、内存占用,以及了解程序的性能受垃圾收集影响的程度,Epsilon收集器是最好用的。

(ZGC) Z垃圾收集器 (-XX:+UseZGC)

Z垃圾收集器(ZGC)是可扩展的,具有低延迟性。它是一个全新的GC,从头开始编写。它可以标记内存,复制和重新定位内存,所有这些都是并发的,它可以在堆内存中工作,范围从KBs到大型TB内存。作为一个并发的垃圾收集器,ZGC保证不超过应用延迟10毫秒,即使是更大的堆大小。ZGC最初是作为Java 11(Linux)的实验性GC发布的,随着时间的推移,预计JDK 11、13和14会有更多的变化。

在ZGC中,stop-the-world的暂停只限于根扫描。它使用带有彩色指针的负载屏障来执行线程运行时的并发操作,它们被用来跟踪堆的使用。有色指针是ZGC的核心概念之一,它使ZGC能够找到、标记、定位和重新映射对象。与G1相比,ZGC有更好的方法来处理非常大的对象分配,这在回收内存和重新分配内存时有很高的性能,它是一个单代GC。

ZGC将内存划分为若干区域,也称为ZPages。这些ZPages可以动态地创建和销毁,也可以动态地确定大小。与其他GC不同,ZGC的物理堆区域可以映射到一个更大的堆地址空间(可以包括虚拟内存),这可以避免内存碎片问题。

结论

一般来说,串行收集器适用于小型设备,或者当我们想确保GC不影响其他应用程序或CPU时,并行收集器最适合批量应用程序,CMS收集器用于一般应用程序,G1收集器最适合可预测的延迟,Shenandoah收集器是G1的改进,我们将能够在几个版本的Java中作为默认收集器使用(从Java 12)。Epsilon和ZGC收集器是在JDK 11中引入的新的实验性收集器,它们在不同的版本中仍然经历着许多变化。

参考资料有很多,我真诚地感谢所有的Java大师:)

https://blogs.oracle.com/javamagazine/

http://plumbr.io/

https://www.journaldev.com/

谢谢你阅读这篇文章:) 学习愉快:)

全文Translated with DeepL,致谢DeepL。

在 OpenJDK17 中使用 Shenandoah 垃圾回收器


摘要

此文章概述了 Shenandoah 垃圾收集器,并解释了如何在 OpenJDK17 中配置它。

Shenandoah 是低暂停时间的垃圾收集器,通过与正在运行的 Java 程序同时执行更多垃圾收集工作来减少 GC 暂停时间。Shenandoah 同时完成大部分GC工作,包括并发压实,这意味着其暂停时间不再与堆的大小成正比。收集200 GB堆或2 GB堆的垃圾应该具有类似的低暂停行为。

JDK8、11、17都提供了生产状态的 Shenandoah 垃圾收集器。

Shenandoah GC 实现描述

Shenandoah是区域化的收集器,它将堆维护为区域的集合。
常规的Shenandoah GC周期看起来像这样:

上述阶段的工作大致如下:
Init Mark启动了并发标记。它为并发标记准备了堆和应用线程,然后扫描根集。这是周期中的第一个暂停,最主要的消费是根集扫描。因此,它的持续时间取决于根集的大小。

Concurrent Marking在遍历堆内存,并追踪可到达的对象。这个阶段与应用程序一起运行,其持续时间取决于活对象的数量和堆中对象图的结构。由于应用程序在这个阶段可以自由分配新的数据,所以在并发标记期间,堆的占用率会上升。

Final Mark是所有待定的标记/更新队列已经全部清理完,并重新扫描完根集。它还通过找出要疏散的区域(集合集)来初始化疏散,预先疏散一些根,并为下一阶段的运行时间做一般准备。这些工作的一部分可以在并发预清理阶段同时完成。这是周期中的第二个暂停,这里最主要的时间消耗者是清理所有待定标记/更新队列和扫描根集。

Concurrent Cleanup回收即时垃圾区域–也就是在并发标记后检测到的没有活对象的区域。

Concurrent Evacuation(并发疏散)将对象从收集集中复制到其他区域。这是与其他 OpenJDK GC 的主要区别。这个阶段再次与应用程序一起运行,因此应用程序可以自由分配。它的持续时间取决于该周期所选择的集合集的大小。

Init Update Refs(Init-UR)初始化了更新引用阶段。除了确保所有的GC和应用程序线程都完成了疏散,然后为下一阶段的GC做准备外,它几乎什么都不做。这是周期中的第三次暂停,是所有暂停中最短的一次。

Concurrent Update References(并发更新引用)遍历堆内存,并更新在并发疏散过程中被移动的对象的引用。这是与其他 OpenJDK GC 的另一个主要区别。它的持续时间取决于堆中对象的数量,但不取决于对象图结构,因为它是线性扫描堆的。这个阶段与应用程序同时运行。

Final Update Refs 重新更新了现有的根集,并完成了更新引用。它也从集合中回收了内存区域,因为现在堆中没有陈旧对象的引用。这是循环中的最后一次暂停,其持续时间取决于根集的大小。

Concurrent Cleanup(并发清理)回收了集合的区域,现在这些区域已经没有引用了。

性能指南

堆尺寸:Shenandoah的性能与几乎所有其他GC的性能一样,取决于堆的大小。当并发阶段运行时有足够的堆空间来容纳分配时,它应该表现得更好。并发阶段的时间与实时数据集大小(LDS)相关,LDS是实时数据占用的空间。因此,合理的堆大小取决于LDS和工作量中的分配压力:对于给定的分配率,较大的LDS-es需要比例更大的堆尺寸;对于给定的LDS,更大的分配率需要更大的堆尺寸。对于一些具有微小实时数据集和中等分配压力的工作负载,1~2 GB堆性能良好。我们定期在各种工作负载上测试4~128 GB堆,LDS大小高达80%。不要害怕尝试不同的堆尺寸,看看什么适合你的工作量。

暂停:Shenandoah的暂停行为主要由根集操作主导:扫描和更新根。根集包括:局部变量、嵌入生成代码中的引用、内部字符串、类加载器的引用(例如静态最终引用)、JNI引用、JVMTI引用。拥有更大的根集通常意味着与Shenandoah的暂停时间更长,除非具体的JDK版本具有同时完成部分工作的能力,并且Shenandoah能够使用它。二阶效果是:a)弱引用处理(发生在Final Mark暂停中),但仅适用于需要处理的引用;b)类卸载和其他JDK清理(也发生在Final Mark暂停中)。这些二阶效果可以通过配置其他选项来控制处理频率(包括完全禁用它)和/或修改应用程序以更好地播放来缓解。

吞吐量:由于Shenandoah是并发GC,它在收集周期内使用障碍来保持不变量。这些障碍可能会导致可衡量的吞吐量损失。请参阅下面的诊断部分,了解如何剖析那里发生的事情。一些用户报告说,障碍造成的吞吐量损失是通过自然地将并发GC工作卸载到备用和闲置核心来偿还的;换句话说,在某些情况下,它用更高的应用程序+JVM利用率来换取更高的应用程序吞吐量。

在大多数情况下,暂停时间在0~10ms以内,吞吐量损失在0~15%以内。实际性能数字在很大程度上取决于实际应用程序、负载配置文件等。对于没有大量根、弱引用和/或类搅动的应用程序,暂停可能在亚毫秒范围内。对于没有如此多的突变堆或被当前编译器很好地优化的应用程序,障碍开销可能接近于零。

使用 Shenandoah GC 运行Java应用程序

  1. 下载安装,下载地址:https://adoptium.net/zh-CN/
  2. 使用-XX:+UseShenandoahGC JVM选项使用Shenandoah GC运行Java应用程序。
    • $ java <PATH_TO_YOUR_APPLICATION> -XX:+UseShenandoahGC

Shenandoah垃圾收集器的基本配置选项

Shenandoah垃圾收集器(GC)具有以下基本配置选项:

  • -Xlog:gc
    • 打印单个GC时间。
  • -Xlog:gc+ergo
    • 打印启发式决策,这可能会揭示异常值(如果有的话)。
  • -Xlog:gc+stats
    • 在一次垃圾回收运行结束时打印Shenandoah内部时间的汇总表。
    • 最好是在启用日志记录的情况下运行。这个汇总表传达了关于GC性能的重要信息。启发式日志对于找出GC的异常值很有用。
  • -XX:+AlwaysPreTouch
    • 将堆页提交到内存中,并有助于减少延迟小问题。
  • -Xms and -Xmx
    • 用 -Xms = -Xmx 使堆不可重扩,减少了堆管理的困难。与AlwaysPreTouch一起,-Xms = -Xmx在启动时提交所有的内存,这就避免了最终使用内存时的困难。-Xms还定义了内存不提交的低边界,所以在-Xms = -Xmx的情况下,所有内存都保持提交。如果你想把Shenandoah配置成一个较低的占用率,那么建议设置较低的-Xms。你需要决定设置多低来平衡提交/未提交的开销和内存占用。在许多情况下,你可以将-Xms任意设置得很低。
  • -XX:+UseLargePages
    • 启用 hugetlbfs Linux支持
  • -XX:+UseTransparentHugePages
    • 透明地启用巨大的页面。使用透明的巨大页面,建议将 /sys/kernel/mm/transparent_hugepage/enabled 和 /sys/kernel/mm/transparent_hugepage/defrag 设置为 madvise。当与AlwaysPreTouch一起运行时,它也会在启动时预先支付碎片整理工具的成本。
  • -XX:+UseNUMA
    • 虽然Shenandoah还没有明确支持NUMA,但在多插槽主机上启用NUMA交织是一个好主意。再加上AlwaysPreTouch,它提供了比默认的开箱配置更好的性能。
  • -XX:-UseBiasedLocking
    • 在无争议(偏向)锁定的吞吐量和JVM为启用和禁用它们所做的安全点之间有一个权衡。对于面向延迟的工作负载,关闭偏向性锁定。
  • -XX:+DisableExplicitGC
    • 从用户代码调用System.gc()迫使Shenandoah执行额外的GC周期。它通常不会造成伤害,因为-XX:+ExplicitGCInvokesConcurrent默认启用,这意味着将调用并发GC周期,而不是STW(Stop the world) Full GC。

实际Java程序启用的参数

[root@localhost ~]# java -server -Xms1g -Xmx1g -XX:+UseShenandoahGC -Xlog:gc -Xlog:gc+stats -XX:+AlwaysPreTouch -XX:+UseNUMA -jar <PATH_TO_YOUR_APPLICATION>

CentOS 8 Stream 编译安装 Python 3.11.1


一、更新系统

[root@localhost ~]# dnf update

二、安装依赖

[root@localhost ~]# dnf groupinstall 'development tools'
[root@localhost ~]# dnf install wget yum-utils make gcc openssl-devel bzip2-devel libffi-devel zlib-devel

三、下载 Python 3.11.1 源代码

[root@localhost ~]# wget https://www.python.org/ftp/python/3.11.1/Python-3.11.1.tgz

四、解压缩源码

[root@localhost ~]# tar xf Python-3.11.1.tgz
[root@localhost ~]# cd Python-3.11.1

五、执行配置,注意添加“–enable-shared”参数,否则 pyinstaller 执行打包的时候报错:“OSError: Python library not found: libpython3.11mu.so.1.0, libpython3.11m.so, libpython3.11m.so.1.0, libpython3.11.so.1.0, libpython3.11.so”

[root@localhost ~]# ./configure --enable-shared --enable-optimizations

六、使用服务器配置的CPU核心数执行并行编译

[root@localhost ~]# make -j ${nproc}

七、执行安装

[root@localhost ~]# make altinstall

八、确认版本,出现共享库文件错误

[root@localhost ~]# python3.11 -V
python3.11: error while loading shared libraries: libpython3.11.so.1.0: cannot open shared object file: No such file or directory

九、确认共享库文件 libpython3.11.so.1.0 的位置

[root@localhost ~]# find / -name libpython3.11.so.1.0
/root/Python-3.11.1/libpython3.11.so.1.0
/usr/local/lib/libpython3.11.so.1.0

十、修改 ld.so 配置,使 Python3.11 共享库生效

[root@localhost ~]# echo "/usr/local/lib/" >> /etc/ld.so.conf.d/python3.11.conf

十一、使配置生效

[root@localhost ~]# ldconfig

十二、再次确认 Python 版本,安装正常

[root@localhost ~]# python3.11 -V
Python 3.11.1

十三、在项目中执行打包

[root@localhost pj-kpi]# source bin/active
(pj-kpi) [root@gitlab pj-kpi]# pyinstaller pj_kpi/kpi.py --onefile --noupx

使用Bucardo配置PostgreSQL14数据库双主同步


一、前言

  • 目标是 PostgreSQL 的双主同步
  • Bucardo 官方网站的手册语焉不详
  • Bucardo 有限制,不同步DDL,也就是表结构变化不会同步,大对象也不会同步,表必须有唯一主键
  • Bucardo 是异步同步,如果写入库生效了,突然宕机,会存在数据丢失的可能性
  • Bucardo 支持指定同步表

二、环境

  • 操作系统:CentOS 8 Stream
  • 数据库: PostgreSQL 14
  • Bucardo版本:5.6.0
  • 禁用防火墙
$ systemctl stop firewalld
$ systemctl disable firewalld
  • 禁用 SELinux
$ setenforce 0
$ sed -i 's/SELINUX=enforcing/SELINUX=disabled/g' /etc/selinux/config

三、安装软件

1. 安装EPEL包

$ yum install epel-release

2. 安装 PostgreSQL 14

# Install the repository RPM (for CentOS 8):
$ yum -y install https://download.postgresql.org/pub/repos/yum/reporpms/EL-8-x86_64/pgdg-redhat-repo-latest.noarch.rpm
# Install packages, disable default postgresql module
$ dnf -qy module disable postgresql
$ dnf -y install postgresql14 postgresql14-server
# Initialize your PostgreSQL DB
$ /usr/pgsql-14/bin/postgresql-14-setup initdb
$ systemctl start postgresql-14
# Optional: Configure PostgreSQL to start on boot
$ systemctl enable --now postgresql-14

3. 安装 Bucardo 依赖包

$ yum install perl-DBIx-Safe perl-DBD-Pg postgresql14-plperl

4. 查看 perl 可用包

$ dnf module list perl

5. 安装 bucardo 需要的依赖环境 perl5

$ dnf module -y install perl:5.26/common 

6. 下载 Bucardo 源代码,并解压缩

$ wget https://bucardo.org/downloads/Bucardo-5.6.0.tar.gz
$ tar xzf Bucardo-5.6.0.tar.gz
$ cd Bucardo-5.6.0

7. 编译 Bucardo

$ perl Makefile.PL
$ make
$ make install

四、配置 Bucardo 运行环境

Bucardo 需要被安装到一个数据库中。为了做双主,我们在两台 PostgreSQL 数据库服务器上都安装 Bucardo,远端的机器为同步的目标机器。

1. 修改 PostgreSQL 监听,允许非本机访问

$ vim /var/lib/pgsql/14/data/postgresql.conf 

注意修改 listen_addresses 属性

# - Connection Settings -

listen_addresses = '*'          # what IP address(es) to listen on;
                                        # comma-separated list of addresses;
                                        # defaults to 'localhost'; use '*' for all
                                        # (change requires restart)
port = 5432                             # (change requires restart)
max_connections = 500                   # (change requires restart)

2. 配置 PostgreSQL 允许 bucardo 无密码访问

$ vim /var/lib/pgsql/14/data/pg_hba.conf

注意增加的配置

# IPv4 local connections:
host    all             all             127.0.0.1/32            scram-sha-256
host    all             bucardo         127.0.0.1/32            trust
host    all             bucardo         192.168.0.218/32        trust
host    all             bucardo         192.168.0.219/32        trust
# IPv6 local connections:
host    all             all             ::1/128                 scram-sha-256
host    all             bucardo         ::1/128                 trust

注意:bucardo 这一行一定要设置成 trust,也就是信任 127.0.0.1 的任何访问,不会校验密码。同时也配置本机内网IP和对端主机的 Bucardo 账号访问不需要密码。

否则在执行初始化时会出现以下错误:

DBI connect('dbname=bucardo;host=localhost;port=5432','bucardo',...) failed: connection to server at "localhost" (127.0.0.1), port 5432 failed: fe_sendauth: no password supplied at /usr/bin/bucardo line 9162.

这是因为在/usr/bin/bucardo的9162行,连接数据库的时候,账号名称默认是bucardo,密码为空字符串。

$dbh = DBI->connect($BDSN, 'bucardo', '', {AutoCommit=>0,RaiseError=>1,PrintError=>0});

3. 配置完成,重启数据库

$ systemctl restart postgresql-14

4. 在 PostgreSQL 中配置 Bucardo 需要的空数据库和访问数据库所需的账户名

$ su - postgres
postgres@localhost ~]$ psql
psql (14.5 (Ubuntu 14.5-1.pgdg22.04+1))
Type "help" for help.

postgres=# create user bucardo with superuser password 'bucardo';
CREATE ROLE
postgres=# create database bucardo with owner = bucardo;
CREATE DATABASE

5. 创建PID文件目录和日志目录,并设置好权限

# 创建 PID 目录
$ mkdir /var/run/bucardo
$ chown -R bucardo:bucardo /var/run/bucardo
# 创建日志目录
$ mkdir /var/log/bucardo
$ chown -R bucardo:bucardo /var/log/bucardo

6. 在操作系统创建 bucardo 用户,添加 .pgpass 文件

$ useradd bucardo
$ su - bucardo
$ echo "*:5432:*:bucardo:bucardo" > .pgpass
$ chmod 600 .pgpass

重要:bucardo 的所有操作都需要做 Linux 系统用户 bucardo 下完成,否则会出现类似于以下的错误

DBI connect('dbname=bucardo','bucardo',...) failed: connection to server on socket "/var/run/postgresql/.s.PGSQL.5432" failed: FATAL:  Peer authentication failed for user "bucardo" at /usr/local/bin/bucardo line 310.

7. 在系统用户 bucardo 下完成 Bucardo 初始化(如无特别说明,以下操作均在 bucardo 用户下完成)

$ bucardo install -h 192.168.7.218
This will install the bucardo database into an existing Postgres cluster.
Postgres must have been compiled with Perl support,
and you must connect as a superuser

Current connection settings:
1. Host:           192.168.7.218
2. Port:           5432
3. User:           bucardo
4. Database:       bucardo
5. PID directory:  /var/run/bucardo
Enter a number to change it, P to proceed, or Q to quit: p

Attempting to create and populate the bucardo database and schema
Database creation is complete

Updated configuration setting "piddir"
Installation is now complete.
If you see errors or need help, please email bucardo-general@bucardo.org

You may want to check over the configuration variables next, by running:
bucardo show all
Change any setting by using: bucardo set foo=bar

8. 检查 Bucardo 所有参数

$ bucardo show all
autosync_ddl              = newcol
bucardo_initial_version   = 5.6.0
bucardo_vac               = 1
bucardo_version           = 5.6.0
ctl_checkonkids_time      = 10
ctl_createkid_time        = 0.5
ctl_sleep                 = 0.2
default_conflict_strategy = bucardo_latest
default_email_from        = nobody@example.com
default_email_host        = localhost
default_email_port        = 25
default_email_to          = nobody@example.com
email_auth_pass           = 
email_auth_user           = 
email_debug_file          = 
endsync_sleep             = 1.0
flatfile_dir              = .
host_safety_check         = 
isolation_level           = repeatable read
kid_deadlock_sleep        = 0.5
kid_nodeltarows_sleep     = 0.5
kid_pingtime              = 60
kid_restart_sleep         = 1
kid_serial_sleep          = 0.5
kid_sleep                 = 0.5
log_conflict_file         = bucardo_conflict.log
log_level                 = normal
log_microsecond           = 0
log_showlevel             = 0
log_showline              = 0
log_showpid               = 1
log_showsyncname          = 1
log_showtime              = 3
log_timer_format          = 
mcp_dbproblem_sleep       = 15
mcp_loop_sleep            = 0.2
mcp_pingtime              = 60
mcp_vactime               = 60
piddir                    = /var/run/bucardo
quick_delta_check         = 1
reason_file               = bucardo.restart.reason.txt
reload_config_timeout     = 30
semaphore_table           = bucardo_status
statement_chunk_size      = 6000
stats_script_url          = http://www.bucardo.org/
stopfile                  = fullstopbucardo
syslog_facility           = log_local1
tcp_keepalives_count      = 0
tcp_keepalives_idle       = 0
tcp_keepalives_interval   = 0
vac_run                   = 30
vac_sleep                 = 120
warning_file              = bucardo.warning.log

9. 检查 Bucardo 服务状态

$ bucardo status
PID of Bucardo MCP: 50914
No syncs have been created yet.

10. 检查数据库表状态

$ su - postgres
postgres@localhost ~]$ psql
psql (14.5 (Ubuntu 14.5-1.pgdg22.04+1))
Type "help" for help.

postgres=# \c bucardo
bucardo=# \dp
                                           Access privileges
 Schema  |             Name              |   Type   | Access privileges | Column privileges | Policies 
---------+-------------------------------+----------+-------------------+-------------------+----------
 bucardo | bucardo_config                | table    |                   |                   | 
 bucardo | bucardo_custom_trigger        | table    |                   |                   | 
 bucardo | bucardo_custom_trigger_id_seq | sequence |                   |                   | 
 bucardo | bucardo_log_message           | table    |                   |                   | 
 bucardo | bucardo_rate                  | table    |                   |                   | 
 bucardo | clone                         | table    |                   |                   | 
 bucardo | clone_id_seq                  | sequence |                   |                   | 
 bucardo | customcode                    | table    |                   |                   | 
 bucardo | customcode_id_seq             | sequence |                   |                   | 
 bucardo | customcode_map                | table    |                   |                   | 
 bucardo | customcols                    | table    |                   |                   | 
 bucardo | customcols_id_seq             | sequence |                   |                   | 
 bucardo | customname                    | table    |                   |                   | 
 bucardo | customname_id_seq             | sequence |                   |                   | 
 bucardo | db                            | table    |                   |                   | 
 bucardo | db_connlog                    | table    |                   |                   | 
 bucardo | dbgroup                       | table    |                   |                   | 
 bucardo | dbmap                         | table    |                   |                   | 
 bucardo | dbrun                         | table    |                   |                   | 
 bucardo | goat                          | table    |                   |                   | 
 bucardo | goat_id_seq                   | sequence |                   |                   | 
 bucardo | herd                          | table    |                   |                   | 
 bucardo | herdmap                       | table    |                   |                   | 
 bucardo | sync                          | table    |                   |                   | 
 bucardo | syncrun                       | table    |                   |                   | 
 bucardo | upgrade_log                   | table    |                   |                   | 
(26 rows)

五、初始化业务库表

按业务要求创建业务库表,此处提供测试用表结构

CREATE TABLE public.bucardo_test_20221012 (
	id bigserial NOT NULL,
	std_name varchar(10) NULL,
	grade int4 NULL,
	join_date date NULL,
	attrs jsonb NULL,
	update_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
	CONSTRAINT bucardo_test_20221012_pk PRIMARY KEY (id)
);

六、配置业务库表同步

1. 基本概念

  • bucardo add database 设置同步的数据源
  • bucardo add dbgroup 绑定数据源到一个组
  • bucardo add all 添加要同步的表和序列(PostgreSQL中必须同步,自增字段类型serial系列存储的值)
  • bucardo add sync 创建同步任务,并指定冲突处理策略
  • 所有的配置完成后记得 bucardo stop 停止服务再使用 bucardo start 启动服务,使新参数生效
  • bucardo 是依赖主键做同步,只要有变化就会操作同步

2. 在第一台主机配置

$ bucardo add database db218 dbname=aps host=192.168.0.218 port=5432 user=bucardo
$ bucardo add database db219 dbname=aps host=192.168.0.219 port=5432 user=bucardo
$ bucardo add dbgroup grp1 db218:source db219:target
$ bucardo add all tables db=db218 --relgroup=relg_aps --verbose
$ bucardo add all sequences db=db218 --relgroup=relg_aps --verbose

3. 在第二台主机配置

$ bucardo add database db218 dbname=aps host=192.168.0.218 port=5432 user=bucardo
$ bucardo add database db219 dbname=aps host=192.168.0.219 port=5432 user=bucardo
$ bucardo add dbgroup grp1 db218:source db219:target
$ bucardo add all tables db=db218 --relgroup=relg_aps --verbose
$ bucardo add all sequences db=db218 --relgroup=relg_aps --verbose

4. 在两台主机上分别执行添加同步的命令

$ bucardo add sync dbsync relgroup=relg_aps dbs=grp1 conflict_strategy=bucardo_latest

5. 查看 Bucardo 各种业务库表同步配置

[bucardo@localhost ~]$ bucardo list dbs
Database: db218  Status: active  Conn: psql -p 5432 -U bucardo -d aps -h 192.168.7.218
Database: db219  Status: active  Conn: psql -p 5432 -U bucardo -d aps -h 192.168.7.218
[bucardo@localhost ~]$ bucardo list dbgroups
dbgroup: grp1  Members: db218:source db219:target
[bucardo@localhost ~]$ bucardo list relgroups
Relgroup: relg_aps  Members: 
  Used in syncs: dbsync
[bucardo@localhost ~]$ bucardo list syncs
Sync "dbsync"  Relgroup "relg_aps"  DB group "grp1" db218:source db219:target  [Active]
[bucardo@localhost ~]$ bucardo list tables
1. Table: public.bucardo_test_20221012  DB: db218  PK: id (bigint)

6. 查看 Bucardo 运行状态

$ bucardo status
PID of Bucardo MCP: 47874
 Name     State    Last good    Time    Last I/D    Last bad    Time  
========+========+============+=======+===========+===========+=======
 dbsync | Good   | 02:40:24   | 4s    | 0/0       | none      |       

七、参考资料

修复 apt-key Deprecation 警告


Ubuntu 22.04.1 添加了第三方安装源之后,每次执行apt命令都会收到以下警告信息。

Key is stored in legacy trusted.gpg keyring (/etc/apt/trusted.gpg), see the DEPRECATION section in apt-key(8) for details.

通过以下方式可以修复这个问题:

1. 获取key信息

$ sudo apt-key list
...
pub rsa4096 2020-01-29 [SC]
8CAE 012E BFAC 38B1 7A93  7CD8 C5E2 2450 0C12 89C0
...

2. 复制ID的后8位,执行以下命令

sudo apt-key export 0C1289C0 | sudo gpg --dearmour -o  /etc/apt/trusted.gpg.d/pgdg.gpg

3. 再执行apt命令时,刚刚配置的repo将不再出现deprecated key错误,如果还有其他的repo也报告key错误,每个key都是需要重复执行第2部命令。

命令行设置Ubuntu 22.04.1 LTS DNS地址


公司申请虚拟机安装Ubuntu 22.04.1之后,DNS配置不正确,导致无法正常解析域名,手工修改 /etc/resolv.conf 文件之后虽然能够生效,但是重启之后配置就被还原了,所以有了此文。

Ubuntu 从17.10版本开始使用netplan修改系统网络设定,所以我修改resolv.conf文件可以短暂生效,但是重启系统后配置又还原了。接下来讲怎么通过netplan修改Ubuntu的DNS设置。

编辑netplan配置文件

$ sudo vim /etc/netplan/00-installer-config.yaml

文件内容如下,nameservers节点是新增的配置,配置为公司内网DNS服务器:

# This is the network config written by 'subiquity'
network:
  ethernets:
    ens160:
      dhcp4: no
      addresses:
         - 192.168.7.219/24
      routes:
         - to: default
           via: 192.168.7.254
      nameservers:
           addresses: [192.168.100.250]
  version: 2

通过以上方法配置后,可以正常上网了。