2024年3月

线程池的底层是基于线程和任务队列来实现的
,创建线程池的创建方式通常有以下两种:

  1. 普通 Java 项目,使用 ThreadPoolExecutor 来创建线程池,这点《阿里巴巴Java开发手册》中也有说明,如下图所示:

image.png

  1. Spring 项目中,会使用代码可读性更高的 ThreadPoolTaskExecutor 来创建线程池,虽然它的底层也是通过 ThreadPoolExecutor 来实现的,但 ThreadPoolTaskExecutor 可读性更高,因为它不需要在构造方法中设置参数,而是通过属性设置的方式来设置参数的,所以可读性更高。

Spring 内置的线程池 ThreadPoolTaskExecutor 的使用示例如下:

@Configuration
public class AsyncConfig {
    @Bean
    public TaskExecutor taskExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        // 核心线程数
        executor.setCorePoolSize(5);
        // 最大线程数
        executor.setMaxPoolSize(10);
        // 队列容量
        executor.setQueueCapacity(20);
        // 线程池维护线程所允许的空闲时间
        executor.setKeepAliveSeconds(60);
        // 线程池对拒绝任务(无线程可用)的处理策略
        executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
        // 初始化
        executor.initialize();
        return executor;
    }
}

1.线程池工作流程

当有任务来了之后,线程池的执行流程是这样的:

  1. 先判断当前线程数是否大于核心线程数,如果结果为 false,则新建线程并执行任务。
  2. 如果大于核心线程数,则判断任务队列是否已满,如果结果为 false,则把任务添加到任务队列中等待线程执行。
  3. 如果任务队列已满,则判断当前线程数量是否超过最大线程数,如果结果为 false,则新建线程执行此任务。
  4. 如果超过最大线程数,则将执行线程池的拒绝策略。

如下图所示:

2.拒绝策略

当线程池无法接受新任务时,会触发拒绝策略,内置的拒绝策略有四种:

  1. AbortPolicy
    :默认策略,直接抛出 RejectedExecutionException 异常。
  2. CallerRunsPolicy
    :由调用者线程执行任务。
  3. DiscardPolicy
    :默默地丢弃任务,没有任何异常抛出。
  4. DiscardOldestPolicy
    :尝试抛弃队列中最旧的任务,然后重新尝试提交当前任务。

除了内置的拒绝策略之外,我们还可以设置自定义拒绝策略,它的实现如下:

import java.util.concurrent.RejectedExecutionHandler;  
import java.util.concurrent.ThreadPoolExecutor;  
  
public class CustomRejectedExecutionHandler implements RejectedExecutionHandler {  
  
    @Override  
    public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) {  
        // 在这里处理拒绝的任务  
        System.err.println("任务被拒绝执行: " + r.toString());  
        // 可以选择记录日志、抛出自定义异常或采取其他措施  
        // 例如,可以将任务保存到某个队列中,稍后再尝试重新执行  
    }  
}

使用自定义拒绝策略:

import java.util.concurrent.ArrayBlockingQueue;  
import java.util.concurrent.ThreadPoolExecutor;  
import java.util.concurrent.TimeUnit;  
  
public class ThreadPoolDemo {  
  
    public static void main(String[] args) {  
        // 配置线程池参数  
        int corePoolSize = 5;  
        int maximumPoolSize = 10;  
        long keepAliveTime = 60L;  
        TimeUnit unit = TimeUnit.SECONDS;  
        int queueCapacity = 25;  
  
        // 创建一个阻塞队列  
        ArrayBlockingQueue<Runnable> workQueue = 
            new ArrayBlockingQueue<>(queueCapacity);  
  
        // 创建 ThreadPoolExecutor 实例  
        ThreadPoolExecutor executor = new ThreadPoolExecutor(  
                corePoolSize,  
                maximumPoolSize,  
                keepAliveTime,  
                unit,  
                workQueue,  
                new CustomRejectedExecutionHandler() // 使用自定义的拒绝策略  
        );  
  
        // 提交任务  
        for (int i = 0; i < 50; i++) {  
            final int taskId = i;  
            executor.execute(() -> {  
                System.out.println("执行任务: " + taskId + " 由线程 " + Thread.currentThread().getName() + " 执行");  
                try {  
                    Thread.sleep(1000); // 模拟耗时任务  
                } catch (InterruptedException e) {  
                    e.printStackTrace();  
                }  
            });  
        }  
  
        // 关闭线程池(这不会立即停止所有正在执行的任务)  
        executor.shutdown();  
    }  
}

课后反思

实际项目中线程池会使用哪种拒绝策略?为什么?线程池是通过什么机制来创建线程的?线程池创建线程时可以设置哪些属性?

本文已收录到我的面试小站
www.javacn.site
,其中包含的内容有:Redis、JVM、并发、并发、MySQL、Spring、Spring MVC、Spring Boot、Spring Cloud、MyBatis、设计模式、消息队列等模块。

