阿里三面:ConcurrentHashMap多线程扩容机制

一、引言

ConcurrentHashMap 技术在互联网技术使用如此广泛,几乎所有的后端技术面试官都要在 ConcurrentHashMap 技术的使用和原理方面对小伙伴们进行 360° 的刁难。

作为一个在互联网公司面一次拿一次 Offer 的面霸,打败了无数竞争对手,每次都只能看到无数落寞的身影失望的离开,略感愧疚(请允许我使用一下夸张的修辞手法)。

于是在一个寂寞难耐的夜晚,暖男我痛定思痛,决定开始写 《吊打面试官》 系列,希望能帮助各位读者以后面试势如破竹,对面试官进行 360° 的反击,吊打问你的面试官,让一同面试的同僚瞠目结舌,疯狂收割大厂 Offer

虽然现在是互联网寒冬,但乾坤未定,你我皆是黑马!

本篇文章大约读取时间十分钟,带你彻底掌握 ConcurrentHashMap魔鬼式的多线程扩容 逻辑,目录如下:

二、扩容流程

今天这篇文章,我们重点讲一下 ConcurrentHashMap 魔鬼式的多线程扩容逻辑

系好安全带,我们发车了!

1、treeifyBin方法触发扩容

// 在链表长度大于等于8时,尝试将链表转为红黑树
private final void treeifyBin(Node<K,V>[] tab, int index) {
    Node<K,V> b; int n, sc;
    // 数组不能为空
    if (tab != null) {
        // 数组的长度n,是否小于64
        if ((n = tab.length) < MIN_TREEIFY_CAPACITY)
            // 如果数组长度小于64,不能将链表转为红黑树,先尝试扩容操作
            // 这里的扩容传入的数据是当前数据的2倍
            tryPresize(n << 1);
        // 省略部分代码……
    }
}

2、tryPreSize方法-针对putAll的初始化操作

private final void tryPresize(int size) {
    // 如果当前传过来的size大于最大值MAXIMUM_CAPACITY >>> 1,则取 MAXIMUM_CAPACITY 
    // 反之取 tableSizeFor(size + (size >>> 1) + 1)= size的1.5倍 + 1
    // tableSizeFor:这个方法我们之前也讲过,找size的最近二次幂
    // 这个地方的c
    int c = (size >= (MAXIMUM_CAPACITY >>> 1)) ? MAXIMUM_CAPACITY : tableSizeFor(size + (size >>> 1) + 1);
    int sc;
    // 当前的sizeCtl大于等于0,证明不属于异常状态
    // static final int MOVED     = -1; // 代表当前hash位置的数据正在扩容!
    // static final int TREEBIN   = -2; // 代表当前hash位置下挂载的是一个红黑树
    // static final int RESERVED  = -3; // 预留当前索引位置……
    while ((sc = sizeCtl) >= 0) {
        Node<K,V>[] tab = table; int n;
        // 如果当前的数组为空,走初始化的逻辑
        // 有人可能会好奇,这里怎么会为空呢?
        // 大家可以去看下putAll的源码(放在下面),源码里面第一句调用的就是tryPresize的方法
        if (tab == null || (n = tab.length) == 0) {
            // 进来执行初始化
            // sc是初始化长度,初始化长度如果比计算出来的c要大的话,直接使用sc,如果没有sc大,
            // 说明sc无法容纳下putAll中传入的map,使用更大的数组长度
            n = (sc > c) ? sc : c;
            // 还是老配方,CAS将SIZECTL尝试从sc变成-1
            if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
                try {
                    // Step1:判断引用
                    // Step2:创建数组
                    // Step3:修改sizeCtl数值
                    if (table == tab) {
                        Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
                        table = nt;
                        sc = n - (n >>> 2);
                    }
                } finally {
                    sizeCtl = sc;
                }
            }
        }
        // 如果计算出来的长度c如果小于等于sc,直接退出循环结束方法
        // 数组长度大于等于最大长度了,直接退出循环结束方法
        // 不需要扩容
        else if (c <= sc || n >= MAXIMUM_CAPACITY)
            break;
        // 省略部分代码
    }
}

// putAll方法
public void putAll(Map<? extends K, ? extends V> m) {
    // 直接走扩容的逻辑
    tryPresize(m.size());
    for (Map.Entry<? extends K, ? extends V> e : m.entrySet())
        putVal(e.getKey(), e.getValue(), false);
}

