2024年3月

1、Dubbo介绍

Apache Dubbo 是一款易用、高性能的 WEB 和 RPC 框架,同时为构建企业级微服务提供服务发现、流量治理、可观测、认证鉴权等能力、工具与最佳实践。用于解决微服务架构下的服务治理与通信问题,官方提供了 Java、Golang 等多语言 SDK 实现。使用 Dubbo 开发的微服务原生具备相互之间的远程地址发现与通信能力, 利用 Dubbo 提供的丰富服务治理特性,可以实现诸如服务发现、负载均衡、流量调度等服务治理诉求。Dubbo 被设计为高度可扩展,用户可以方便的实现流量拦截、选址的各种定制逻辑。
Dubbo官网:
https://cn.dubbo.apache.org/zh-cn/
Dubbo文档:
https://cn.dubbo.apache.org/zh-cn/overview/quickstart/
Dubbo GitHub地址:
https://github.com/apache/dubbo
Dubbo 使用版本对应关系:
https://github.com/alibaba/spring-cloud-alibaba/wiki/版本说明#2021x-分支
选择和项目相互对应的版本进行使用。这里我使用的是Dubbo 2.7.8

2、Dubbo连接注册中心

Dubbo推荐使用Zookeeper作为注册中心,Zookeeper是Apacahe Hadoop的子项目,是一个树型的目录服务,支持变更推送,适合作为 Dubbo 服务的注册中心,工业强度较高,可用于生产环境。除此之外Dubbo还可以使用阿里巴巴的nacos做注册中心。Nacos作为注册中心Dubbo使用与ZooKeeper基本相同,在使用上,不同的地方只有以下两点:

  • 1、导入的依赖,配置不同;
  • 2、注解不同,ZooKeeper使用@Service、@Reference注解,Nacos使用@DubboService、@DubboReference注解;

3、Dubbo负载均衡

  • RandomLoadBalance:加权随机,默认算法,默认权重相同;
  • RoundRobinLoadBalance:加权轮询,默认权重相同;
  • LeastActiveLoadBalance:最少活跃优先+加权随机,能者多劳;
  • ConsistentHashLoadBalance:一致性Hash,确定入参,确定提供者,适用于有状态的请求;

4、Dubbo Admin下载与使用

官网地址:
https://github.com/apache/dubbo-admin
Dubbo和Dubbo Admin版本说明:
https://cn.dubbo.apache.org/zh-cn/blog/2019/01/07/新版-dubbo-admin-介绍/

4.1、修改配置文件

进入dubbo-admin-server的resources目录,修改application.properties文件修改配置中心。默认为zookeeper:

admin.registry.address注册中心 
admin.config-center 配置中心 
admin.metadata-report.address元数据中心

由于我使用nacos,修改注册中心为nacos。 nacos注册中心有 GROUP 和 namespace:

admin.registry.address=nacos://127.0.0.1:8848?group=DEFAULT_GROUP&namespace=public&username=nacos&password=nacos
admin.config-center=nacos://127.0.0.1:8848?group=dubbo&username=nacos&password=nacos
admin.metadata-report.address=nacos://127.0.0.1:8848?group=dubbo&username=nacos&password=nacos
//改为自己的注册中心:
admin.registry.address=nacos://localhost:8848?group=DEFAULT_GROUP&namespace=23857f22-27ac-4947-988a-1b88d4eeb807&username=nacos&password=nacos
admin.config-center=nacos://localhost:8848?group=DEFAULT_GROUP&namespace=23857f22-27ac-4947-988a-1b88d4eeb807&username=nacos&password=nacos
admin.metadata-report.address=nacos://localhost:8848?group=DEFAULT_GROUP&namespace=23857f22-27ac-4947-988a-1b88d4eeb807&username=nacos&password=nacos
4.2、Dubbo Admin项目打包

在项目根目录进行打包,跳过测试:

mvn clean package -Dmaven.test.skip=true

进入dubbo-admin-0.6.0/dubbo-admin-distribution/target 进行启动后端:

java -jar dubbo-admin-0.6.0.jar

dubbo-admin-ui 目录下执行命令启动前端:

npm run dev

这是官方项目开发环境说明,打开项目就能看到:

4.3 访问dubbo admin

浏览器输入地址进行访问,之前的dubbo-admin老版本用的是Tomcat启动的,后端端口是8080(可能会冲突),前端端口是8081

http://localhost:8081

新版的dubbo-admin用的是Netty,默认配置端口是38080,前端端口38082

http://localhost:38082 或 http://localhost:38080

用户名密码都是root
登录成功:

5、SpringCloud集成Dubbo

在SpringBoot模块中引入maven依赖:

<!-- Dubbo Spring Cloud Starter -->
        <dependency>
            <groupId>com.alibaba.cloud</groupId>
            <artifactId>spring-cloud-starter-dubbo</artifactId>
        </dependency>

这里使用用户模块和订单模块模拟微服务使用Dubbo RPC的调用。在提供者模块中加入Dubbo依赖,在配置文件中设置dubbo连接注册中心和配置中心:

dubbo:
  application:
    name: user-service-model-provider
  protocol:
    name: dubbo
    port: -1
  provider:
    group: DEFAULT_GROUP
    version: 2.0
  #port: 20881
  registry:
    address: nacos://${nacos.address:127.0.0.1}:8848?username=nacos&password=nacos
    #配置nacos自定义命名空间
    parameters:
      namespace: 23857f22-27ac-4947-988a-1b88d4eeb807
    group: DEFAULT_GROUP
#  registry:
#    address: zookeeper://${zookeeper.address:127.0.0.1}:2181
  metadata-report:
    address: nacos://${nacos.address:127.0.0.1}:8848?username=nacos&password=nacos
    #配置nacos自定义命名空间
    parameters:
      namespace: 23857f22-27ac-4947-988a-1b88d4eeb807

配置添加成功后,在业务模块service层,新建一个对外提供的dubbo实现类UserExternalServiceImpl,需要使用@DubboService注解,@Service注解尽量不要使用,可以使用@Componet代替。代码如下:

//@Service
@Component
@DubboService(timeout = 1000 * 10,group = "userGroup",version = "2.0")
public class UserExternalServiceImpl implements IUserExternalService {

    @Autowired
    private IUserService userService;


    @Override
    public Response selectUserAll() {
//        try {
//            TimeUnit.MILLISECONDS.sleep(1000*5);
//        } catch (InterruptedException e) {
//            e.printStackTrace();
//        }
        return Response.success(userService.selectUserAll());
    }

    @Override
    public Response insert(UserExternal userExternal) {
//        boolean flag = true;
//        if (flag == true){
//            throw new ParamException(500,"用户模块出现错误,需要回滚");
//        }
//        try {
//            TimeUnit.MILLISECONDS.sleep(20000);
//        } catch (InterruptedException e) {
//            e.printStackTrace();
//        }
        User user=new User();
        BeanUtils.copyProperties(userExternal,user);
        boolean save = userService.save(user);
        if (save){
            return Response.success();
        }else {
            return Response.fail();
        }
    }
}

然后还需要在新建一个专门存dubbo的对外接口服务模块用interface-module名称,在该模块中新建一个IUserExternalService接口,该接口实现在用户模块中的UserExternalServiceImpl实现类:

public interface IUserExternalService {

    Response<List<UserExternal>> selectUserAll();

    Response insert(UserExternal user);
}

现在我们的接口提供方已经编写完成了,接下来开始编写接口使用方也就是消费者。在订单模块中引入dubbo依赖,在配置文件中将dubbo连接注册中心和配置中心:

dubbo:
  application:
    name: order-service-model-consumer
  consumer:
    group: DEFAULT_GROUP
    version: 2.0
  protocol:
    name: dubbo
    port: -1
  registry:
    address: nacos://${nacos.address:127.0.0.1}:8848?username=nacos&password=nacos
    #配置nacos自定义命名空间
    parameters:
      namespace: 23857f22-27ac-4947-988a-1b88d4eeb807
#  registry:
#    address: zookeeper://${zookeeper.address:127.0.0.1}:2181
  cloud:
    subscribed-services: user-service-model-provider
  metadata-report:
    address: nacos://${nacos.address:127.0.0.1}:8848?username=nacos&password=nacos
    #配置nacos自定义命名空间
    parameters:
      namespace: 23857f22-27ac-4947-988a-1b88d4eeb807

下面我们需要引入刚刚新建存dubbo接口模块的依赖包,然后就可以使用该接口了。首先建一个IDubboUserService的接口实现DubboUserServiceImpl类,意思是这个类是专门存放通过dubbo接口调用用户模块的业务类,后续在订单模块中处理用户模块信息都可以在该业务类中进行处理。
IDubboUserService类代码:

public interface IDubboUserService {

    List<UserExternal> selectUserAll();

}

DubboUserServiceImpl业务类代码,需要在该类中使用@DubboReference(group = "userGroup",version = "2.0")注解注入IUserExternalService接口信息,通过Dubbo RPC实现远程调用,注意group 和version 需要和提供方相互对应,不然会注入失败:

@Service
@Slf4j
public class DubboUserServiceImpl implements IDubboUserService {

    @DubboReference(group = "userGroup",version = "2.0")
    private IUserExternalService userExternalService;

    @Override
    public List<UserExternal> selectUserAll() {
        //添加blog
        Blog blog = new Blog();
        blog.setUid(UUID.randomUUID().toString());
        blog.setTitle("dubbo测试Test");
        blog.setContent("啊");
        blog.setSummary("12");
        blog.setTagUid("3c16b9093e9b1bfddbdfcb599b23d835");
        blogService.insert(blog);
        //处理相关逻辑
        Response<List<UserExternal>> response = userExternalService.selectUserAll();
        UserExternal user = new UserExternal();
        user.setUserName("dubbo测试Test");
        user.setAccount("system");
        user.setEmail("dubbo@gemail.com");
        Response insert = userExternalService.insert(user);
        System.out.println(insert);
        return response.getModel();
    }
}