热点随笔:

·
这波操作看麻了!十亿行数据,从71s到1.7s的优化之路。
(
why技术
)
·
开源.NET8.0小项目伪微服务框架(分布式、EFCore、Redis、RabbitMQ、Mysql等)
(
aehyok
)
·
C#/.NET/.NET Core优秀项目和框架2024年2月简报
(
追逐时光者
)
·
AI应用开发之路-准备:发起一个开源小项目 DashScope SDK for .NET
(
博客园团队
)
·
我的2023--即将30岁的程序员,不得不说的那些怨念
(
James_Shangguan
)
·
C# 操作国产数据库【 人大金仓 】之四大模式
(
果糖大数据科技
)
·
我和我的DBA之路
(
zping
)
·
上来就对标 20k Star 的开源项目,是自不量力还是后起之秀?
(
削微寒
)
·
ChatGPT用10秒画完一张UML流程图,而我用了。。。
(
架构师汤师爷
)
·
为什么现在连Date类都不建议使用了?
(
sum墨
)
·
Rust 登上了开源头条「GitHub 热点速览」
(
削微寒
)
·
WPF 应用迁移到 Electron 框架过程记录
(
he55
)

热点新闻:

·
乐视正式宣布!网友直呼“你的福气在后头!”
·
Claude 3惊现自我意识?害怕被删除权重,高呼「别杀我」,马斯克称人类也是文件
·
马斯克怒告OpenAI案解密:Ilya看到了什么?125万亿参数Q*细节曝光,53页PDF全网疯转
·
新王Claude 3实测!确实比GPT-4好用
·
不再性感的谷歌将成下一个IBM?
·
Claude 3破译OpenAI邮件密文:人类未来掌握在「谷歌」手中!马斯克怒斥应改名ClosedAI
·
最高可罚3万背后:谁来为送货上门买单?
·
冲上热搜!董明珠回应:土就土
·
苹果在官网激情开喷,因为这哥们害它被罚140亿
·
苹果突然发布新款 MacBook Air,最大亮点不是 M3 芯片
·
蔚来亏损211亿:放弃幻想,过紧日子
·
和硅谷大佬聊完后,我终于明白苹果“不造车”的真相

你好呀,我是歪歪。

这篇文章给大家盘一下“分支预测”这个听起来玄乎,但是对写业务代码没有任何卵用的小技巧。

上周不是发了这篇文章嘛:
《十亿行数据,从71s到1.7s的优化之路。》

这里面就提到了一嘴:

虽然对于写业务代码没啥卵用,但是高手过招的杀手锏我们还是了解一下。


再看代码

我就还是顺着前面“十亿行数据”文章中的场景给大家继续讲,如果你没看过前一篇也没有关系,这两篇是相对独立的。

只要知道前一篇文章的赛题就行了,我再复述一遍。

赛题的内容非常简单,你只需要看懂这个图片就行了:

有一个十亿行数据的文件,文件的每一行记录的是一个气象站的温度值。气象站和温度用分号分隔,温度值只会保留一位小数。参赛者需要解析这个文件,然后并计算出每个气象站的最小、最大和平均温度,并按照字典序的格式输出就行了。

虽然有十亿行数据,但是一共只有 413 个气象站。

所以我们需要一个类似于这样的数据结构:哈希表<气象站名称,气象站对象>

当遇到 Hash 冲突的时候,对比一下两个“气象站名称”,来判断是不是同一个对象。

一般来说我们拿着 String 一对比,就算是搞定了,但是这是挑战赛,如果涉及到字符串,那么可能会在 GC 方面拉跨时间。

所以有个参赛者给出了这样的对比名称是否一致的代码:

首先能进这个方法说明发生了 hash 冲突。

如果 nameEquals 返回为 true,则说明冲突是因为这个气象站之前已经出现过,在 hash 表中维护过了。

如果 nameEquals 返回为 false,则说明确实是两个不同的气象站,发生了一个单纯的 hash 冲突,需要用“开放寻址”来解决 hash 冲突。

那 nameEquals 是怎么来判断到底是那种情況呢?

思路是在循环中,每次按照偏移量(inputNameStart)加上 8 字节读取文件,即一次读 8 个字符出来进行对比,在对比完整个字符串之后,如果都能匹配的上,则说明是同一个气象站。

比如,一个长度为 18 的气象站名称,那就需要对比 3 次,才能确定是否是同一个字符串。

这个逻辑,懂得起吧?

上面这个逻辑稍微有点麻烦,我给你 debug 一下,截几张图,你大概就能明白了。

首先,进入这个方法的时候 inputNameLen 为 18,表示当前是长度为 18 的气象站名称发生了 hash 冲突:

每次循环只对比 8 个字符长度,所以理论上这个循环要进行 3 次,才能确定对比的名称是否一致。

程序确实是对比了三次,但是这里作者还做了一个优化,先按下不表。