3、tryPreSize方法-计算扩容戳

private final void tryPresize(int size) {
    int c = (size >= (MAXIMUM_CAPACITY >>> 1)) ? MAXIMUM_CAPACITY :
        tableSizeFor(size + (size >>> 1) + 1);
    int sc;
    while ((sc = sizeCtl) >= 0) {
        Node<K,V>[] tab = table;
        // 当前数组的长度
        int n; 
        // 判断引用是否一致
        if (tab == table) {
            // 计算扩容标识戳,根据当前数组的长度计算一个16位的扩容戳
            // 1、保证后面的SIZECTL赋值是负数
            // 2、记录当前从什么长度开始扩容
            int rs = resizeStamp(n);
            // 这里存在bug
            // 由于我们上面(sc = sizeCtl) >= 0的判断,sc只能大于0
            // 这里永远大于等于0
            if (sc < 0) {
                // 协助扩容的代码
            }
            // 代表当前没有线程进行扩容,我是第一个扩容的
            else if (U.compareAndSwapInt(this, SIZECTL, sc, (rs << RESIZE_STAMP_SHIFT) + 2))
                transfer(tab, null);
        }
    }
}

// 计算扩容标识戳
static final int resizeStamp(int n) {
    return Integer.numberOfLeadingZeros(n) | (1 << (RESIZE_STAMP_BITS - 1));
}

4、tryPreSize方法-对sizeCtl的修改

private final void tryPresize(int size) {
    // sc默认为sizeCtl
    while ((sc = sizeCtl) >= 0) {
        else if (tab == table) {
            // rs:扩容戳  00000000 00000000 10000000 00011010
            int rs = resizeStamp(n);
            if (sc < 0) {
                // 说明有线程正在扩容,过来帮助扩容
                // 我们之前第一个线程扩容的时候,将sizeCtl设置成:10000000 00011010 00000000 00000010
                // 所以:sc = 10000000 00011010 00000000 00000010
                Node<K,V>[] nt;
                // 依然有BUG
                // 当前线程扩容时,老数组长度是否和我当前线程扩容时的老数组长度一致
                // 00000000 00000000 10000000 00011010
                if ((sc >>> RESIZE_STAMP_SHIFT) != rs  
                    // 10000000 00011010 00000000 00000010 
                    // 00000000 00000000 10000000 00011010
                    // 这两个判断都是有问题的,核心问题就应该先将rs左移16位,再追加当前值。
                    // 这两个判断是BUG
                    // 判断当前扩容是否已经即将结束
                    || sc == rs + 1   // sc == rs << 16 + 1 BUG
                    // 判断当前扩容的线程是否达到了最大限度
                    || sc == rs + MAX_RESIZERS   // sc == rs << 16 + MAX_RESIZERS BUG
                    // 扩容已经结束了。
                    || (nt = nextTable) == null 
                    // 记录迁移的索引位置,从高位往低位迁移,也代表扩容即将结束。
                    || transferIndex <= 0)
                    break;
                // 如果线程需要协助扩容,首先就是对sizeCtl进行+1操作,代表当前要进来一个线程协助扩容
                if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1))
                    // 上面的判断没进去的话,nt就代表新数组
                    transfer(tab, nt);
            }
            // 是第一个来扩容的线程
            // 基于CAS将sizeCtl修改为:10000000 00011010 00000000 00000010 
            // 将扩容戳左移16位之后,符号位是1,就代码这个值为负数
            // 低16位在表示当前正在扩容的线程有多少个:低16位为2,代表当前有一个线程正在扩容
            // 为什么低16位值为2时,代表有一个线程正在扩容
            // 每一个线程扩容完毕后,会对低16位进行-1操作,当最后一个线程扩容完毕后,减1的结果还是-1,
            // 当值为-1时,要对老数组进行一波扫描,查看是否有遗漏的数据没有迁移到新数组
            else if (U.compareAndSwapInt(this, SIZECTL, sc,(rs << RESIZE_STAMP_SHIFT) + 2))
                // 调用transfer方法,并且将第二个参数设置为null,就代表是第一次来扩容!
                transfer(tab, null);
        }
    }
}

5、transfer方法-计算每个线程迁移的长度