通过上面的代码,就可以实现服务模块与模块之间的远程调用了。使用Dubbo在订单模块调用用户模块就和调用其他业务类代码一样通过依赖注入就可以了,是不是非常方便。

5、dubbo使用Sentinel进行限流和异常兜底

需要引入Maven依赖:

        <!-- 在dubbo中使用 Sentinel 需要添加下面依赖 -->
        <dependency>
            <groupId>com.alibaba.csp</groupId>
            <artifactId>sentinel-apache-dubbo-adapter</artifactId>
        </dependency>

由于限流和兜底是消费方要处理的事情,所以我们只需要在订单模块中引入上面依赖即可。在DubboUserServiceImpl中,通过@SentinelResource注解处理,代码如下:

@Service
@Slf4j
public class DubboUserServiceImpl implements IDubboUserService {

    @DubboReference(group = "userGroup",version = "2.0")
    private IUserExternalService userExternalService;

    @Autowired
    private IBlogService blogService;

//    @PostConstruct
//    private void initFlowRules(){
//        System.out.println("Sentinel initFlowRules start===");
//        List<FlowRule> rules = new ArrayList<>();
//        FlowRule rule = new FlowRule();
//        rule.setResource(IDubboUserService.class.getName());
//        rule.setGrade(RuleConstant.FLOW_GRADE_QPS);
//        // Set limit QPS to 20.
//        rule.setCount(20);
//        rules.add(rule);
//        FlowRuleManager.loadRules(rules);
//        System.out.println("Sentinel initFlowRules end====");
//    }


    @Override
    @SentinelResource(value = "com.itmy.user.service.IUserExternalService:selectUserAll()", //当前方法的路径
            blockHandler = "selectUserAll",
            blockHandlerClass = CustomerBlockHandler.class, //触发限流 走该类的blockHandler = "selectUserAll"方法
            fallback = "selectUserAllFallback",
            fallbackClass = UserFallback.class, //dubbo调用接口异常,走该类的  fallback = "selectUserAllFallback"方法
            exceptionsToIgnore = {IllegalArgumentException.class})
    //fallback 负责业务异常 blockHandler限流方法 exceptionsToIgnore 报该异常fallback不处理
    @GlobalTransactional(rollbackFor = Exception.class,timeoutMills = 30000,name = "order_tx_group")  //seata事务注解,目前没有使用后面会在seata博客中介绍。
    public List<UserExternal> selectUserAll() {
        //添加blog
        Blog blog = new Blog();
        blog.setUid(UUID.randomUUID().toString());
        blog.setTitle("dubbo事务测试Test");
        blog.setContent("dubbo事务测试Test啊的服务器打");
        blog.setSummary("12");
        blog.setTagUid("3c16b9093e9b1bfddbdfcb599b23d835");
        blogService.insert(blog);
        //处理相关逻辑
        Response<List<UserExternal>> response = userExternalService.selectUserAll();
//        boolean flag = true;
//        if (flag == true){
//            throw new ParamException(500,"用户模块出现错误,需要回滚");
//        }
        UserExternal user = new UserExternal();
        user.setUserName("dubbo事务");
        user.setAccount("system");
        user.setEmail("dubbo@gemail.com");
        Response insert = userExternalService.insert(user);
        System.out.println(insert);
        return response.getModel();
    }
}

CustomerBlockHandler处理限流的相关代码:

@Slf4j
public class CustomerBlockHandler {

    /**
     * 查询用户热点限流测试
     * @param name
     * @param email
     * @param exception
     * @return
     */
    public static Response selectUserBlockException(@RequestParam(value = "name",required = false) String name,
                                                    @RequestParam(value = "email",required = false) String email,
                                                    BlockException exception){
        log.error("CustomerBlockHandler|selectUserBlockException is fail");
        return Response.fail(FallbackErrorEnum.USER_MODULE_FALL);
    }

    /**
     * 查询限流
     * @return
     */
    public static Response redisFindBlockException(BlockException exception){
        log.error("添加订单 redis|添加用户 redis信息,调用接口被限流。。。。。");
        return Response.fail(FallbackErrorEnum.REDIS_FIND_FALL);
    }


    public List<UserExternal> selectUserAll(BlockException exception){
        log.error("添加订单|添加用户信息,触发限流控制。。。。。");
        throw new ParamException(600,"添加用户信息异常:"+exception.getMessage());
    }

}

UserFallback异常处理:

@Slf4j
public class UserFallback {

    public static List<UserExternal> selectUserAllFallback(Throwable throwable) {
        log.error("添加订单|添加用户信息异常,触发熔断兜底操作。");
        throw new ParamException(600,"添加用户信息异常,触发兜底操作");
    }
}

6、总结

SpringCloud集成Dubbo到目前为止就介绍完毕了,希望本博客对你有所帮助。目前只介绍了如何使用dubbo,dubbo还有需多需要去学习的地方,让我们持续学习新的知识,来应对工作中的各种问题。

本文基于 Linux 内核 5.4 版本进行讨论

自上篇文章
《从 Linux 内核角度探秘 JDK MappedByteBuffer》
发布之后,很多读者朋友私信我说,文章的信息量太大了,其中很多章节介绍的内容都是大家非常想要了解,并且是频繁被搜索的内容,所以根据读者朋友的建议,笔者决定将一些重要的章节内容独立出来,更好的方便大家检索。

关于 MappedByteBuffer 和 FileChannel 的话题,网上有很多,但大部分都在讨论 MappedByteBuffer 相较于传统 FileChannel 的优势,但好像很少有人来写一写 MappedByteBuffer 的劣势,所以笔者这里想写一点不一样的,来和大家讨论讨论 MappedByteBuffer 的劣势有哪些。

但在开始讨论这个话题之前,笔者想了想还是不能免俗,仍然需要把 MappedByteBuffer 和 FileChannel 放在一起从头到尾对比一下,基于这个思路,我们先来重新简要梳理一下 FileChannel 和 MappedByteBuffer 读写文件的流程。

1. FileChannel 读写文件过程

在之前的文章
《从 Linux 内核角度探秘 JDK NIO 文件读写本质》
中,由于当时我们还未介绍 DirectByteBuffer 以及 MappedByteBuffer,所以笔者以 HeapByteBuffer 为例来介绍 FileChannel 读写文件的整个源码实现逻辑。

当我们使用 HeapByteBuffer 传入 FileChannel 的 read or write 方法对文件进行读写时,JDK 会首先创建一个临时的 DirectByteBuffer,对于
FileChannel#read
来说,JDK 在 native 层会将 read 系统调用从文件中读取的内容首先存放到这个临时的 DirectByteBuffer 中,然后在拷贝到 HeapByteBuffer 中返回。

对于
FileChannel#write
来说,JDK 会首先将 HeapByteBuffer 中的待写入数据拷贝到临时的 DirectByteBuffer 中,然后在 native 层通过 write 系统调用将 DirectByteBuffer 中的数据写入到文件的 page cache 中。

public class IOUtil {

   static int read(FileDescriptor fd, ByteBuffer dst, long position,
                    NativeDispatcher nd)
        throws IOException
    {
        // 如果我们传入的 dst 是 DirectBuffer,那么直接进行文件的读取
        // 将文件内容读取到 dst 中
        if (dst instanceof DirectBuffer)
            return readIntoNativeBuffer(fd, dst, position, nd);
  
        // 如果我们传入的 dst 是一个 HeapBuffer,那么这里就需要创建一个临时的 DirectBuffer
        // 在调用 native 方法底层利用 read  or write 系统调用进行文件读写的时候
        // 传入的只能是 DirectBuffer
        ByteBuffer bb = Util.getTemporaryDirectBuffer(dst.remaining());
        try {
            // 底层通过 read 系统调用将文件内容拷贝到临时 DirectBuffer 中
            int n = readIntoNativeBuffer(fd, bb, position, nd);    
            if (n > 0)
                // 将临时 DirectBuffer 中的文件内容在拷贝到 HeapBuffer 中返回
                dst.put(bb);
            return n;
        }
    }

    static int write(FileDescriptor fd, ByteBuffer src, long position,
                     NativeDispatcher nd) throws IOException
    {
        // 如果传入的 src 是 DirectBuffer,那么直接将 DirectBuffer 中的内容拷贝到文件 page cache 中
        if (src instanceof DirectBuffer)
            return writeFromNativeBuffer(fd, src, position, nd);
        // 如果传入的 src 是 HeapBuffer,那么这里需要首先创建一个临时的 DirectBuffer
        ByteBuffer bb = Util.getTemporaryDirectBuffer(rem);
        try {
            // 首先将 HeapBuffer 中的待写入内容拷贝到临时的 DirectBuffer 中
            // 随后通过 write 系统调用将临时 DirectBuffer 中的内容写入到文件 page cache 中
            int n = writeFromNativeBuffer(fd, bb, position, nd);     
            return n;
        } 
    }
}

当时有很多读者朋友给我留言提问说,为什么必须要在 DirectByteBuffer 中做一次中转,直接将 HeapByteBuffer 传给 native 层不行吗 ?

答案是肯定不行的,在本文开头笔者为大家介绍过 JVM 进程的虚拟内存空间布局,如下图所示:

image

HeapByteBuffer 和 DirectByteBuffer 从本质上来说均是 JVM 进程地址空间内的一段虚拟内存,对于 Java 程序来说 HeapByteBuffer 被用来特定表示 JVM 堆中的内存,而 DirectByteBuffer 就是一个普通的 C++ 程序通过 malloc 系统调用向操作系统申请的一段 Native Memory 位于 JVM 堆之外。