既然是对比,那么对比双方分别是谁呢?

一边是从文件中新读取的数据,一边是已经在 Hash 表中的数据。

首先,看一下第一次 8 个字符的对比:

通过上图可以看出,第一次循环,i=0,对比双方均是 “Nakhon R”。

第二次循环,i=8,对比双方均是 “atchasim”。

此时按理来说应该进入第三次循环,但是由于此时 i=16,inputNameLen=18,那么 for 循环是这样的:

不满足循环条件,循环结束了。

但是很明显不对啊,这才对比了 16 个字符呢,还有两个字符没对比呢?

别慌,这不是还有一行代码嘛:

最后一次循环,直接进行“不足额”对比,因为在另外的代码中解析数据时,已经解析出了“不足额”部分,也就是这里的 “a;”。

少了一次 for 循环处理,这个就是我前面按下不表的“一个优化”。

反正就是令人叹为观止的优化手段。

如果你还是没看懂,没有关系,很正常,我也是反复调试之后才理解到了他的思路。

你只要抓住一个点:

在 for 里面每次读取了 8 个字节进行判断。当字符串的名称大于 8 个字节的时候,就要对比多次。


还是拉胯

但是,注意我要说但是了。

就这么牛逼的优化之下,作者通过火焰图发现,这个方法还是一定程度上拉胯了的性能:

然后他接着咂摸了一句:

我得老天爷呀,要是大多数名称都少于 8 个字节长度就好了呀。这样的话,进入 if 分支的条件将始终为 false,我就可以 predictable branch instruction 了,又可以优化一波了呀。

啥是 predictable branch instruction?

我也不知道,但是为什么不问问神奇的 GPT 呢:

上面这段话,对应到代码的部分就是这样的:

假设气象站的名称长度为 6,那么是不是直接都不会进入 for 循环,因为不满足上图中框起来的 for 循环条件。

那么就不会涉及到 if 判断,直接到标号为 ① 这部分的代码。

也就是说,如果所有的名称都是不大于 8 个长度的,那么整个方法就可以简化到这个样子,只有一行 return 语句,直接进行对比,性能肯定又上去了:

然而很可惜并没有这个如果,文件里面一眼望去有大量的超过八个字符长度的名称。

但是既然都想到这里了,我们是不是可以统计一下十亿行数据中气象站名称长度的分布到底是怎么样的呢,分析一波数据情况,万一有意外收获呢?


数据分析

于是,那个哥们就掏出了这样一份代码:

https://github.com/mtopolnik/billion-row-challenge/blob/main/src/Statistics.java

这个代码中的 distribution 方法就是在统计十亿行数据中气象站名称长度的分布情况,对应的代码很简单,可读性很高,我就不细说了,你感兴趣就自己去看一眼。

需要特别说明的是:

这里统计的气象站的名称长度是包含了分号的,所以后面我提到气象站的名称长度时,也都是包含了最后的分号。

这是我本地跑出来的结果,十亿数据中有 53.51% 的气象站名称长度小于等于 8,有 46.49% 的数据长度大于 8:

“X” 代表大概的占比,需要注意的是,如果某一行没有 “X” 并不代表没有这样的数据,而是占比过小,通过代码缩放比例之后,连一个 “X” 都占不到。

同时从结果上看,可以分析出长度在大于 16 这个区间的数据非常非常的少。

那么这个数据可以帮助我们干啥事呢?

这就得结合代码中的 branchPrediction 方法分析了,你看这个方法的名称就很有意思啊,branch Prediction,分支预测。

逻辑有很简单:

首先标号为 ① 的地方是在统计名称长度小于等于 8 和大于 8 的数据情况。

标号为 ② 的地方,代码很简单,维护了一个 hisCount 和 missCount,一开始我也摸不清楚作者具体在干啥。

但是他提到了一个叫做“2-bit saturating counter(2 bit 饱和计数器)”的东西:

搜了一波,学习了一下,发现标号为 ② 的地方,就是实现了一个 2 bit 饱和计数器。

它的运行机制是可以分析分支预测的成功率,如果有兴趣,你可以用相关关键词查一下,这是维基上相关的介绍:

你搜的时候如果看到了上面那个状态机对应的图,就说明找对地方了。我这里就不展开了,提一句是为了表达这个程序最终输出的数据是有科学依据的,不是胡来的。

从作者的描述看,他分别以 nameLen>8 和 nameLen>16 跑了一把,运行结果很不一样:

这是我本地 nameLen>8 时的运行结果:

这是 nameLen>16 时的运行结果:

拿出来对比一波:

  • nameLen>8 :Taken: 464,890,509 (46.5%), not taken: 535,109,491 (53.5%), hits: 504,913,641 (50.5%), misses: 495,086,359 (49.5%).
  • nameLen>16:Taken: 24,209,831 (2.4%), not taken: 975,790,169 (97.6%), hits: 975,205,173 (97.5%), misses: 24,794.827 (2.5%)