private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) {
    // 数组长度	
    int n = tab.length; 
    // 每个线程一次性迁移多少数据到新数组
    int stride; 		
    // 基于CPU的线程数来计算:NCPU = Runtime.getRuntime().availableProcessors()
    // MIN_TRANSFER_STRIDE = 16
    // NCPU = 4
    // 举个例子:N = 1024 - 512 - 256 - 128 / 4 = 32
    // 如果算出来每个线程的长度小于16的话,直接使用16
    // 如果大于16的话,则使用N
    // 如果线程数只有1的话,直接就是原数组长度
    if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE){
        stride = MIN_TRANSFER_STRIDE;
    }
}

6、 transfer方法-构建新数组并查看标识属性

// 以32位长度数组扩容到64位为例子
private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) {
    // 数组长度	
    int n = tab.length; 
    // 每个线程一次性迁移多少数据到新数组
    int stride; 	
    // 第一个进来扩容的线程需要把新数组构建出来
    if (nextTab == null) {            
        try {
            // 将原数组长度左移一位创建新数组
            Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n << 1];
            // 赋值操作
            nextTab = nt;
        } catch (Throwable ex) {      
            sizeCtl = Integer.MAX_VALUE;
            return;
        }
        // 将成员变量的新数组赋值volatile修饰
        nextTable = nextTab;
        // 迁移数据时用到的标识volatile修饰
        transferIndex = n;
    }
    // 新数组长度
    int nextn = nextTab.length;
    // 老数组迁移完数据后,做的标识
    ForwardingNode<K,V> fwd = new ForwardingNode<K,V>(nextTab);
    // 迁移数据时,需要用到的标识
    boolean advance = true;
    boolean finishing = false; 
}

7、transfer方法-线程领取迁移任务

// 以32位长度数组扩容到64位为例子
// 数组长度	
int n = tab.length; 
// 每个线程一次性迁移多少数据到新数组
int stride;
// 迁移数据时用到的标识volatile修饰
transferIndex = n;
// 新数组长度
int nextn = nextTab.length;
// 老数组迁移完数据后,做的标识
ForwardingNode<K,V> fwd = new ForwardingNode<K,V>(nextTab);
// advance:true,代表当前线程需要接收任务,然后再执行迁移
// 如果为false,代表已经接收完任务
boolean advance = true;
// 标识迁移是否结束
boolean finishing = false;
// 开始循环
for (int i = 0, bound = 0;;) {
    Node<K,V> f; 
    int fh;
    while (advance) {
        int nextIndex;
        int nextBound;
        // 第一次:这里肯定进不去
        // 主要判断当前任务是否执行完毕
        if (--i >= bound || finishing)
            advance = false;
        // 第一次:这里肯定也进不去
        // 判断transferIndex是否小于等于0,代表没有任务可领取,结束了。
        // 在线程领取任务会,会对transferIndex进行修改,修改为transferIndex - stride
        // 在任务都领取完之后,transferIndex肯定是小于等于0的,代表没有迁移数据的任务可以领取
        else if ((nextIndex = transferIndex) <= 0) {
            i = -1;
            advance = false;
        }
        
       	// nextIndex=32
        // stride=16
        // nextIndex > stride ? nextIndex - stride : 0:当前的nextIndex是否大于每个线程切割的
        // 是:nextIndex - stride/否:0
        // 将TRANSFERINDEX从nextIndex变成16
        else if (U.compareAndSwapInt(this, TRANSFERINDEX, nextIndex,
                                     nextBound = (nextIndex > stride ?nextIndex - stride : 0))) {
            // 对bound赋值(16)
            bound = nextBound;
            // 对i赋值(31)
            i = nextIndex - 1;
            // 赋值,代表当前领取任务结束
            // 该线程当前领取的任务是(16~31)
            advance = false;
        }
    }
}

线程领取扩容任务的流程:

image-20230521204745277

8、transfer方法-扩容是否已经结束