既然 HeapByteBuffer 是位于 JVM 堆中的内存,那么它必然会受到 GC 的管理,当发生 GC 的时候,如果我们选择的垃圾回收器采用的是 Mark-Copy 或者 Mark-Compact 算法的时候(Mark-Swap 除外),GC 会来回移动存活的对象,这就导致了存活的 Java 对象比如这里的 HeapByteBuffer 在 GC 之后它背后的内存地址可能已经发生了变化。

而 JVM 中的这些 native 方法是处于 safepoint 之下的,执行 native 方法的线程由于是处于 safepoint 中,所以在执行 native 方法的过程中可能会有 GC 的发生。

如果我们把一个 HeapByteBuffer 传递给 native 层进行文件读写的时候不巧发生了 GC,那么 HeapByteBuffer 背后的内存地址就会变化,这样一来,如果我们在读取文件的话,内核将会把文件内容拷贝到另一个内存地址中。如果我们在写入文件的话,内核将会把另一个内存地址中的内存写入到文件的 page cache 中。

所以我们在通过 native 方法执行相关系统调用的时候必须要保证传入的内存地址是不会变化的,由于 DirectByteBuffer 背后所依赖的 Native Memory 位于 JVM 堆之外,是不会受到 GC 管理的,因此不管发不发生 GC,DirectByteBuffer 所引用的这些 Native Memory 地址是不会发生变化的。

所以我们在调用 native 方法进行文件读写的时候需要传入 DirectByteBuffer,如果我们用得是 HeapByteBuffer ,那么就需要一个临时的 DirectByteBuffer 作为中转。

这时可能有读者朋友又会问了,我们在使用 HeapByteBuffer 通过
FileChannel#write
对文件进行写入的时候,首先会将 HeapByteBuffer 中的内容拷贝到临时的 DirectByteBuffer 中,那如果在这个拷贝的过程中发生了 GC,HeapByteBuffer 背后引用内存的地址发生了变化,那么拷贝到 DirectByteBuffer 中的内容仍然是错的啊。

事实上在这个拷贝的过程中是不会发生 GC 的,因为 JVM 这里会使用
Unsafe#copyMemory
方法来实现 HeapByteBuffer 到 DirectByteBuffer 的拷贝操作,copyMemory 被 JVM 实现为一个 intrinsic 方法,中间是没有 safepoint 的,执行 copyMemory 的线程由于不在 safepoint 中,所以在拷贝的过程中是不会发生 GC 的。

public final class Unsafe {
  // intrinsic 方法
  public native void copyMemory(Object srcBase, long srcOffset,
                                  Object destBase, long destOffset,
                                  long bytes);  
}

在交代完这个遗留的问题之后,下面我们就以 DirectByteBuffer 为例来重新简要回顾下传统 FileChannel 对文件的读写流程:

image

  1. 当 JVM 在 native 层使用 read 系统调用进行文件读取的时候,JVM 进程会发生
    第一次上下文切换
    ,从用户态转为内核态。

  2. 随后 JVM 进程进入虚拟文件系统层,在这一层内核首先会查看读取文件对应的 page cache 中是否含有请求的文件数据,如果有,那么直接将文件数据
    拷贝
    到 DirectByteBuffer 中返回,避免一次磁盘 IO。并根据内核预读算法从磁盘中异步预读若干文件数据到 page cache 中

  3. 如果请求的文件数据不在 page cache 中,则会进入具体的文件系统层,在这一层内核会启动磁盘块设备驱动触发真正的磁盘 IO。并根据内核预读算法同步预读若干文件数据。请求的文件数据和预读的文件数据将被一起填充到 page cache 中。

  4. 磁盘控制器 DMA 将从磁盘中读取的数据拷贝到页高速缓存 page cache 中。发生
    第一次数据拷贝

  5. 由于 page cache 是属于内核空间的,不能被 JVM 进程直接寻址,所以还需要 CPU 将 page cache 中的数据拷贝到位于用户空间的 DirectByteBuffer 中,发生
    第二次数据拷贝

  6. 最后 JVM 进程从系统调用 read 中返回,并从内核态切换回用户态。发生
    第二次上下文切换

从以上过程我们可以看到,当使用
FileChannel#read
对文件读取的时候,如果文件数据在 page cache 中,涉及到的性能开销点主要有两次上下文切换,以及一次 CPU 拷贝。其中上下文切换是主要的性能开销点。

下面是通过
FileChannel#write
写入文件的整个过程:

image

  1. 当 JVM 在 native 层使用 write 系统调用进行文件写入的时候,JVM 进程会发生
    第一次上下文切换
    ,从用户态转为内核态。

  2. 进入内核态之后,JVM 进程在虚拟文件系统层调用 vfs_write 触发对 page cache 写入的操作。内核调用 iov_iter_copy_from_user_atomic 函数将 DirectByteBuffer 中的待写入数据拷贝到 page cache 中。发生
    第一次拷贝动作
    ( CPU 拷贝)。

  3. 当待写入数据拷贝到 page cache 中时,内核会将对应的文件页标记为脏页,内核会根据一定的阈值判断是否要对 page cache 中的脏页进行回写,如果不需要同步回写,进程直接返回。这里发生
    第二次上下文切换

  4. 脏页回写又会根据脏页数量在内存中的占比分为:进程同步回写和内核异步回写。当脏页太多了,进程自己都看不下去的时候,会同步回写内存中的脏页,直到回写完毕才会返回。在回写的过程中会发生
    第二次拷贝
    (DMA 拷贝)。

从以上过程我们可以看到,当使用
FileChannel#write
对文件写入的时候,如果不考虑脏页回写的情况,单纯对于 JVM 这个进程来说涉及到的性能开销点主要有两次上下文切换,以及一次 CPU 拷贝。其中上下文切换仍然是主要的性能开销点。

2. MappedByteBuffer 读写文件过程

下面我们来看下通过 MappedByteBuffer 对文件进行读写的过程:

image

首先我们需要通过
FileChannel#map
将文件的某个区域映射到 JVM 进程的虚拟内存空间中,从而获得一段文件映射的虚拟内存区域 MappedByteBuffer。由于底层使用到了 mmap 系统调用,所以这个过程也涉及到了
两次上下文切换

如上图所示,当 MappedByteBuffer 在刚刚映射出来的时候,它只是进程地址空间中的一段虚拟内存,其对应在进程页表中的页表项还是空的,背后还没有映射物理内存。此时映射文件对应的 page cache 也是空的,我们要映射的文件内容此时还静静地躺在磁盘中。

当 JVM 进程开始对 MappedByteBuffer 进行读写的时候,就会触发缺页中断,内核会将映射的文件内容从磁盘中加载到 page cache 中,然后在进程页表中建立 MappedByteBuffer 与 page cache 的映射关系。由于这里涉及到了缺页中断的处理,因此也会有
两次上下文切换
的开销。

image

后面 JVM 进程对 MappedByteBuffer 的读写就相当于是直接读写 page cache 了,关于这一点,很多读者朋友会有这样的疑问:page cache 是内核态的部分,为什么我们通过用户态的 MappedByteBuffer 就可以直接访问内核态的东西了?

这里大家不要被内核态这三个字给唬住了,虽然 page cache 是属于内核部分的,但其本质上还是一块普通的物理内存,想想我们是怎么访问内存的 ? 不就是先有一段虚拟内存,然后在申请一段物理内存,最后通过进程页表将虚拟内存和物理内存映射起来么,进程在访问虚拟内存的时候,通过页表找到其映射的物理内存地址,然后直接通过物理内存地址访问物理内存。

回到我们讨论的内容中,这段虚拟内存不就是 MappedByteBuffer 吗,物理内存就是 page cache 啊,在通过页表映射起来之后,进程在通过 MappedByteBuffer 访问 page cache 的过程就和访问普通内存的过程是一模一样的。

也正因为 MappedByteBuffer 背后映射的物理内存是内核空间的 page cache,所以它不会消耗任何用户空间的物理内存(JVM 的堆外内存),因此也不会受到
-XX:MaxDirectMemorySize
参数的限制。

3. MappedByteBuffer VS FileChannel

现在我们已经清楚了 FileChannel 以及 MappedByteBuffer 进行文件读写的整个过程,下面我们就来把两种文件读写方式放在一起来对比一下,但这里有一个对比的前提:

  • 对于 MappedByteBuffer 来说,我们对比的是其在缺页处理之后,读写文件的开销。

  • 对于 FileChannel 来说,我们对比的是文件数据已经存在于 page cache 中的情况下读写文件的开销。

因为笔者认为只有基于这个前提来对比两者的性能差异才有意义。

  • 对于 FileChannel 来说,无论是通过 read 方法对文件的读取,还是通过 write 方法对文件的写入,它们都需要
    两次上下文切换
    ,以及
    一次 CPU 拷贝
    ,其中上下文切换是其主要的性能开销点。

  • 对于 MappedByteBuffer 来说,由于其背后直接映射的就是 page cache,读写 MappedByteBuffer 本质上就是读写 page cache,整个读写过程和读写普通的内存没有任何区别,因此
    没有上下文切换的开销,不会切态,更没有任何拷贝

从上面的对比我们可以看出使用 MappedByteBuffer 来读写文件既没有上下文切换的开销,也没有数据拷贝的开销(可忽略),简直是完爆 FileChannel。

既然 MappedByteBuffer 这么屌,那我们何不干脆在所有文件的读写场景中全部使用 MappedByteBuffer,这样岂不省事 ?JDK 为何还保留了 FileChannel 的 read , write 方法呢 ?让我们来带着这个疑问继续下面的内容~~

4. 通过 Benchmark 从内核层面对比两者的性能差异

到现在为止,笔者已经带着大家完整的剖析了 mmap,read,write 这些系统调用在内核中的源码实现,并基于源码对 MappedByteBuffer 和 FileChannel 两者进行了性能开销上的对比。

虽然祭出了源码,但毕竟还是 talk is cheap,本小节我们就来对两者进行一次 Benchmark,来看一下 MappedByteBuffer 与 FileChannel 对文件读写的实际性能表现如何 ? 是否和我们从源码中分析的结果一致。