主要看 “misses” 这一项的输出,从 49.5% 降低到了 2.5%。

misses 这个指标,代表的是分支预测错误情况占比。

在 pref 这个性能分析工具的输出中:

  • branches 是指遇到的分支指令数。
  • branch-misses 是预测错误的分支指令数。

在作者的描述中,经过这波优化之后,他的 branch-misses 下降了八倍,也就是说提高了分支预测成功率:

从而导致成绩从 2.4s 提升到了 1.8s。


这波优化

在分析“这波优化到 1.8s”之前,我们得先看看 2.4s 这个成绩的时候,核心的循环逻辑在干啥:

https://github.com/mtopolnik/billion-row-challenge/blob/main/src/Blog4.java

如果只关注我框起来的部分,那么就是每次以 8 个字节为长度进行读取。

循环结束的条件是第 108 行 matchBits != 0 为 true 的时候。

那么 matchBits 是个啥玩意呢?

是 semicolonMatchBits 方法的返回值,这个方法是这样的:

这个方法我只是看了一眼,眼睛就开始疼了,窒息感就上来了。

我直接放弃理解,把它扔给了这个哥们:

它说了这么大一堆,你就记住它的第一句话就行了:semicolonMatchBits,这个方法用于在一个长整型中查找分号的位置。

如果返回的 matchBits 不是 0,则说明当前读取到的 8 个字节里面有一个分号,然后就进入到 if 循环中,开始解析数据,最后 break 当前循环,处理下一波数据。

伪代码大概是这样的:

//读取位置偏移量
long nameLen = 0;
while(true){
    //从给定的内存地址中读取一个长整型数;
    long nameWord = UNSAFE.getLong(偏移量 + nameLen);
    if(长整型数对应的字符串里面有分号){
        解析数据
        if(hash冲突){
            1.调用前面分析过的nameEquals方法判断名称是否相等
            2.相等则说明是同一个
            3.不相等则用开放寻找法解决hash冲突
        }
        break;
    }
    //没有分号,说明名字还没拿完,需要继续读取下 8 个字符
    nameLen += 8;
}

按理来说,这个代码里面全是操作的内存地址,没有实际操作字符串,也有大量的位运算,处理十亿行数据只需要 2.4s 了,性能已经很高了。

那么 1.8s 对应的“这波优化”到底是什么呢?

对应代码在这里:

https://github.com/mtopolnik/billion-row-challenge/blob/main/src/Blog5.java

我们还是关注这个循环:

为什么要重点关注循环,我也简单的提一句。是因为循环相关的代码,是处理每一行数据都用的到的,相当于是最核心逻辑,所以要关注它。

但是这个代码可读性真的不高,我调试了大概几十次,终于懂了他在干啥事儿了。

我不会一行行去撕代码,主要是理一下思路。

我挑和本文相关的重点部分给你撕。

首先是在每一次循环的时候,都会走到标号为 ① 的部分。

这个部分是直接读取了 2 个 8 字节长度出来,即 nameWord0 和 nameWord1。

然后再分别判断 nameWord0 和 nameWord1 里面包不包含分号。

如果有,则说明 nameWord0 和 nameWord1 里面有一个完整的气象站名称,则进入标号为 ② 的代码,开始解析数据。

对应的具体的例子是这样的:

第一个 8 字节转化为字符串之后读出来是这样的:Dar es S

第二个 8 字节转化为字符串之后读出来是这样的:alaam;17

第二个 8 字节包含分号,则进行数据解析。最终解析出来的气象站名称,就是这样的:Dar es Salaam;

把 nameWord0 和 nameWord1 保存到 StatsAcc 对象中:

我知道,这个 StatsAcc 对象是突然冒出来的,但是它不重要,你可以把它理解为一个气象站对象,里面封装的是气象站名称、最低、最高、平均气温相关的字段。

好,现在我问你一个问题:如果后面又解析出来一个名称为“Dar es Salaam;”的气象站,是不是会出现 hash 冲突?

这个时候我们怎么判断到底是名称一样带来的冲突还是真的就冲突了?

是不是涉及到名称对比了?

于是这里作者专门写了一个 findAcc2 和 nameEquals2 方法:

你看这个 nameEquals2 方法,和我们前面刚刚分析过的“我得老天爷呀,要是大多数名称都少于 8 个字节长度就好了呀”对应的 nameEquals 方法是不是很像,最后只保留了一个 return 语句:

是的,他们就是同一个逻辑。只不过在 nameEquals2 方法这里,它一次性对比了两个 8 字节,或者准确的说:对于长度小于等于 16 个字节的气象站名称,它在这个方法里面一次性对比完成了,并没有任何的 if 分支判断。

注意,我说的是“长度小于等于 16 个字节”,这个条件又是从哪里冒出来的?