// 判断扩容是否已经结束!
// i < 0:当前线程没有接收到任务!
// i >= n: 迁移的索引位置,不可能大于数组的长度,不会成立
// i + n >= nextn:因为i最大值就是数组索引的最大值,不会成立
if (i < 0 || i >= n || i + n >= nextn) {
    int sc;
    // 第一次到这里:必定是false
    // 如果再次到这里的时候,最后一个扩容的线程也扫描完了
    if (finishing) {
        nextTable = null;
        table = nextTab;
        // 重新更改当前的sizeCtl的数值:0.75N
        sizeCtl = (n << 1) - (n >>> 1);
        return;
    }
    // 到这里代表当前线程已经不需要去扩容了
    // 那么它要把当前并发扩容的线程数减一
    // SIZECTL ----> sc - 1
    if (U.compareAndSwapInt(this, SIZECTL, sc = sizeCtl, sc - 1)) {
        // 判断当前这个线程是不是最后一个扩容线程
        if ((sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT)
            // 如果不是的话,直接返回就好了
            return;
        // 如果是的话,当前线程还需要将旧数据全部扫描一遍,校验一下是不是都迁移完了
        finishing = advance = true;
        // 将当前的i重新变成原数组的长度
        // 重新进行一轮校验
        i = n; // recheck before commit 
    }
}

9、 transfer方法-迁移数据(链表)

    // 如果当前的i位置上没有数据,直接不需要迁移
	else if ((f = tabAt(tab, i)) == null)
        // 将原来i位置上CAS标记成fwd
        // fwd的Hash为Move
        advance = casTabAt(tab, i, null, fwd);
	// // 拿到当前i位置的hash值,如果为MOVED,证明数据已经迁移过了。
    else if ((fh = f.hash) == MOVED)
        // 那么直接返回即可,主要是最后一个扩容线程扫描的时候使用
        advance = true; // already processed
    else {
        // 锁住
        synchronized (f) {
            // 再次校验
            if (tabAt(tab, i) == f) {
                // ln:null  - lowNode
                Node<K,V> ln;
                 // hn:null  - highNode
                Node<K,V> hn;
                // 如果当前的fh是正常的
                if (fh >= 0) {
                    // 求当前的runBit
                    // 这里只会有两个结果:0 或者 n
                    int runBit = fh & n;
                    // 用当前lastRun指向f
                    Node<K,V> lastRun = f;
                    // 进行链表的遍历
                    for (Node<K,V> p = f.next; p != null; p = p.next) {
                        // 求每一个链表的p.hash & n
                        int b = p.hash & n;
                        // 如果当前的b不等于上一个节点
                        // 需要更新下runBit的数据和lastRun的指针
                        if (b != runBit) {
                            runBit = b;
                            lastRun = p;
                        }
                    }
                    // 如果最后runBit=0的话
                    // lastRun赋值给ln
                    if (runBit == 0) {
                        ln = lastRun;
                        hn = null;
                    }
                    // 如果最后runBit=0的话
                    // lastRun赋值给hn
                    else {
                        hn = lastRun;
                        ln = null;
                    }
                    // 从数组头节点开始一直遍历到lastRun位置
                    for (Node<K,V> p = f; p != lastRun; p = p.next) {
                        // hash/key/value
                        int ph = p.hash; K pk = p.key; V pv = p.val;
                        // 如果当前的是0,则挂到ln下面
                        if ((ph & n) == 0)
                            ln = new Node<K,V>(ph, pk, pv, ln);
                        // 如果当前的是1,则挂到hn下面
                        else
                            hn = new Node<K,V>(ph, pk, pv, hn);
                    }
                    // 将新数组i位置设置成ln
                    setTabAt(nextTab, i, ln);
                    // 将新数组i+N位置设置成hn
                    setTabAt(nextTab, i + n, hn);
                    // 将原来数组的i位置设置成fwd,代表迁移完毕
                    setTabAt(tab, i, fwd);
                    // 将advance设置成true,保证进行上面的while循环
                    // 执行上面的i--,进行下一节点的迁移计划
                    advance = true;
                }
            }
        }
    }
}

9.1 LastRun机制

假如当前我需要将数组从 16 扩容至 32,我们看下原来的结构:

image-20230521222843218

当我们进行下面这一步时:

int runBit = fh & n;
for (Node<K,V> p = f.next; p != null; p = p.next) {
    // 求每一个链表的p.hash & n
    int b = p.hash & n;
    // 如果当前的b不等于上一个节点
    // 需要更新下runBit的数据和lastRun的指针
    if (b != runBit) {
        runBit = b;
        lastRun = p;
    }
}