我们从两个方面来对比 MappedByteBuffer 和 FileChannel 的文件读写性能:

  • 文件数据完全加载到 page cache 中,并且将 page cache 锁定在内存中,不允许 swap,MappedByteBuffer 不会有缺页中断,FileChannel 不会触发磁盘 IO 都是直接对 page cache 进行读写。

  • 文件数据不在 page cache 中,我们加上了 缺页中断,磁盘IO,以及 swap 对文件读写的影响。

具体的测试思路是,用 MappedByteBuffer 和 FileChannel 分别以
64B ,128B ,512B ,1K ,2K ,4K ,8K ,32K ,64K ,1M ,32M ,64M ,512M 为单位依次对 1G 大小的文件进行读写,从以上两个方面对比两者在不同读写单位下的性能表现。

image

需要提醒大家的是本小节中得出的读写性能具体数值是没有参考价值的,因为不同软硬件环境下测试得出的具体性能数值都不一样,值得参考的是 MappedByteBuffer 和 FileChannel 在不同数据集大小下的读写性能趋势走向。笔者的软硬件测试环境如下:

  • 处理器:2.5 GHz 四核Intel Core i7
  • 内存:16 GB 1600 MHz DDR3
  • SSD:APPLE SSD SM0512F
  • 操作系统:macOS
  • JVM:OpenJDK 17

测试代码:
https://github.com/huibinliupush/benchmark
, 大家也可以在自己的测试环境中运行一下,然后将跑出的结果提交到这个仓库中。这样方便大家在不同的测试环境下对比两者的文件读写性能差异 —— 众人拾柴火焰高。

4.1 文件数据在 page cache 中

由于这里我们要测试 MappedByteBuffer 和 FileChannel 直接对 page cache 的读写性能,所以笔者让 MappedByteBuffer ,FileChannel 只针对同一个文件进行读写测试。

在对文件进行读写之前,首先通过 mlock 系统调用将文件数据提前加载到 page cache 中并主动触发缺页处理,在进程页表中建立好 MappedByteBuffer 和 page cache 的映射关系。最后将 page cache 锁定在内存中不允许 swap。

下面是 MappedByteBuffer 和 FileChannel 在不同数据集下对 page cache 的读取性能测试:

image

运行结果如下:

image

为了直观的让大家一眼看出 MappedByteBuffer 和 FileChannel 在对 page cache 读取的性能差异,笔者根据上面跑出的性能数据绘制成下面这幅柱状图,方便大家观察两者的性能趋势走向。

image

这里我们可以看出,MappedByteBuffer 在 4K 之前具有明显的压倒性优势,在 [8K , 32M] 这个区间内,MappedByteBuffer 依然具有优势但已经不是十分明显了,从 64M 开始 FileChannel 实现了一点点反超。

我们可以得到的性能趋势是,在 [64B, 2K] 这个单次读取数据量级范围内,MappedByteBuffer 读取的性能越来越快,并在 2K 这个数据量级下达到了性能最高值,仅消耗了 73 ms。从 4K 开始读取性能在一点一点的逐渐下降,并在 64M 这个数据量级下被 FileChannel 反超。

而 FileChannel 的读取性能会随着数据量的增大反而越来越好,并在某一个数据量级下性能会反超 MappedByteBuffer。FileChannel 的最佳读取性能点是在 64K 处,消耗了 167ms 。

因此 MappedByteBuffer 适合频繁读取小数据量的场景,具体多小,需要大家根据自己的环境进行测试,本小节我们得出的数据是 4K 以下。

FileChannel 适合大数据量的批量读取场景,具体多大,还是需要大家根据自己的环境进行测试,本小节我们得出的数据是 64M 以上。

image

下面是 MappedByteBuffer 和 FileChannel 在不同数据集下对 page cache 的写入性能测试:

image

运行结果如下:

image

MappedByteBuffer 和 FileChannel 在不同数据集下对 page cache 的写入性能的趋势走向柱状图:

image

这里我们可以看到 MappedByteBuffer 在 8K 之前具有明显的写入优势,它的写入性能趋势是在 [64B , 8K] 这个数据集方位内,写入性能随着数据量的增大而越来越快,直到在 8K 这个数据集下达到了最佳写入性能。

而在 [32K, 32M] 这个数据集范围内,MappedByteBuffer 仍然具有优势,但已经不是十分明显了,最终在 64M 这个数据集下被 FileChannel 反超。

和前面的读取性能趋势一样,FileChannel 的写入性能也是随着数据量的增大反而越来越好,最佳的写入性能是在 64K 处,仅消耗了 160 ms 。

image

4.2 文件数据不在 page cache 中

在这一小节中,我们将缺页中断和磁盘 IO 的影响加入进来,不添加任何的优化手段纯粹地测一下 MappedByteBuffer 和 FileChannel 对文件读写的性能。

为了避免被 page cache 影响,所以我们需要在每一个测试数据集下,单独分别为 MappedByteBuffer 和 FileChannel 创建各自的测试文件。

下面是 MappedByteBuffer 和 FileChannel 在不同数据集下对文件的读取性能测试:

image

运行结果:

image

从这里我们可以看到,在加入了缺页中断和磁盘 IO 的影响之后,MappedByteBuffer 在缺页中断的影响下平均比之前多出了 500 ms 的开销。FileChannel 在磁盘 IO 的影响下在 [64B , 512B] 这个数据集范围内比之前平均多出了 1000 ms 的开销,在 [1K, 512M] 这个数据集范围内比之前平均多出了 100 ms 的开销。

image

在 2K 之前, MappedByteBuffer 具有明显的读取性能优势,最佳的读取性能出现在 512B 这个数据集下,从 512B 往后,MappedByteBuffer 的读取性能趋势总体成下降趋势,并在 4K 这个地方被 FileChannel 反超。

FileChannel 则是在 [64B, 1M] 这个数据集范围内,读取性能会随着数据集的增大而提高,并在 1M 这个地方达到了 FileChannel 的最佳读取性能,仅消耗了 258 ms,在 [32M , 512M] 这个范围内 FileChannel 的读取性能在逐渐下降,但是比 MappedByteBuffer 的性能高出了一倍。

image

读到这里大家不禁要问了,
理论上来讲 MappedByteBuffer 应该是完爆 FileChannel 才对啊,因为 MappedByteBuffer 没有系统调用的开销,为什么性能在后面反而被 FileChannel 超越了近一倍之多呢 ?

要明白这个问题,我们就需要分别把 MappedByteBuffer 和 FileChannel 在读写文件时候所涉及到的性能开销点一一列举出来,并对这些性能开销点进行详细对比,这样答案就有了。

首先 MappedByteBuffer 的主要性能开销是在缺页中断,而 FileChannel 的主要开销是在系统调用,两者都会涉及上下文的切换。

FileChannel 在读写文件的时候有磁盘IO,有预读。同样 MappedByteBuffer 的缺页中断也有磁盘IO 也有预读。目前来看他俩一比一打平。

但别忘了 MappedByteBuffer 是需要进程页表支持的,
在实际访问内存的过程中会遇到页表竞争以及 TLB shootdown 等问题
。还有就是 MappedByteBuffer 刚刚被映射出来的时候,其在进程页表中对应的各级页表以及页目录可能都是空的。所以缺页中断这里需要做的一件非常重要的事情就是补齐完善 MappedByteBuffer 在进程页表中对应的各级页目录表和页表,并在页表项中将 page cache 映射起来,最后还要刷新 TLB 等硬件缓存。

想更多了解缺页中断细节的读者可以看下之前的文章——
《一文聊透 Linux 缺页异常的处理 —— 图解 Page Faults》

而 FileChannel 并不会涉及上面的这些开销,所以 MappedByteBuffer 的缺页中断要比 FileChannel 的系统调用开销要大,这一点我们可以在上小节和本小节的读写性能对比中看得出来。

文件数据在 page cache 中与不在 page cache 中,MappedByteBuffer 前后的读取性能平均差了 500 ms,而 FileChannel 前后却只平均差了 100 ms。

MappedByteBuffer 的缺页中断是平均每 4K 触发一次,而 FileChannel 的系统调用开销则是每次都会触发。当两者单次按照小数据量读取 1G 文件的时候,MappedByteBuffer 的缺页中断较少触发,而 FileChannel 的系统调用却在频繁触发,所以在这种情况下,FileChannel 的系统调用是主要的性能瓶颈。

这也就解释了当我们在
频繁读写小数据量的时候,MappedByteBuffer 的性能具有压倒性优势
。当单次读写的数据量越来越大的时候,FileChannel 调用的次数就会越来越少,
这时候缺页中断就会成为 MappedByteBuffer 的性能瓶颈,到某一个点之后,FileChannel 就会反超 MappedByteBuffer。因此当我们需要高吞吐量读写文件的时候 FileChannel 反而是最合适的

除此之外,内核的脏页回写也会对 MappedByteBuffer 以及 FileChannel 的文件写入性能有非常大的影响,无论是我们在用户态中调用 fsync 或者 msync 主动触发脏页回写还是内核通过 pdflush 线程异步脏页回写,当我们使用 MappedByteBuffer 或者 FileChannel 写入 page cache 的时候,如果恰巧遇到文件页的回写,那么写入操作都会有非常大的延迟,这个在 MappedByteBuffer 身上体现的更为明显。

为什么这么说呢 ? 我们还是到内核源码中去探寻原因,先来看脏页回写对 FileChannel 的写入影响。下面是 FileChannel 文件写入在内核中的核心实现:

ssize_t generic_perform_write(struct file *file,
    struct iov_iter *i, loff_t pos)
{
   // 从 page cache 中获取要写入的文件页并准备记录文件元数据日志工作
  status = a_ops->write_begin(file, mapping, pos, bytes, flags,
      &page, &fsdata);
   // 将用户空间缓冲区 DirectByteBuffer 中的数据拷贝到 page cache 中的文件页中
  copied = iov_iter_copy_from_user_atomic(page, i, offset, bytes);
  // 将写入的文件页标记为脏页并完成文件元数据日志的写入
  status = a_ops->write_end(file, mapping, pos, bytes, copied,
      page, fsdata);
  // 判断是否需要同步回写脏页
  balance_dirty_pages_ratelimited(mapping);
}

首先内核会在 write_begin 函数中通过 grab_cache_page_write_begin 从文件 page cache 中获取要写入的文件页。

struct page *grab_cache_page_write_begin(struct address_space *mapping,
          pgoff_t index, unsigned flags)
{
  struct page *page;
  // 在 page cache 中查找写入数据的缓存页
  page = pagecache_get_page(mapping, index, fgp_flags,
      mapping_gfp_mask(mapping));
  if (page)
    wait_for_stable_page(page);
  return page;
}

在这里会调用一个非常重要的函数 wait_for_stable_page,这个函数的作用就是判断当前 page cache 中的这个文件页是否正在被回写,如果正在回写到磁盘,那么当前进程就会阻塞直到脏页回写完毕。

/**
 * wait_for_stable_page() - wait for writeback to finish, if necessary.
 * @page:	The page to wait on.
 *
 * This function determines if the given page is related to a backing device
 * that requires page contents to be held stable during writeback.  If so, then
 * it will wait for any pending writeback to complete.
 */
void wait_for_stable_page(struct page *page)
{
	if (bdi_cap_stable_pages_required(inode_to_bdi(page->mapping->host)))
		wait_on_page_writeback(page);
}
EXPORT_SYMBOL_GPL(wait_for_stable_page);

等到脏页回写完毕之后,进程才会调用 iov_iter_copy_from_user_atomic 将待写入数据拷贝到 page cache 中,最后在 write_end 中调用 mark_buffer_dirty 将写入的文件页标记为脏页。

除了正在回写的脏页会阻塞 FileChannel 的写入过程之外,如果此时系统中的脏页太多了,超过了
dirty_ratio
或者
dirty_bytes
等内核参数配置的脏页比例,那么进程就会同步去回写脏页,这也对写入性能有非常大的影响。

我们接着再来看脏页回写对 MappedByteBuffer 的写入影响,在开始分析之前,
笔者先问大家一个问题:通过 MappedByteBuffer 写入 page cache 之后,page cache 中的相应文件页是怎么变脏的

FileChannel 很好理解,因为 FileChannel 走的是系统调用,会进入到文件系统由内核进行处理,如果写入文件页恰好正在回写时,内核会调用 wait_for_stable_page 阻塞当前进程。在将数据写入文件页之后,内核又会调用 mark_buffer_dirty 将页面变脏。

MappedByteBuffer 就很难理解了,因为 MappedByteBuffer 不会走系统调用,直接读写的就是 page cache,而 page cache 也只是内核在软件层面上的定义,它的本质还是物理内存。另外脏页以及脏页的回写都是内核在软件层面上定义的概念和行为。

MappedByteBuffer 直接写入的是硬件层面的物理内存(page cache),硬件哪管你软件上定义的脏页以及脏页回写啊,没有内核的参与,那么在通过 MappedByteBuffer 写入文件页之后,文件页是如何变脏的呢 ?还有就是 MappedByteBuffer 如何探测到对应文件页正在回写并阻塞等待呢 ?

既然我们涉及到了软件的概念和行为,那么一定就会有内核的参与,我们回想一下整个 MappedByteBuffer 的生命周期,唯一一次和内核打交道的机会就是缺页中断,我们看看能不能在缺页中断中发现点什么~

当 MappedByteBuffer 刚刚被 mmap 映射出来的时候它还只是一段普通的虚拟内存,背后什么都没有,其在进程页表中的各级页目录项以及页表项都还是空的。

当我们立即对 MappedByteBuffer 进行写入的时候就会发生缺页中断,在缺页中断的处理中,内核会在进程页表中补齐与 MappedByteBuffer 映射相关的各级页目录并在页表项中与 page cache 进行映射。

static vm_fault_t do_shared_fault(struct vm_fault *vmf)
{
    // 从 page cache 中读取文件页
    ret = __do_fault(vmf);   
    if (vma->vm_ops->page_mkwrite) {
        unlock_page(vmf->page);
        // 将文件页变为可写状态,并设置文件页为脏页
        // 如果文件页正在回写,那么阻塞等待
        tmp = do_page_mkwrite(vmf);
    }
}

除此之外,内核还会调用 do_page_mkwrite 方法将 MappedByteBuffer 对应的页表项变成可写状态,并将与其映射的文件页立即设置位脏页,如果此时文件页正在回写,那么 MappedByteBuffer 在缺页中断中也会阻塞。

int block_page_mkwrite(struct vm_area_struct *vma, struct vm_fault *vmf,
			 get_block_t get_block)
{
	set_page_dirty(page);
	wait_for_stable_page(page);
}

这里我们可以看到 MappedByteBuffer 在内核中是先变脏然后在对 page cache 进行写入,而 FileChannel 是先写入 page cache 后在变脏。

从此之后,通过 MappedByteBuffer 对 page cache 的写入就会变得非常丝滑,那么问题来了,当 page cache 中的脏页被内核异步回写之后,内核会把文件页中的脏页标记清除掉,那么这时如果 MappedByteBuffer 对 page cache 写入,由于不会发生缺页中断,那么 page cache 中的文件页如何再次变脏呢 ?

内核这里的设计非常巧妙,当内核回写完脏页之后,会调用 page_mkclean_one 函数清除文件页的脏页标记,在这里会首先通过 page_vma_mapped_walk 判断该文件页是不是被 mmap 映射到进程地址空间的,如果是,那么说明该文件页是被 MappedByteBuffer 映射的。随后内核就会做一些特殊处理:

  1. 通过 pte_wrprotect 对 MappedByteBuffer 在进程页表中对应的页表项 pte 进行写保护,变为只读权限。

  2. 通过 pte_mkclean 清除页表项上的脏页标记。