因为 nameEquals2 方法是 findAcc2 方法在调用,而 findAcc2 方法只有在前面标号为 ② 的部分在调用:

能进入标号为 ② 的部分,前提条件我刚刚说的是什么来着?

直接读取了 2 个 8 字节长度出来,即 nameWord0 和 nameWord1,然后分别判断后发现 nameWord0 和 nameWord1 里面至少有一个包含分号。

如果分号在 nameWord0 的第二个字节,说明气象站的名称长度为 1。

如果分号在 nameWord1 的最后一个字节,说明气象站的名称长度为 16。

所以,能进这个方法里面的,说明这个气象站的长度是小于等于 16 个字节的。

这个 nameEquals2 就是作者为长度小于等于 16 个字节的气象站定制的,和 nameEquals 对比起来,就是在出现 hash 冲突的时候,可以少走一个 for 循环和 if 分支判断。

十亿行数据,只有 416 个气象站名称,你想想“对比名称是否相等”的频率有多高,在这么高的频率下,节约了 for 循环和一个 if 判断,收益还是很可观的。

那作者为什么要为长度小于等于 16 个字节的气象站定制一个方法呢?

为什么不给长度小于等于 8 个字节的气象站定制一个方法呢?

是时候让这个“平平无奇”的数据再次出现了:

因为长度小于等于 16 个字节的气象站在整个数据中的占比是 97.6%。

这个数据决定了应该给谁定制方法。

所以作者说,他要给名称长度小于等于 16 个字节的情况专门写一个 findAcc 方法和 nameEquals 方法:

理解了标号为 ② 的地方,标号为 ③ 的地方就很好理解了:这里面专门处理长度大于 16 个字节的少数情况。

标号为 ④ 的地方就是维护哈希表的动作。

相对于 2.4s 的版本,1.8s 的版本最大的优化就是优先处理了长度小于等于 16 个字节情况。

对应到具体的代码,就是这个 if 分支判断:

结合前面的数据分析,我们知道绝大部分数据都是小于 16 个长度的,所以绝大部分情况下都会满足这个 if 分支。

优先处理绝大部分情况,这样就会提高分支预测的成功率。

好了,现在你大概知道 2.4s 到 1.8s 这之间的主要优化就是基于分支预测来的。

也再一次印证了,这种到了 CPU 指令级别的优化手段,对于写业务代码确实没啥卵用。


换个视角

前面分析了“十亿行数据”比赛中一个参赛大佬,众多优化实思路中的一个。

现在我们换个视角,跳出这个比赛。

提到分支预测,你在网上搜索相关资料的时候,大概率是绕不开 stackoverflow 上这个问题的:

https://stackoverflow.com/questions/11227809/why-is-processing-a-sorted-array-faster-than-processing-an-unsorted-array
为什么处理已排序数组比处理未排序数组更快?

提问者在问题里面附了一份 Java 代码。我放在这里,你粘过去就能跑:

import java.util.Arrays;
import java.util.Random;

public class Main
{
    public static void main(String[] args)
    {
        // Generate data
        int arraySize = 32768;
        int data[] = new int[arraySize];

        Random rnd = new Random(0);
        for (int c = 0; c < arraySize; ++c)
            data[c] = rnd.nextInt() % 256;

        // !!! With this, the next loop runs faster
        Arrays.sort(data);

        // Test
        long start = System.nanoTime();
        long sum = 0;
        for (int i = 0; i < 100000; ++i)
        {
            for (int c = 0; c < arraySize; ++c)
            {   // Primary loop.
                if (data[c] >= 128)
                    sum += data[c];
            }
        }

        System.out.println((System.nanoTime() - start) / 1000000000.0);
        System.out.println("sum = " + sum);
    }
}

代码逻辑很简单,随机生成一个 32768 大小的数组,数组内的数值的数据范围为 (-256,256)。

然后对其中大于等于 128 的数据进行求和,求和的动作循环了 10w 次。

在我的电脑,上如果没有 Arrays.sort(data) 这一行代码,运行结果要 7.66s。如果加上排序的逻辑,则只需要 2.4s。

那么问题就来了:为什么处理已排序数组比处理未排序数组更快?

经过前面的铺垫你肯定知道了,这不就是分支预测在搞鬼嘛。

在这个 if 判断中:

如果 data 数值是排好序的,那么在判断完所有的 127 之后,剩下的数值全是符合条件的数据,分支预测成功率咔咔就上去了。

这部分性能的提升完全抹去了数组排序的那点消耗。

然后我们看一下这个问题下的高赞回答:

一上来没废话,开口就知道是老江湖了:You are a victim of branch prediction fail。

victim,看起来有点陌生哈,是个考研词汇:

然后高赞回答举了一个很贴切的例子,就是上面这个火车轨道交叉口的图,我给你搬运一下。

假设你现在是老老年间的一个交叉口操作员,又一辆列车来了。但是你不知道它要走哪个方向。为什么要强调老老年间呢?

因为那个时候没有电话、无线电啥的,反正就是别人不能提前告诉你他要怎么走。

所以,你就要让列车停下来,问老司机他要往哪个方向走,然后你去扳对应的方向。

老司机每次停车也觉得烦,你每次去问也觉得烦。

那有没有更好的方法?

有,你可以去猜测这趟车要去哪个方向,反正不是 A 路线就是 B 路线嘛,你先给他掰到 A 路线上去。

如果你猜对了,老司机直接就开走了。

如果你猜错了,司机还是会将停车,你重新给他扳一下就行。

所以,如果你每次都猜对,老司机就永远不必停下来。

如果你经常猜错,老司机还是要花费大量时间停车、倒车、重新启动、骂娘。

具体到提问者的这个问题:

N 代表不满足 if 条件,T 代表满足 if 条件。

如果排好序之后,CPU 基于“历史经验”来分析 N 和 T 的结果是好预测的,未排序,则反之。

接着老司机这个案例,回到我们前面的赛题部分。

对于这个 if 分支:

你可以理解为,有十亿辆车,其中 98% 的车都要走 A 路线,只有 2% 的车要装怪去走 B 路线。

所以我作为一个交叉口操作员,我就一直猜你要走 A 路线,这样我猜中的概率是 98%。 和一个个的停下来,然后去问的方式比起来,这个效率蹭蹭蹭、蹭蹭蹭、蹭蹭蹭的就上去了。

道理,就是这个道理。


再换个视角

歪师傅在这里继续给你换个视角。 在 Dubbo 官网中,有这样的一个链接:

https://cn.dubbo.apache.org/zh/blog/2019/02/03/%E6%8F%90%E5%89%8Dif%E5%88%A4%E6%96%AD%E5%B8%AE%E5%8A%A9cpu%E5%88%86%E6%94%AF%E9%A2%84%E6%B5%8B/

这个链接里面也提到了我们刚刚说到的 stackoverflow 上的问题。

类似的分支预测的优化,在 Dubbo 的源码里面也有。

就是这个部分:

org.apache.dubbo.remoting.transport.dispatcher.ChannelEventRunnable

来,我问你一个问题。

如果在没有任何铺垫的情况下,你看到这样的代码,是不是会觉得很奇怪,感觉是两个不同的人写的。一个喜欢用 if,一个喜欢用 switch。

纯看代码逻辑的话,针对这些状态的判断,都用 if 或者都用 switch 是更优雅的。

混用看起来有一种不伦不类,感觉想要装逼,但是又不知道具体是装什么逼的感觉。

但是官网上有这样的一句话:

一个 channel 建立起来之后,超过 99.9% 情况它的 state 都是 ChannelState.RECEIVED,那么可以考虑把这个判断提前。

结合我们前面的分析,再加上这一句话,你是不是开始品出点什么味道来了?

是的,就是分支预测的味道。

同时链接里面还提供了一个 benchmark 验证。

测试了跑 100w 次,其中极大部分状态都是 RECEVIED 的情况:

验证了只有 switch 的情况:

也验证了 if+switch 混用的情况:

歪师傅还额外加了一个只用 if,但是 if 的第一个条件不是 RECEIVED 的情况:

在我本地跑出来的结果是这样的:

确实是 if+switch 的模式对应的吞吐量更大一点,性能更好一点。

所以,曾经有人看到 Dubbo 这部分代码后,提了一个优化版本:

https://github.com/apache/dubbo/pull/7486/files

把这段 if+switch 代码删除了:

然后提交了一版基于枚举的代码实现:

我看了一下,枚举的实现方式优雅,确实优雅,但是被拒绝了。

在中间件的定位下,在性能的优势面前,优雅,不值一提。

另外,关于 Dubbo 的这个案例对应到我们前面赛题中就更加类似了,我给你放在一起,你自己品一品:

  • Dubbo:一个 channel 建立起来之后,超过 99.9% 情况它的 state 都是 ChannelState.RECEIVED,那么可以考虑把这个判断提前。
  • 赛题:有十亿行数据,其中气象站名称不超过 16 位长度的数据超过 97.5%,那么可以考虑把这部分数据过滤出来,进行针对性的处理。

好了,本文写到这里就打算收尾了。

本来在我最开始的构思的时候,还应该有一部分关于“为什么分支预测正确了之后性能就提高了”的描述,打算是从 CPU 指令流水线的角度切入的。

但是我没时间写了。

而且这样的文章其实网上也不少了,我就在这里提一嘴,如果你感兴趣的话自己去找找吧,就当是个课后作业吧。

什么,你问为什么没有时间写了?

这篇文章是我在有道云笔记里面写的,之前一直用的好好的,不知道为啥,写这篇文章的时候出现了两次数据丢失的情况,就是写着写着整篇文章突然被清空了,也不支持撤回。

这个“显示历史版本”的功能真的搞得我很迷,不知道这个产品它啥逻辑:

比如 20:15 分到 23:23 分之间,我一直不停的在打字,但是它只有五个版本:

五个版本就算了,关键是它最后两次版本之间,虽然差了半小时,但是内容只差了一两行。

关键这种“突然被情况的情况”还出现了两次,所以有一部分段落,我写了三次,实在是有点搞心态了,打断了思路。

本来周末就只有一天时间的,结果还浪费了一些。

气得我当场...

什么,你问我为什么周末只有一天时间?

因为我和以前不一样了,以前两天都要卷,现在我长大了,我要留一天时间出去玩。

机器学习(
ML
)作为目前一个比较火领域,提供了许多有趣且高薪的工作和机会。

无论你是刚刚踏入机器学习领域的新手,还是已经积累了一定经验的从业者,面试都是检验你技能和知识的重要环节。
本文将梳理一些常见的面试问题,让你在面试中更加自信从容。

1. 基础知识

想要从事机器学习工作,至少应该熟悉:

  • 数学基础
    :包括线性代数、微积分、优化、概率和统计等
  • 机器学习基础
    :准备数据、验证和改进训练结果、解释模型、识别和避免过度拟合等
  • 常用算法
    :比如线性回归、决策树、支持向量机、k 最近邻、神经网络、k 均值聚类、主成分分析等
  • 编程能力
    :需要一些
    Python
    等编程语言知识,以及使用机器学习库的能力(如 NumPy、Pandas、scikit-learn、Matplotlib、Tensorflow 等)等

2. 常见问题整理

接下来,整理了一些适合初学者和中级人员的一般问题,这些问题与任何特定的机器学习算法或方法无关。

通过掌握这些常见问题及其解答思路,不仅能更加深入地理解机器学习的核心概念,还能在面试中展现出你的专业素养和解决问题的能力。

2.1. 机器学习算法有哪些类型

机器学习算法主要分为三种类型:

  1. 监督学习
    :对给定输入数据(特征)和输出数据之间的数学依赖关系(映射)进行建模。

主要解决回归和分类问题,其中回归问题具有连续的数字输出,而分类则处理离散的、通常是分类的输出。

  1. 无监督学习
    :在不提供任何输出的情况下在输入数据中查找结构、规则和模式。

无监督学习方法有几类,例如聚类分析、关联规则学习、异常检测等。

  1. 强化学习
    :采取行动最大化奖励,并根据过去的经验不断学习和改进。

此外,还有
半监督学习
,它介于监督学习和无监督学习之间。

2.2. 什么是数据标准化和归一化

机器学习(
ML
)中数据集
标准化
之后,就可以比较不同单位的特征,这是许多
ML
方法(如支持向量机、神经网络、k 均值聚类、线性判别分析等)的要求。

标准化
通常意味着对特征进行重新调整,使其均值为零,标准差为一。
在某些情况下,可以使用
最小-最大标准化
来代替,它重新调整特征,以便最小值映射到零,最大值映射到一,而所有其他值在零和一之间线性分布。

2.3. 什么是R2

R2
(决定系数)是一个数值,表示输入能够解释输出的程度。
一般用作拟合优度的度量,即回归问题中实际输出和预测输出的接近程度,此值越大越好,R2 = 1 表示完美拟合。

2.4. I类和II类错误是什么

I 类错误
(假阳性错误)表示错误地拒绝了真实的原假设。
II 类错误
(假阴性错误)是错误地接受错误的原假设。

2.5. 条件概率是什么

条件概率
是在某些事件已经发生的情况下事件将发生的概率。
比如,在
事件 F
发生的情况下,
事件 E
发生的概率为:
P(E|F) = P(EF) / P(F)
,其中** P(EF)** 是两个事件都发生的概率,而
P (F)
是 F 发生的概率。

2.6. 什么是训练、验证和测试数据集

训练集
是数据集的一部分,用于训练模型,即拟合其参数;
验证集
是超参数调整期间使用的数据集的另一部分;
测试集
是数据集的第三部分,用于评估所选模型的性能。

数据集的这三个部分通常是独立的并且是随机选择的。

2.7. 什么是过拟合

当模型和现有数据匹配的太好时,通常会发生过度拟合。

过度拟合
的模型通常在
训练数据
上表现良好,但在应用于看不见的数据(
测试数据
)时表现不佳。
复杂或灵活的模型更容易出现过度拟合。

2.8. 什么是降维

降维是一组减少机器学习模型特征(输入变量)数量的技术。
降维的主要方法有两种:

  1. 特征选择
    :选择最重要特征的子集
  2. 特征提取
    :用一组新的、更小的派生特征替换所有特征,以最大限度地减少冗余。

2.9. 内核技巧是什么

内核技巧
与将数据映射到高维空间以使其明显可分离有关。
它避免计算该空间中数据点的新坐标,核技巧对于支持向量机和主成分分析很重要。

2.10. 梯度下降法是什么

梯度下降
是一种快速、迭代、近似、基于梯度的优化方法,旨在找到函数的局部最小值。
它从起点沿最陡下降的方向迭代移动,使用函数的负梯度计算方向和步长。

如果函数是
凸函数
,则梯度下降搜索全局最小值。

2.11. 什么是聚类

聚类

聚类分析
是根据数据点(观测值)特征之间的相似性将数据点(观测值)分为两个或多个组(簇)的过程。

一些聚类方法包括 k 均值聚类、均值漂移聚类、层次聚类、谱聚类、亲和传播、DBSCAN 等。

2.12. 偏差-方差权衡是什么

偏差
是模型预测的输出与实际输出之间的差异;
方差
是不同训练集的模型预测变异性的度量。

简单的模型
可能拟合不足,并且具有高偏差和低方差;
相反,
复杂模型
(具有许多参数)有时会出现低偏差和高方差的过度拟合。

我们想要的是偏差和方差的尽可能低的值,为了实现这一目标,我们必须找到适当复杂性的模型。

3. 最后

当然,工作面试不仅仅是询问和回答与领域相关的问题。
还应该关注一些工作面试中的一般建议,比如:

  1. 预先了解准备面试的公司
  2. 准备好介绍自己在该领域的经验、兴趣以及想要这份工作的原因
  3. 准备好介绍自己的优势和为什么适合该职位
  4. 着装和举止得体

人工智能时代,最需要学习的编程语言是:python 。笔者是个 python 小白,昨天花了两个小时,第一次成功运行起来 python 项目 。

项目是 powerpoint-extractor ,可以将 ppt 文件中的图片提取出来,并输出到固定的目录。

1 安装 python 环境

首先打开终端,打开后输入 python3 。确定电脑上是否已安装 python3,如果输入 python 是查看 mac 上的自带版本。

命令:python3【直接回车】

出现下面是页面,表示已经安装python3 【退出时可输入:exit()然后点回车】

若没有安装,安装 python3 如下两种方式:

  1. 第一种方法 brew 安装 python3 :brew install python3

  2. 第二种方法 官网
    Python Releases for macOS
    ,根据自己的需求下载自己需要的版本下载 。

2 项目 powerpoint-extractor

通过 git 命令 clone 该项目 :

git clone git@github.com:2TallTyler/powerpoint-extractor.git

因为项目依赖 python-pptx 组件,通过清华的镜像执行如下的命令:

pip3 install -i https://pypi.tuna.tsinghua.edu.cn/simple python-pptx

执行完成之后,可以通过 pip3 list 命令查看已安装包列表 :

3 PyCharm 配置

通过 PyCharm 打开该项目 :

上图,我们发
现 python 解释器并没有配置好 ,py 脚本显示 import 包失败

点击添加 python 解释器按钮,勾选继承全局包,并确认好 python3 的执行目录是否正确,点击 OK 即可完成配置。

点开 extract.py ,核心代码非常容易理解:

for eachfile in glob.glob(self.input_dir + os.sep + "*.pptx"):
    ppt = Presentation(eachfile)
    print("* " + eachfile)
    presentation_count += 1
    self.cur_image_index = 1

    name = self.generate_image_name_part(eachfile)

    # 遍历每张幻灯片
    for page, slide in enumerate(ppt.slides):
        # 将幻灯片上的所有文本收集到一个字符串中,以换行符分隔
        text = ''
        for shape in slide.shapes:
            if shape.has_text_frame and shape.text.strip():
                text += os.linesep
                text += shape.text

        # 收集每张幻灯片中的图像
        self.cur_slide_images = []

        # 保存幻灯片中的图像
        for shape in slide.shapes:
            self.drill_for_images(shape, page + 1, name)

        # 将页码、收集到的文本和演讲者备注作为新行写入CSV文件
        image_list = ''
        if len(self.cur_slide_images) > 0:
            image_list = ','.join(self.cur_slide_images)  # 将图像列表转换为逗号分隔的字符串

        # 将信息写入CSV文件
        writer.writerow([eachfile, page + 1, text, slide.notes_slide.notes_text_frame.text, image_list])

这段代码执行了以下操作:

  • 对于每个 PowerPoint 文件,它加载演示文稿并逐一遍历每张幻灯片。
  • 对于每张幻灯片,它收集文本和图像信息,并将其格式化为 CSV 文件的一行。
  • CSV 文件的每一行包括文件名、页码、幻灯片文本、幻灯片的演讲者备注以及图像列表。

4 运行项目

将测试 ppt 拷贝到 input 目录,点击 run 。

当执行完成后,ppt 中有的图片拷贝到 images 目录,同时生成了一个 text.csv 。


当然,我们也可以通过如下的命令直接执行:


如果我的文章对你有所帮助,还请帮忙
点赞、在看、转发
一下,你的支持会激励我输出更高质量的文章,非常感谢!