图片如下:紫色代表 0,粉色代表 1

image-20230521223401481

在迁移之前,我们要掌握一个知识,由于我们的数组是 2 倍扩容,所以原始数据的数据一定会落在新数组的 2N + 2 的位置

image-20230521223702370

最后将我们的数组从原始数组直接迁移过来,由于 lastRun 之后位置的数据都是一样的hash,所以直接全量迁移即可,不需要挨个遍历,这也是实施 lastRun 的原因,减少时间复杂度

image-20230521223916426

最终迁移效果如上,迁移完毕,减少 i 的下标,继续迁移下一个数组的位置。

10、协助扩容

// 如果当前的hash是正在扩容
if ((fh = f.hash) == MOVED){
     // 进行协作式扩容
    tab = helpTransfer(tab, f);
}
   

final Node<K,V>[] helpTransfer(Node<K,V>[] tab, Node<K,V> f) {
    Node<K,V>[] nextTab; 
    int sc;

    // 第一个判断:老数组不为null
    // 第二个判断:新数组不为null  (将新数组赋值给nextTab)
    if (tab != null && (f instanceof ForwardingNode) &&
        (nextTab = ((ForwardingNode<K,V>)f).nextTable) != null) {
        // 拿到扩容戳
        int rs = resizeStamp(tab.length);
        // 第一个判断:fwd中的新数组,和当前正在扩容的新数组是否相等。    
        // 相等:可以协助扩容。不相等:要么扩容结束,要么开启了新的扩容
        // 第二个判断:老数组是否改变了。     相等:可以协助扩容。不相等:扩容结束了
        // 第三个判断:如果正在扩容,sizeCtl肯定为负数,并且给sc赋值
        while (nextTab == nextTable && table == tab &&
               (sc = sizeCtl) < 0) {
            // 判断扩容戳是否一致
            // 看下transferIndex是否小于0,如果小于的话,说明扩容的任务已经被领完了
            if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 ||
                sc == rs + MAX_RESIZERS || transferIndex <= 0)
                break;
            // 如果还有任务的话,CAS将当前的SIZECTL加一,协助扩容
            if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1)) {
                // 协助扩容的代码,上面刚刚分析过
                transfer(tab, nextTab);
                break;
            }
        }
        return nextTab;
    }
    return table;
}

三、流程图

在这里插入图片描述

四、总结

鲁迅先生曾说:独行难,众行易,和志同道合的人一起进步。彼此毫无保留的分享经验,才是对抗互联网寒冬的最佳选择。

其实很多时候,并不是我们不够努力,很可能就是自己努力的方向不对,如果有一个人能稍微指点你一下,你真的可能会少走几年弯路。

我是爱敲代码的小黄,独角兽企业的Java开发工程师,CSDN博客专家,喜欢后端架构和中间件源码。

我们下期再见。

我从清晨走过,也拥抱夜晚的星辰,人生没有捷径,你我皆平凡,你好,陌生人,一起共勉。

#面试##实习##Java##HashMap#
全部评论
黄总马上要进阿里了耶
点赞 回复 分享
发布于 2023-05-23 00:17 湖南
黄总黄总!!!
点赞 回复 分享
发布于 2023-05-23 00:17 湖南

相关推荐

04-26 14:36
已编辑
郑州信息科技职业学院 Java
由于高考成绩不是很理想,听取了张雪峰老师的建议,优先选了专业并且当时的想法就是选一个能赚钱的专业,于是最终选择了报了一个能收留我的有计算机专业的学校。当时听张雪峰老师说河南的学习氛围很好,所以就想去体验一下,事实雀食如张雪峰老师所说,大家都一股脑的铺在学习这条路上。可能是因为那边氛围导致的吧,我一开始想的也是卷学习卷绩点,所以大一的时候就一直在学习硬试教育的一些东西,学期结束了,排名出来的时候中上水平吧,据我了解保研的只有前5名可能会有机会,当时的心里就想着,我这成绩再卷也卷不到哪去了,并且保研也无望了,总结的说,一些事情只有真正做了才知道是不是自己所追求的。说了很多废话吧,剩下的关于学校的就长话短说了吧。大二很多专业课基本上要从早八上到晚上,但基本上我都是不去,不如自学现在新媒体技术这么发达,并且还可以学一下自己需要的技术栈,由于学校的课程原因对其他的技术栈不是很了解,所以,一心就投入在Java这个方向了,但是,Python也会学一下,这是因为加入实验室,实验室老师是做人工智能方向的缘故。现在回想,我大二当时还是学的太慢了,还有就是信息差太大了,出来工作之后才发现有些佬们已经大二就出来实习,并且八股就背的滚瓜烂熟了。只能说这里的学习氛围很好吧,走廊里都是背书刷题的声音,跟身边的同学和实验室的同学谈是否直接就业的事,他们要么都是说考研,要么对直接就业很含糊,可能是因为觉得自己学的还不够吧,我想说,学的不够就干中学呗,反正,我先迈出去这步再说。到了大三上还是没有找工作的打算,因为身边的人也都还没有这个意识吧,现在跟了身边的同事聊天才知道,我的信息差太大了。到了大三下刚开始,我才开始正式的踏上求职路,当时的信息差还是很大的,根本就不敢碰瓷大厂,想着有一个公司能要再说吧,并且地域也限制的很死,只想着在本地找一下,因为怕学校找事(我想这是学校一贯操作了),在本地吧,他们大多数都是接受的线下面,一开始面了一个,可能自己比较摆也很悲观,就显得我很差吧,hr面完就没后续了,最终终于有一个面,并且也展示出自己的自信和对专业的理解了,最后,我也没想着这么多背调公司呀,当个备选什么的就直接去了。也算是我的第一家正式的公司吧(之前都是线上的码农兼职),干多了就发现,这个公司压根学不到东西,并且薪资低的,因为我是第一个进来的计算机实习生,有一个同事干了两三年的吧,带着我做的时候是真能学到东西,但是,最后那个同事离职了,我就只能和学艺术的老板直接汇报项目进度,一个学艺术的来指导我这个科班出身的就很离谱的好吧。最后,我也离职了,也跟前同事聊了很久,她说我是她见过大三就能学到这程度,已经超过很多人了,并且她当时在的时候还说我是内定能转正的。并且还说我真的可以去考研。我也仔细思考了一下,我决定让自己沉淀一下再出发吧,先备考了软件设计师,然后期末考,大三暑期的时候就充实自己的简历,并且也认识了一个某东的老哥,也用了内推码,教我了怎么写好简历量化成果之类的,总之,很感谢一路走来帮助我的人吧,并且我在边充实自己的同时也在边投递简历,但当时卡的也很死,要选base地在河南附近的,不像现在全国可飞。面了很多base地在学校附近的,然后,还有一个北京的py和杭州的java,最终就这两个地方给了offer,但是都是实习转正的,不是秋招offer,因为觉得Java的太卷了,然后,面试的时候也会感觉压力很大,所以就把杭州的那个拒了,去了北京的,北京是免费住的房子(三个月这是伏笔),当时觉得环境很好,但是合租室友的作息跟自己的作息不一样就很不习惯,于是,我就想着要是三个月后我一定要找一个单间的哪怕破一点。北京这个公司吧就很像国企的感觉,早九晚五,当月发当月工资,并且干的活接触的数据量都不是很大,就是干了很多杂活,并且mentor和部门的领导都不是技术出身,所以,我能学到的东西少之又少,但是吧,学习是自己的事,而且这部门不是很忙对于实习生来说,我完全可以学自己的东西(前提是不被发现)。到最后这个部门的氛围就很微妙,我遇到不会的问他们我应该怎么做的时候,他们说让我自己想,我当时就想说,神人一个,啥都不说让我自己干,干出来又不满意,你说你让我干py的东西你不会我就不说啥了,让我干无关代码的东西,让我调研项目应该做些什么内容,现在回想都是泪呀,我就这样被欺压的过完了三个月,最后免费住的地方也到期了,伏笔来了,最后,找我谈话说你技术可以了能看出来,因为你也自己独立完成了消息通知那一块内容嘛,但是,由于我们部门干的活比较杂并且我也缺少一些电力相关的一些知识,所以,觉得不合适。(OS:其实我对每一份工作都是真心换真心的,并且这些电力知识我也知道我有一点欠缺所以我也有自己再学习,你们啥也不教我,最后把屎盆子把我头上扣)最后,回到了学校,心态也发生了变化,想着做啥都不如找一个稳定的工作重要,想着回家沉淀吧,少年终有出头日。但是,计划赶不上变化,之前那个同事,内推了我去她现在的公司,并且是做AI应用的也是我想接触的,并且还是与我上家的业务场景类似的,真的感谢那个同事,俗话说:千里马常有而伯乐不常有。并且那里的部门领导也很好,并且说我虽然不是电力相关出身的,但是能做的这样已经很不错了,所以DDDD,由于各种不可抗力因素吧,还是想找一个离家近,然后不是很像小作坊的感觉(这个公司虽然比较小,但是比之前那个大的公司的氛围和待遇一点都不差的好吧甚至更好)。最终,在学校也呆了一个月吧,也陆陆续续面了一个月有一个C厂的面答的都挺好直接就谈薪了,但是风评不好还是保命要紧,还有各种的中小厂面吧,但感觉都不是自己想要的,只是想刷刷面试经验吧(这是某东哥告诉我的,与其一直改简历不如去多面)。最后,在校期间面了一个比较合适的某鸦智能,一直推进到了HR面,但是最后被横向了,开始复盘,被横向了属实是没招了,经历了这么多大风大浪什么场面没见过。过年期间,求职路线关闭,把自己缺少的技术栈和简历中的项目业务理清楚说明白。年过完就要开始加入找工作大军中了,把节前没面完的先面了,节后一开始就是某鸟的HRG面,聊的就很憋屈的感觉,问我技术方面的,说我说的很像AI的(我心想跟你说具体的细节你又说我不想听技术的,说的比较宽泛浅显说我AI)。最后,反正体验感不是很好的结束了吧。说一个星期等通知,等了两个星期才说是通过的(我认为是排名靠前的那些人没去,顺位到我了)。那你既然这样说了,那我就接受吧。还没入职就问我要身份证信息要这要那的,最后都给过去了,说HC调整,要重新review,又又又一次被恶心到了。后面就是陆续的沉淀面试等,我当时的重心已经完全的想着私企没人要,就去试试考公和考央国企了,毕竟我的履历不看学历的话放到电网当中还是可以的。私企的话有一个外企洋里洋气的说话,问我怎么口语这么好?我说这叫智取,宝贝。虽然这个tek外企过了,但是还有一个openday要去线下,来回的衣食住行不是很方便也不是很想去所以就拒绝了没去。后来就收到了,国网网申通过的通知,说实话,我之前问了很多我们学校历年有没有考央国企之类的案例,很显然都不知道,也可以说少之又少吧,于是我就奔赴京城进京赶考,唉,时间不太合适就想着算了吧,再等等,好事多磨,宁缺毋滥吧。金三银四终于等来了面试的机会,这个岗位我只能说我不是很熟悉,但是语言这东西吧都是相通的,重要的是我要把其中的内核搞懂,梳理清楚业务逻辑。最终,来到了这家公司,目前来说是我遇到过最好的了,能有hc且不是要通过实习评估的那种,并且合同期限是三年的,并且是12%的公积金。我认为这就是我所遇到的最好的了。希望能真心换真心吧,不再把我当创口贴/路边一条了,并且也遇到了很多优秀的同事。总的来说,就是要是能重来我要选李白。我肯定会打破这些信息差,后悔知道的太晚,并且跟优秀的人聊天说话真的可以学到很多东西,之前上文提到的贵人就不说了,说说最近的,他是跟我一届,学校后缀甚至不如我的后缀,但是真正了解的才会知道真是佬👍,他跟我找工作的时间线差不多,但是他在中大厂甚至大厂都呆过,因为跟他聊了才知道我当时的信息差有多大,并且毅力也是我甚至…都没有的。并且也听说了他们学校找工作的氛围很好,不像我阿巴阿巴阿巴,只有考研等相关的一些。并且说的一些观点都是很认同的。总之,希望能在这好好的吧,我真的不想经历大起大落了。经历了,打招呼挂,简历挂,一面挂,HR面挂,offer挂的,现在的心态已经放宽了很多了,但是难过还是有的,希望这家公司诚不欺我吧。也祝大家遇到自己的梦中情厂
选择和努力,哪个更重要?
点赞 评论 收藏
分享
评论
2
5
分享

创作者周榜

更多
牛客网
牛客网在线编程
牛客网题解
牛客企业服务