static bool page_mkclean_one(struct page *page, struct vm_area_struct *vma,
			    unsigned long address, void *arg)
{

	while (page_vma_mapped_walk(&pvmw)) {
		int ret = 0;

		address = pvmw.address;
		if (pvmw.pte) {
			pte_t entry;
			entry = ptep_clear_flush(vma, address, pte);
			entry = pte_wrprotect(entry);
			entry = pte_mkclean(entry);
			set_pte_at(vma->vm_mm, address, pte, entry);
		}
	return true;
}

这样一来,在脏页回写完毕之后,MappedByteBuffer 在页表中就变成只读的了,这一切对用户态的我们都是透明的,当再次对 MappedByteBuffer 写入的时候就不是那么丝滑了,会触发写保护缺页中断(我们以为不会有缺页中断,其实是有的),在写保护中断的处理中,内核会重新将页表项 pte 变为可写,文件页标记为脏页。如果文件页正在回写,缺页中断会阻塞。如果脏页积累的太多,这里也会同步回写脏页。

static vm_fault_t wp_page_shared(struct vm_fault *vmf)
    __releases(vmf->ptl)
{
    if (vma->vm_ops && vma->vm_ops->page_mkwrite) {
        // 设置页表项为可写
        // 标记文件页为脏页
        // 如果文件页正在回写则阻塞等待
        tmp = do_page_mkwrite(vmf);
    } 
    // 判断是否需要同步回写脏页,
    fault_dirty_shared_page(vma, vmf->page);
    return VM_FAULT_WRITE;
}

所以并不是对 MappedByteBuffer 调用 mlock 之后就万事大吉了,在遇到脏页回写的时候,MappedByteBuffer 依然会发生写保护类型的缺页中断
。在缺页中断处理中会等待脏页的回写,并且还可能会发生脏页的同步回写。这对 MappedByteBuffer 的写入性能会有非常大的影响。

在明白这些问题之后,下面我们继续来看 MappedByteBuffer 和 FileChannel 在不同数据集下对文件的写入性能测试:

image

运行结果:

image

image

在笔者的测试环境中,我们看到 MappedByteBuffer 在对文件的写入性能一路碾压 FileChannel,并没有出现被 FileChannel 反超的情况。但我们看到 MappedByteBuffer 从 4K 开始写入性能是在逐渐下降的,而 FileChannel 的写入性能却在一路升高。

根据上面的分析,我们可以推断出,后面随着数据量的增大,由于 MappedByteBuffer 缺页中断瓶颈的影响,在 512M 后面某一个数据集下,FileChannel 的写入性能最终是会超过 MappedByteBuffer 的。

在本小节的开头,笔者就强调了,本小节值得参考的是 MappedByteBuffer 和 FileChannel 在不同数据集大小下的读写性能趋势走向,而不是具体的性能数值。

image

一:背景

1. 讲故事

昨晚给训练营里面的一位朋友分析了一个程序崩溃的故障,因为看小伙子昨天在群里问了一天也没搞定,干脆自己亲自上阵吧,抓取的dump也是我极力推荐的用 procdump 注册 AEDebug 的方式,省去了很多沟通成本。

二:WinDbg分析

1. 为什么会崩溃

windbg有一个非常强大的点就是当你双击打开后,会自动帮你切换到崩溃的线程以及崩溃处的汇编代码,省去了
!analyze -v
命令的龟速输出,参考信息如下:


................................................................
...................................................
This dump file has an exception of interest stored in it.
The stored exception information can be accessed via .ecxr.
(10f4.f58): Access violation - code c0000005 (first/second chance not available)
For analysis of this file, run !analyze -v
eax=00000000 ebx=00000000 ecx=00000040 edx=00000000 esi=004c1b98 edi=07a8ed4c
eip=7008508f esp=07a8ec74 ebp=07a8ec80 iopl=0         nv up ei pl zr na pe nc
cs=0023  ss=002b  ds=002b  es=002b  fs=0053  gs=002b             efl=00010246
clr!Thread::GetSafelyRedirectableThreadContext+0x7c:
7008508f 8038eb          cmp     byte ptr [eax],0EBh        ds:002b:00000000=??
...

从卦中可以看到,当前崩溃是因为 eax=0 导致的,那为什么 eax 等于 0 呢?要想寻找这个答案,需要观察崩溃前的线程栈上下文,可以使用命令
.ecxr;k 9
即可。


0:009> .ecxr;k 9
eax=00000000 ebx=00000000 ecx=00000040 edx=00000000 esi=004c1b98 edi=07a8ed4c
eip=7008508f esp=07a8ec74 ebp=07a8ec80 iopl=0         nv up ei pl zr na pe nc
cs=0023  ss=002b  ds=002b  es=002b  fs=0053  gs=002b             efl=00010246
clr!Thread::GetSafelyRedirectableThreadContext+0x7c:
7008508f 8038eb          cmp     byte ptr [eax],0EBh        ds:002b:00000000=??
 # ChildEBP RetAddr      
00 07a8ec80 6fe7f6cd     clr!Thread::GetSafelyRedirectableThreadContext+0x7c
01 07a8f030 6fe7f2f3     clr!Thread::HandledJITCase+0x31
02 07a8f0a4 6fee23da     clr!Thread::SuspendRuntime+0x260
03 07a8f184 6fedf72d     clr!WKS::GCHeap::SuspendEE+0x1fe
04 07a8f1b0 6fe309ca     clr!WKS::GCHeap::GarbageCollectGeneration+0x168
05 07a8f1c0 6fe30a2e     clr!WKS::GCHeap::GarbageCollectTry+0x56
06 07a8f1e4 6fe30a90     clr!WKS::GCHeap::GarbageCollect+0xa5
07 07a8f230 6f058b01     clr!GCInterface::Collect+0x5d
08 07a8f26c 055fa4b1     mscorlib_ni+0x3b8b01

从卦中信息看,尼玛,真无语了
GCInterface::Collect
说明有人用
GC.Collect()
手工触发GC,不知道为什么要这么做来污染GC内部的统计信息,不管怎么说这个肯定不是崩溃的原因。

2. GC正在干什么

我们继续观察线程栈,可以看到它的逻辑大概是这样的,通过
SuspendRuntime
把所有的托管线程进行逻辑上暂停,在暂停其中的一个线程时抛出了异常。

稍微提醒一下,这个 HandledJITCase 方法是用 ip 劫持技术将代码引入到 coreclr 中进行 GC完成等待,这种神操作有些
杀毒软件
会认为是病毒!!!

有些朋友肯定会说,有没有代码支撑。。。这里我就找一下 coreclr 的源码贴一下吧。


void ThreadSuspend::SuspendRuntime(ThreadSuspend::SUSPEND_REASON reason)
{
	while ((thread = ThreadStore::GetThreadList(thread)) != NULL)
	{
		...
		if (workingOnThreadContext.Acquired() && thread->HandledJITCase())
		{
			...
		}
		...
	}
}

结合源码分析思路就非常清晰了,这里的
thread->HandledJITCase()
中的 thread 到底是哪一个线程? 可以观察
kb
输出然后用
!t
去做比对。

从卦中看,当前 GC 正在 Suspend 主线程,并且还看到了主线程有一个
System.AccessViolationException
异常,无语了。。。

3. 主线程到底怎么了

主线程进入到视野之后,那就重点关注一下它,可以用 k 看一下输出。


0:009> ~0s
eax=00000000 ebx=0029ea50 ecx=0029ea90 edx=00000000 esi=7efdb800 edi=000d0000
eip=00000000 esp=0029ea4c ebp=75146381 iopl=0         nv up ei pl nz na po nc
cs=0023  ss=002b  ds=002b  es=002b  fs=0053  gs=002b             efl=00210202
00000000 ??              ???
0:000> k
00 75146381 7efdb800     0x0
01 75146381 7517fa04     0x7efdb800
02 0029ea80 7736013a     user32!__fnHkINLPKBDLLHOOKSTRUCT+0x28
03 0029eae4 7514908d     ntdll!KiUserCallbackDispatcher+0x2e
04 0029eae4 076e3912     user32!CallNextHookEx+0x84
05 0029eb28 076e3064     0x76e3912
06 0029eb5c 0011d48f     xxx!xxx.ScanerHook.KeyboardHookProc+0xe4
07 0029eb8c 75146381     0x11d48f
08 0029eba8 7517fa04     user32!DispatchHookW+0x38
09 0029ebd8 7736013a     user32!__fnHkINLPKBDLLHOOKSTRUCT+0x28
0a 0029ec3c 751406eb     ntdll!KiUserCallbackDispatcher+0x2e
0b 0029ec3c 75140751     user32!_PeekMessage+0x88
0c 0029ec68 6d8af3bf     user32!PeekMessageW+0x108
...

从卦象看,这卦非常奇怪,有如下两点信息:

  • eip=00000000,这个很无语,线程已经疯了
  • KeyboardHookProc ,居然有键盘钩子

熟悉 eip 的朋友应该知道,它相当于一辆车的方向盘,一辆高速行驶的车突然没了方向盘,真的太可怕了,最后必然车毁人亡。

4. 是 eip=0 导致的崩溃吗

在汇编中是因为
eax=0
导致,而这里eip恰好也等于0,仿佛冥冥之中自有牵连,带着强烈的好奇心我们来反汇编下 GetSafelyRedirectableThreadContext 方法逻辑,简化后如下:


0:000> uf 7008508f
clr!Thread::GetSafelyRedirectableThreadContext:
6fe7f60e 55              push    ebp
6fe7f60f 8bec            mov     ebp,esp
6fe7f611 53              push    ebx
6fe7f612 56              push    esi
6fe7f613 57              push    edi
6fe7f614 8bf1            mov     esi,ecx
...
7008506d ffe9            jmp     rcx
7008506f fd              std
70085070 c1daff          rcr     edx,0FFh
70085073 f6450801        test    byte ptr [ebp+8],1
70085077 0f84efa5dfff    je      clr!Thread::GetSafelyRedirectableThreadContext+0xcc (6fe7f66c)
7008507d 8b8604010000    mov     eax,dword ptr [esi+104h]
70085083 3987b8000000    cmp     dword ptr [edi+0B8h],eax
70085089 0f85dda5dfff    jne     clr!Thread::GetSafelyRedirectableThreadContext+0xcc (6fe7f66c)
7008508f 8038eb          cmp     byte ptr [eax],0EBh  

从上面的汇编代码看eax的取值链条是:
eax <- esi+104h <- ecx
,很显然这里的 ecx 是 thiscall 协议中的
Thread=004c1b98
参数,可以用 dp 验证下。


0:000> dp 004c1b98+0x104 L1
004c1c9c  00000000

从卦中看果然是 0,有些朋友好奇这个 104 偏移到底是个什么东西,参考 coreclr 源码其实就是
m_LastRedirectIP
字段,参考如下:


BOOL Thread::GetSafelyRedirectableThreadContext(DWORD dwOptions, CONTEXT* pCtx, REGDISPLAY* pRD)
{
    if (!EEGetThreadContext(this, pCtx))
    {
        return FALSE;
    }
    ... 
	if (GetIP(pCtx) == m_LastRedirectIP)
	{
		const BYTE short_jmp = 0xeb;
		const BYTE self = 0xfe;

		BYTE* ip = (BYTE*)m_LastRedirectIP;
		if (ip[0] == short_jmp && ip[1] == self)
			m_LastRedirectIP = 0;
		return FALSE;
	}
}

结合汇编代码其实我们崩溃在
ip[0] == short_jmp
这一句上,仔细分析上面的C++代码会发现一个很奇怪的信息,那就是为什么
GetIP(pCtx)= 0
,接下来用 dt 观察下寄存器上下文。


0:009> kb 2
 # ChildEBP RetAddr      Args to Child              
00 07a8ec80 6fe7f6cd     00000003 07a8ed4c 07a8ecf0 clr!Thread::GetSafelyRedirectableThreadContext+0x7c
01 07a8f030 6fe7f2f3     004c1b98 0b367326 76a016a1 clr!Thread::HandledJITCase+0x31

0:009> dt _CONTEXT 07a8ed4c
ntdll!_CONTEXT
   +0x000 ContextFlags     : 0x10007
   ...
   +0x01c FloatSave        : _FLOATING_SAVE_AREA
   +0x08c SegGs            : 0x2b
   +0x090 SegFs            : 0x53
   +0x094 SegEs            : 0x2b
   +0x098 SegDs            : 0x2b
   +0x09c Edi              : 0xd0000
   +0x0a0 Esi              : 0x7efdb800
   +0x0a4 Ebx              : 0x29ea50
   +0x0a8 Edx              : 0
   +0x0ac Ecx              : 0x29ea90
   +0x0b0 Eax              : 0
   +0x0b4 Ebp              : 0x75146381
   +0x0b8 Eip              : 0
   +0x0bc SegCs            : 0x23
   +0x0c0 EFlags           : 0x210202
   +0x0c4 Esp              : 0x29ea4c
   ...

从卦中看果然 eip=0,这是一个非常错误的信息,还有一点就是 m_LastRedirectIP 字段一般用来处理一些比较诡异的兼容性问题,所以这里两个字段都是 0 导致崩溃的产生。

有了上面的信息,我们就知道了前因后果,原来是主线程车毁人亡(eip=0),导致GC无法暂停它,在内部抛出了代码异常,你可以说是 CLR 的bug,也可以说是主线程的Bug,所以给到的解决方案就是:

  1. 屏蔽掉
    键盘钩子
    的业务逻辑,肯定是它造成的。
  2. 不去掉的话,要重点观察
    键盘盘子
    ,是否是代码改动引发的。

三:总结

说实话要想解释这个程序为什么会崩溃,需要分析者对GC的
SuspendRuntime
运作逻辑有一定的了解,否则真抓瞎了,所以
.NET调试训练营
中的GC理论知识一定是分析这些 dump 的基石。

图片名称

兴趣是最好的老师,
HelloGitHub
让你对编程感兴趣!

简介

HelloGitHub
分享 GitHub 上有趣、入门级的开源项目。

https://github.com/521xueweihan/HelloGitHub

这里有实战项目、入门教程、黑科技、开源书籍、大厂开源项目等,涵盖多种编程语言 Python、Java、Go、C/C++、Swift...让你在短时间内感受到开源的魅力,对编程产生兴趣!


以下为本期内容|每个月
28
号更新

C 项目

1、
cosmopolitan
:让 C 成为构建一次,可随处运行的语言。这个工具可以将 C 语言编写的程序,编译成可无缝运行在多种操作系统上的可执行文件。它采用自包含式二进制文件的设计,能够将程序所有依赖打包进可执行文件中,实现真正的跨平台运行,支持 Windows、macOS 和 Linux 等主流操作系统。

// 编译
cosmocc -o hello hello.c
// 运行
./hello
// 调试
./hello --strace
./hello --ftrace

2、
linenoise
:一个 C 语言写的命令行编辑库。该项目是 Redis 作者用 C 语言实现的用于提升命令行交互体验的单文件库,整体代码大约 800 多行,轻量且易上手,提供了单/多行编辑模式、左右移动光标、上下回滚输入历史记录、命令补全等功能。来自
@9Ajiang
的分享

3、
xxHash
:超快的非加密哈希算法。哈希算法是一种将任意长度的输入数据转换为固定长度输出哈希值的算法。xxHash 是一种专为快速计算大型数据集哈希值而设计的非加密哈希算法。它具有出色的速度、零依赖和优秀的分布特性,支持流式计算模式和多种编程语言实现,适用于对计算性能要求很高的数据完整性检查、数据流分析、键值对检索等场景。

#include <string.h>
#include "xxhash.h"
 
// Example for a function which hashes a null terminated string with XXH32().
XXH32_hash_t hash_string(const char* string, XXH32_hash_t seed)
{
    // NULL pointers are only valid if the length is zero
    size_t length = (string == NULL) ? 0 : strlen(string);
    return XXH32(string, length, seed);
}

C# 项目

4、
reverse-proxy
:微软开源的反向代理工具包。该项目是微软团队用 C# 开发的一个提供核心代理功能的工具库,可作为库和项目模板,用于创建反向代理服务器的项目,内含简单的反向代理服务器示例项目。

5、
Snap.Hutao
:实用的多功能原神工具箱。这是一款专为 Windows 平台设计的原神工具箱,支持多账号切换、自定义帧率上限、祈愿记录、成就管理、签到奖励、查询角色资料、养成计算器等功能。它不对游戏客户端进行任何破坏性修改,只为改善原神桌面端玩家的游戏体验。来自
@Masterain
的分享

C++ 项目

6、
ada
:快如闪电的 URL 解析利器。该项目是用 C++ 写的符合 WHATWG 规范的 URL 解析器,解析速度是 curl 的数倍,目前已成为 Node.js 默认 URL 解析器(18.16.0 及以上),注意仅仅是 URL 地址解析不是请求。

7、
keepassxc
:一款开源、安全、跨平台的密码管理器。该项目是采用 C++ 开发的免费、离线、无广告的密码管理工具,它提供了简洁直观的用户界面,可轻松管理各种应用/网站的账号密码,支持多平台、浏览器插件、自动填充、密码生成等功能。

8、
TranslucentTB
:自定义 Windows 任务栏透明度的小工具。该项目是采用 C++ 开发的用于调整 Windows 任务栏透明度的工具,它体积小、免费、简单易用,支持 5 种任务栏状态、6 种动态模式、Windows 10/11 操作系统。

9、
tugraph-db
:支付宝背后的分布式图数据库。该项目是由蚂蚁集团和清华大学共同研发的高性能分布式图数据库,支持事务处理、TB 级大容量、低延迟查找和快速图分析等功能。

CSS 项目

10、
easings.net
:CSS 缓动函数速查表。缓动函数(Easing Functions)是一种用于控制 CSS 动画速度的函数,该项目提供了一系列优雅的缓动函数示例代码和效果展示。

.block {
	transition: transform 0.6s cubic-bezier(0.7, 0, 0.84, 0);
}

Go 项目

11、
codapi
:在线运行代码片段的 Go 服务。该项目提供了一个 API 服务,可以在线运行 Python、TypeScript、C、Go 等 30 种编程语言的代码片段,可用于在文档和教程中展示交互式的代码示例。

12、
focalboard
:开源的项目管理和团队协作工具。这是一款开源、多语言、自托管的项目管理工具,兼容了 Trello 和 Notion 的特点。它支持看板、表格和日历等视图管理任务,并提供评论同步、文件共享、用户权限等功能。该工具还提供了适用于 Windows、macOS、Linux 系统的客户端。

13、
go-pretty
:美化控制台输出的 Go 库。这是一个用于美化表格、列表、进度条、文本等控制台输出的库,你可以用它输出精美的表格、多层级的列表以及多任务进度条等内容。

14、
gopeed
:一款由 Go+Flutter 开发的高速下载器。这款下载工具后端用的是 Go 语言,支持 HTTP、BitTorrent、Magnet 等多种协议,并使用协程实现高速并发下载。前端部分采用 Flutter 开发,提供了适用于 Windows、macOS、Linux、Android、iOS 和 Web 等全平台的客户端。来自
@DeShuiYu
的分享

15、
teleport
:一款 Go 写的企业级开源堡垒机。这是一个专为基础设施提供连接、身份验证、访问控制和安全审计的平台,它支持对内网的 Linux 服务器、Kubernetes 集群、Web 应用、PostgreSQL 和 MySQL 数据库的安全访问。该平台采用自动下发证书的方式进行认证,无需在目标机器上管理密码和 SSH Key。此外,用户可以方便地使用 ssh、mysql、kubectl 等远程连接工具,轻松接入受管理的资源。

Java 项目

16、
javers
:用于追踪数据历史记录和审计的 Java 库。该项目是将版本管理的想法应用于数据(Java 对象)变更管理的 Java 库,它支持查看复杂的对象结构差异,保留修改数据的历史记录,并能追踪对象变化。来自
@猎隼丶止戈reNo7
的分享

17、
source-code-hunter
:Spring 全家桶源码解读。该项目提供了一系列互联网主流框架和中间件的源码讲解,包括 Spring 全家桶、Mybatis、Netty、Dubbo 等框架。

JavaScript 项目

18、
aspoem
:现代化的古诗词学习网站。这是一个更加注重阅读体验和 UI 的诗词网站,采用 TypeScript、Next.js、Tailwind CSS 构建。它拥有简洁清爽的界面和好看的字体,提供了古诗词的拼音、注释、译文以及移动端适配、搜索和一键分享等功能。来自
@meetqyhvkXU
的分享

19、
MyIP
:好用的 IP 工具箱。该项目的作者是一位产品经理,这是他借助 ChatGPT 完成的第一个 Vue.js 项目。通过该项目,你可以在线查看自己的 IP 信息(多源),并进行网站可用性、网速、MTR、DNS 泄漏、WebRTC 等检测。来自
@Jason Ng
的分享

20、
nutui
:京东风格的移动端 Vue 组件库。该项目是由京东开源的移动端 Vue 组件库,专为移动端 H5 和小程序开发场景而设计。它内含 80 多个高质量组件,支持按需引用、TypeScript、国际化等特性。

21、
pikachu-volleyball
:用 JavaScript 实现的皮卡丘排球游戏。该项目通过逆向工程解析原版的皮卡丘排球游戏,并使用 JavaScript 重新实现,包括物理引擎和对战机器人部分。

22、
wasp
:一个类似 Rails 的 React、Node.js 全栈 Web 框架。该项目是一个面向 Web 开发人员的全栈 Web 框架,开发者只需编写简单的 .wasp 配置文件,就能自动生成基于 React 和 Node.js 构建的 Web 应用,而且内置了数据库、身份验证、路由等功能。

Python 项目

23、
marker
:将 PDF 转换为 Markdown 文件的项目。这是一个能够将 PDF、EPUB 和 MOBI 格式的文件转换为 Markdown 文件的 Python 项目。相较于 Nougat,它具有更快的速度和更高的准确度,在处理英语类内容时效果最佳,但对中文的处理就要差一些。

24、
Paper-Piano
:在纸上弹钢琴。该项目使用 Python 和 OpenCV 实现图像处理和识别,通过摄像头捕获手指动作和手指下方的阴影,让用户可以通过触摸纸张来演奏钢琴。

25、
pelican
:Python 语言的静态网站生成器。这是一个用 Python 编写的静态网站生成器,让你可以通过编写 Markdown、reStructuredText 等格式的文本文件来创建网站,支持生成 RSS、代码语法高亮、插件扩展等功能。

26、
posthog
:开源的产品分析平台。这是一款基于 Django 构建的产品分析和用户追踪平台,它提供了丰富的功能,包括事件跟踪、漏斗分析、群体分析、A/B 测试等,适用于了解用户行为、改善产品体验的场景。

27、
taipy
:快速打造数据驱动的 Web 应用。这是一个基于 Python 和 Flask 的项目,结合了 React 等前端技术,为开发者提供了一个简洁、高效的开发框架。它能够简化数据处理、API 开发和用户界面构建的开发过程。不论是数据科学家、机器学习工程师还是 Web 开发者,都能够利用 Taipy 快速完成从原型到 Web 应用的全过程。来自
@刘三非
的分享

Rust 项目

28、
genact
:假装很忙的摸鱼神器。该项目可以在终端上模拟一些很忙的假象,比如编译、扫描、下载等。这些操作都是假的,实际上什么都没有发生,所以不会影响你的电脑,适用于 Windows、Linux、macOS 操作系统。来自
@39499740
的分享

29、
rnote
:跨平台的手写笔记和绘图应用。这是一款用 Rust 和 GTK4 编写的绘图应用,可用于绘制草图、手写笔记和注释文档等。它支持导入/导出 PDF 和图片文件,以及无限画布、拖放、自动保存等功能。适用于 Windows、Linux 和 macOS 系统,需要搭配手写板使用。

Swift 项目

30、
Applite
:Homebrew Cask 的桌面应用。这是一款采用 Swift 开发的免费 macOS 应用,它为 Homebrew Cask 提供了一个图形化界面,实现一键安装、更新和卸载应用。

31、
BLEUnlock
:使用蓝牙设备解锁你的 Mac 电脑。这款工具是可以在 macOS 上实现通过蓝牙设备解锁/锁定电脑。使用该工具时,蓝牙设备无需安装任何应用程序。当蓝牙设备靠近 Mac 电脑时,可以解锁屏幕并唤醒电脑;而当蓝牙设备远离时,自动锁定屏幕并暂停播放音乐/视频。支持 iPhone、Apple Watch、蓝牙耳机等设备。

其它

32、
candle
:自制 3D 电子蜡烛。该项目作者使用简单的 LED 板和小型电路板,制作了一个微型电子蜡烛,并通过旋转底座和流体模拟算法,模拟出 3D 的烛光效果。

33、
docker-android
:运行在 Docker 容器里的 Android。这是一个 Android 模拟器的 Docker 镜像,支持 Android 9-14 版本、VNC(远程桌面)、ADB(Android 调试桥)、日志查看等功能,适用于 Android 客户端测试和调试等场景。

docker run -d -p 6080:6080 \
-e EMULATOR_DEVICE="Samsung Galaxy S10" \
-e WEB_VNC=true \
--device /dev/kvm \
--name android-container \
budtmo/docker-android:emulator_11.0

34、
excelCPU
:仅用 Excel 构建出一颗 CPU 。该项目是一颗运行在 Excel 文件中的 16 位 CPU 处理器,它具有 3Hz 主频、128KB RAM 和一块 128x128 像素的显示屏,为此作者还创建了一门汇编语言。

35、
Mr.-Ranedeer-AI-Tutor
:打造你的个性化 AI 老师。该项目通过提示词让 AI 对话机器人充当老师和学习助手的角色,为你生成学习计划、授课解惑、出练习题等,还可以选择不同的授课风格和深度。它可搭配任意大模型,作者推荐 GPT-4 效果最佳。

===
Author: JushBJJ
Name: "Mr. Ranedeer 提示词"
Version: 2.7
===

[Student Configuration]

可伸缩的高维约束基准和算法

​ 在过去二十年里,进化约束多目标优化受到了广泛的关注和研究,并且已经提出了一些基准测试约束多目标进化算法(CMOEAs)。特别地,约束函数与目标函数值有紧密的联系,这使得约束特征太单调并且与真实世界的问题不同。因此,之前的CMOEAs不能特别好的解决现实问题,这些问题涉及多态或者非线性特征的决策空间约束。因此,我将介绍一个新的基准框架和设计一个合适的可伸缩的高维决策空间约束的新测试函数。具体来说,不同的高维约束函数和变量之间的复杂联系与现实特征都有紧密联系。在这个框架里,提供了许多的参数接口,以便用户可以比较容易地调整参数获得不同的函数,并且可以测试算法的性能。现已存在不同类型的CMOEAs被用来测试已经提出的测试函数,但是结果很容易陷入局部可行区域。因此我也会介绍一个基于多任务CMOEA去更好的处理这些问题,这个算法有一个新的搜索算法去提高种群的搜索能力。

SDC基准

​ SDC(Scalable High-Dimensional Constraint)有以下特征:

  1. 在决策空间中构造约束,以便建立单峰或多峰约束;
  2. 可伸缩变量与距离函数相关,这可以使约束困难可调节;
  3. 可伸缩变量与距离函数相关,这可以使收敛性困难得到调节;
  4. 设置CPF(Constraint Pareto Front)和UPF(unconstraint Pareto Front)之间的重叠度来测试算法局部调整种群分布的能力

SDC框架

如图所示,SDC框架可以定义不同的层次和把变量分成不同的组。

Objective level:在这个层次里,变量被分为两部分,形状函数变量和距离函数变量。形状函数变量用于形状函数,这可以控制UPS(unconstraint Pareto Solution)与UPF(unconstraint Pareto Front)的形状和位置。距离函数变量用于距离函数,以便增加收敛难度。

Variable linkage level:为了强制变量联动使距离函数的最优解旋转,距离函数变量通常与形状函数变量中的第一个变量相关联。因此,我们把距离函数变量分为两部分,并且只有后一部分是强制变量联动。

Constraint level:在这个层里,变量 x
1
s
,…,x
a1
s
被改写为 x
1
lc
,…,x
a1
lc
,因为它们被用来构造低维度约束变量(LCV)。LCV是用在低维度约束函数中,它可以控制CPS的位置和形状。除此之外,为了提高约束的复杂性,引入了高维度约束,并且与高维度约束相关的变量 x
1
hc
,…,x
a2
hc
(HCV(high-dimension constraint variable))。可伸缩HCV x
1
sc
,…,x
a3,1
sc
(SHCV(Scalable HCV))可通过转化操作变为THCV(transformed HCV)。

​ 基于以上定义,可以生成以下测试函数公式:

d
1
是CEC2006的约束问题,d
2
是距离函数,g
1
和g
2
分别代表低维度约束和高维度约束。

​ SDC框架里的一些重要观点:

  1. 统一的搜索空间:可以看出 x
    sc
    与 x
    thc
    和 x
    ud
    都有关系,因此,最优解 x
    sc,*
    应当同时满足 d
    2
    == 0 和 g
    2
    == 0。然而,d
    2
    和 g
    2
    可能有不同的搜索范围。为了使不同范围的形状函数、约束函数和距离函数能在SDC 框架中使用,需要定义一个统一的搜索空间。每一个变量有一个固定的范围 [0 , 1]。当计算任何形状、约束和距离函数,首先将变量从统一搜索空间映射到真正的搜索范围。除此之外,变量联动和转化操作都在统一搜索空间实现。
  2. 高维约束函数和距离函数的难度分离控制:现已存在的形状函数和高维度约束函数,它们都有一个固定的维度,例如,a
    1
    和 a
    2
    是固定的。因此对一个D维向量, a
    3
    是固定的。当 a
    3,1
    增加,a
    3
    是固定的,g
    2
    的难度将会增加,而 d
    2
    将不会改变。同样的,当a
    3,1
    固定,a
    3
    增加时,g
    2
    的难度将不会改变,而d
    2
    的难度将会增加。因此,高维度约束函数和距离函数的难度可以控制。
  3. 高维度约束的可伸缩难度:因为CEC2006 函数不可伸缩,它的难度直接调整。为了使它可伸缩,我们使用SHCV 和HCV 获得 THCV,如下图所示。

形状函数(shape functions)

​ 形状函数( h(x
s
)) 的作用是控制UPF的位置和形状。除此之外,当约束 g
1
(x
lc
) 增加时,可以控制CPF的位置和形状。因此,我们可以控制CPF 和 UPF 的关系来检验算法局部调整种群分布的能力。这里有两种调整函数,其中一个的公式如下:

参数 b 可以控制可行区域的位置,不同的值可以使得可行域旋转。另一个调整函数的公式如下:

参数b 可以控制UPF 和 CPF 之间的重叠度,不同的值可以改变可行区域。

高维度约束函数

​ 选择高维度约束函数一会重要的问题是接近现实问题的实质。CEC2006 函数集可以满足真实的约束多目标优化问题,除此之外,CEC2006 函数提高了优化可行目标值。

转换函数

​ 为了保证优化解得到存在,x
thc
的范围应该在[0 , 1]。上图中的x
t
代表参与转换操作的变量集,这里设计了两种转换函数:

距离函数

​ 如图所示,提供了五个距离函数。

​ 除此之外,变量的联系函数在决策空间不同位置中被用来改变最优解,这里也有两种函数:

在公式中,这里设置了一个参数(DTC)来控制 [DCT * x
d,2
]中的x
d,2
变量参与变量的联系。

IMCMO算法

  1. 动态辅助任务分配

    • 算法中引入了辅助任务,为每个主任务分配一个动态变化的辅助任务。
    • 辅助任务的目的是帮助共享和传递有价值的信息,以提高整体优化性能。
  2. 多目标优化

    • 该算法专注于同时处理多个目标函数,以便获得一组帕累托最优解。
    • 通过维护帕累托最优解集合来实现多目标优化的目标。
  3. 演化多任务学习

    • 算法利用演化多任务学习的方法,通过任务间的知识共享来提高收敛速度和全局搜索能力。
    • 任务之间的交叉和突变促进信息传递,有助于优化过程中各任务之间的协作。
  4. 约束处理

    • 对于受约束的多目标优化问题,算法设计了有效的约束处理策略。
    • 这可能涉及罚函数、约束处理技术等方法,以确保生成的解满足所有的约束条件。
  5. 动态性与自适应性

    • 该算法具有一定的动态性和自适应性,可以根据优化过程中的需求和变化来调整任务分配和资源分配策略。
    • 动态地适应不同的优化场景,并根据需要灵活调整算法参数。

通过结合多目标优化、动态任务分配和演化多任务学习等技术,这种算法能够有效解决受约束的多目标优化问题,并在复杂的优化环境中取得良好的性能表现。

以下是算法伪代码: