阅读 287

《JAVA面试考点导读》(一)JDK基础类源码阅读

前言

很多同学想知道面试的知识,但是每个点的面太广,如阅读源码太过耗时间,不知道哪些才是重点。所以跟大家分享一下方法,就是像以前英语、语文的阅读理解题,我们可以先看题目,再带着问题去阅读文章,这样效率就会比较快。

所以准备面试也一样,带着问题点,去找答案。

文章形式

《JAVA面试考点导读》系列中,不会详细的叙述每个知识点,我也不重复的造轮子,因为网上有很多文章。在这系列中,我列出每个章节中重要的知识点,每个点的几个概述,然后推荐大家阅读某些文章。

针对阿里P6级别考点,用这种方式来帮大家在浩瀚海洋中画重点。

如: HashMap 1、数据结构 (常见程度:★★★★ 可以根据常见程度去看或者跳过,最高★★★★★) ....几个关键概述 2、size计算 3、1.7与1.8改进

大家可以私信我,有什么好文章,或者你自己写的好的文章,我会放在此文中,给你们做推广。

下述正文

源码阅读顺序:

blog.csdn.net/qq_21033663…

一、Object对象

1、对象头地址 (常见程度:★★★)

所谓的对象监视器就存储在对象的头部mark_word内存。 帧头,ObjectMonitor对象监视器所有字节包含对象的偏量锁、轻量级锁、重量级、线程ID标记位。

推荐阅读 blog.csdn.net/chengzhang1…

2、 锁升级 (常见程度:★★★)

进入锁流程

每个对象有一个监视器锁(monitor)。当monitor被占用时就会处于锁定状态,线程执行monitorenter指令时尝试获取monitor的所有权,过程如下: 1、如果monitor的进入数为0,则该线程进入monitor,然后将进入数设置为1,该线程即为monitor的所有者。 2、如果线程已经占有该monitor,只是重新进入,则进入monitor的进入数加1. 3.如果其他线程已经占用了monitor,则该线程进入阻塞状态,直到monitor的进入数为0,再重新尝试获取monitor的所有权。

推荐阅读 www.cnblogs.com/paddix/p/53…

锁升级

描述偏斜锁 轻量级 重量级,各个锁之间如何升级变化

  1. synchronized锁对象时也是对象锁,也是使用Object的monitor形式。
  2. 一般情况下没有竞争,当没有竞争出现时,默认会使用偏斜锁,就用标志,set线程id号,用于表示可重入。
  3. 后续有标志了,又有竞争就会cas,则转轻量级
  4. cas不成功再转重量级,如果当前的锁正在膨胀中(轻量级锁膨胀为重量级锁),重新获取mark_word,如有才升级为重量级锁。

推荐阅读 cloud.tencent.com/developer/a… www.cnblogs.com/jxxblogs/p/…

利用cas变更线程锁的状态

www.cnblogs.com/yinbiao/p/1…

COMPARE 偏移值(内存地址),当前值、预期值

3、 wait(), notify(), notifyAll(), wait(timeout)  (常见程度:★)

wait

总结下Object.wait()实现原理:

  • 首先能够调用wait方法,说明已经持有锁(或者说进入了synchronized代码块),而wait依赖于对象监视器objectmonitor的实现,所以首先得要获取这个monitor
  • monitor的指针存放在对象的头部,如果对象的头部没有,首先尝试从当前线程维护的omFreelist链表中分配,如果没有,从全局的gFreelist中分配,如果还没分配到,通过new的方式分配一个128大小的ObjectMonitor数组,放入到当前线程的omFreelist,以及全局的gFreelist,同时设置必要的信息
  • monitor分配好以后,进入wait,首先将当前线程封装成一个ObjectWaiter放入到objectMonitor的waitset队列中,然后退出对象监视器,挂起当前线程!

notify

  • notify时只是把notify标志职位1,当前线程加入到enter(synchronized)队列中,准备重新获取锁。既没有unpark挂起的线程,也没有调用ObjectMonitor::exit方法释放锁!!!!!
  • 主要区别就是wait放入waitset然后会park
  • notify是放入enter但是调用unpark 唤醒

4、锁粗化(常见程度:★)

for循环里面synchronized,若对象没有逃逸到方法外,synchronized会编译时改为for外面

5、锁消除(常见程度:★)

看方法内某个变量是否不会被其他引用,或者逃逸到方法外,如果没有逃逸到方法外,就去除synchronized

6、CAS引起的问题aba (常见程度:★★)

有三个线程 线程1读出A 线程2把A改为B 线程3把B改为A,线程1就未感知。

推荐阅读 blog.csdn.net/qq_36951116…

7、 hashCode(), equals()  (常见程度:★★★)

hashcode一样,内存地址不一定一样

推荐阅读 blog.csdn.net/weixin_3953…

为什么都要重写

推荐阅读 blog.csdn.net/xl_1803/art… 默认使用hashcode,获取map,相同信息但是获取不了,因为人为比较是根据成员属性,而不是内存对象地址 推荐阅读 baijiahao.baidu.com/s?id=162019…

如果equals而不重写hashcode,那么Student类的hashcode方法就是Object默认的hashcode方法,由于默认的hashcode方法是根据对象的内存地址经哈希算法得来的,显然此时s1!=s2,故两者的hashcode不一定相等。

主要是因为多数比较equal时,如hashmap,先比较hashCode 那为什么不直接用equal比较,因为Object的equal是比较对象地址,hashCode比较快

业务比较都是比较信息,而不是比较地址。

hashcode默认是内存地址 www.jianshu.com/p/db67af618…

8、 clone()

深拷贝,浅拷贝 复制时,要看成员是引用对象,还是值(基本类型)。 复制引用对象时会修改原来的。

推荐阅读 www.cnblogs.com/nickhan/p/8…

二、String  (常见程度:★)

final

String 为final修饰,不能重写、继承

数据结构

  1. char[] value
  2. int hash
  3. equals(), startWith(), endWith(), replace

三、AbstractStringBuilder

  1. 数组 char[] value
  2. int count
  3. 扩容:2倍,内存不够则取所需最小。 将扩大的部分全部用’\0’

扩容或add前先确认长度,后变长 int newCapacity = (value.length ) * 2;

append方法会先判断是否需要扩容, 先扩容,后插入

进过我查证源码发现,它Append()在执行的时候会判断添加的元素之前会判断现在的容量是否已满,已满的话会进行一次双倍扩容,当然不管你添加多少元素它只扩容一次,在添加完成之后,容量会和实际数量进行判断,实际数量大于容量容量就等于实际容量,当然前提是你没有设置StringBuilder的最大容量。

推荐阅读 blog.csdn.net/qq_41600074…

利用System.arraycopy

insert在某个位置插入str:System.arraycopy(value, index, value, index + len, count - index);

#四、StringBuffer

  1. 继承AbstractStringBuilder
  2. synchronized方法保证线程安全
  3. char[] toStringCache

5、StringBuilder 继承AbstractStringBuilder

五、Thread (常见程度:★)

blog.csdn.net/linxdcn/art…

1、四个状态 (常见程度:★★★)

  1. 就是 生命周期

NEW, RUNNABLE, BLOCKED, WAITING, TIMED_WAITING, TERMINATED;

  1. BLOCKED 和 WATING 的区别

一个是在临界点外面等待进入, 一个是在临界点里面wait等待别人notify,  WATING 等待唤醒,BLOCKED等待进入

2、流程、主要方法

推荐阅读 blog.csdn.net/caoxiaohong…

start()

// 启动一个线程 public synchronized void start() { // 线程不能重复start if (threadStatus != 0)

join()

调用wai(0)

###不用stop的原因

www.cnblogs.com/DreamDrive/…

同步锁,标志位

synchronized (blockerLock) { Interruptible b = blocker; if (b != null) { // 只是设置了中断标志位

interrupt() (常见程度:★★)

sleep也是中断,所以interrupt中断后,再中断就会抛异常,这样线程内就可以人为控制退出 blog.csdn.net/zhangliangz… blog.csdn.net/canot/artic…

3、退出线程时注意

JVM退出时Daemon线程中的finally块中的代码不一定会执行。因此不能依靠finally块中的内容来确保执行关闭或清理资源的逻辑

六、ThreadLocal(常见程度:★★★)

www.cnblogs.com/dolphin0520… maimai.cn/article/det…

  1. ThreadLocal,很多地方叫做线程本地变量,也有些地方叫做线程本地存储,其实意思差不多。 可能很多朋友都知道ThreadLocal为变量在每个线程中都创建了一个副本,那么每个线程可以访问自己内部的副本变量。 entry以ThreadLocal为key,其实是为了构造entry的父类,以Object为value

  2. 注意没有先set,直接get的话,运行时会报空指针异常。 重写了initialValue方法 就可以直接不用先set而直接调用get了。

3.ThreadLocal弱引用作为key,如果一个ThreadLocal没有外部强引用引用他,那么系统gc的时候,这个ThreadLocal势必会被回收,

Entry将全部被GC回收。但如果是线程对象不被回收的情况,比如使用线程池,线程结束是不会被销毁的,就可能出现真正意义上的内存泄漏。

jdbc深入理解connection与threadlocal

www.cnblogs.com/panxuejun/p…

七、ArrayList (常见程度:★★)

  1. Object[] elementData
  2. int size
  3. 默认大小10
  4. 扩容:*1.5,不够取所需最小

但ArrayList非线程安全,达到数组长度时每次扩大50%:用>>1的方式来加原来的长度

所以>>1等于乘以0.5

ArrayList扩容

默认长度10

blog.csdn.net/zymx14/arti…

add时限保证容量,检查好容量后,add对象进去

size表示使用多少个元素,add每次+1,到10就扩容

www.cnblogs.com/woshimrf/p/…

八、LinkedList (常见程度:★★)

  1. Node {E item, Node prev, Node next}
  2. int size
  3. Node first
  4. Node last
  5. linkFirst(), linkLast(), linkBefore(), unLinkFirst(), unLinkLast(), unLink(), indexOf()

LinkedList和ArrayList的区别(常见程度:★★★★)

插入速度优先linkedList 查询速度优先ArrayList

九、HashMap  (常见程度:★★★★★)

key可以为null

  1. Node{int hash, K key, V value, Node next}
  2. 默认容量16,负载因子0.75f

blog.csdn.net/tingting256…

1、扩容

首先算得key得hashcode值,然后跟数组的长度-1做一次“与”运算(&)。看上去很简单,其实比较有玄机。比如数组的长度是2的4次方,那么hashcode就会和2的4次方-1

为什么扩容后?用同样的hashcode能够找到新位置而不是原来位置,因为获取位置的时候是根据数组长度来进行计算

  1. int size, modCount, threshold, float loadFactor
  2. Node[] table
  3. Set entrySet
  4. put():根据key算hash,根据容量和hash算index,table[index]没有直接添加到数组中,table[index]有,需要判断hashCode 和equal,若index位置同一个key则更新,否则遍历next是否有,有则更新,无则新增链表节点,最后根据thread与size判断是否扩容。注:扩容时容量翻倍,重新算hash复制到新数组

put的时候创建数组

blog.csdn.net/github_3060… 7)get()类似  注:先比较hash,若相等在比较equals

##2、 先判断hashcode相同就进入同个key,如果equal不相同就进入链表,链表太长才会转换tree www.cnblogs.com/wangflower/…

##3、 rehash 多线程下,同时扩容rehash时发生

blog.csdn.net/hll174/arti…

www.cnblogs.com/dongguacai/…

hashCode相同但是值不相同的案例

String str1 = "通话"; String str2 = "重地"; System. out. println(String. format("str1:%d | str2:%d", str1. hashCode(),str2. hashCode())); System. out. println(str1. equals(str2));

##4、 1.7与1.8有什么不同 (常见程度:★★★★★)

推荐阅读 blog.csdn.net/qq_36520235…

主要是头插入尾巴插入,解决两个线程同时并行,并且rehash的时候出现。但是这个不是线程安全的变量,不应该去考虑多线程的问题。 和扩容后新位置,原位置+扩展容量的大小。 注意:11111,2的指数

5、解决冲突方式

blog.csdn.net/weixin_4116… hash算法、位或运算 链表、数组、1.8才有红黑树、 hash值一样,但是equal不相同 blog.csdn.net/u010775025/…

避免rehash优化:2的指数倍,恰好够大

6、HashMap为啥8个变链表?

泊松分布,转换概率 链表查找速度和数组速度在8开始有差异 6个才还原,主要为了减少变换频率

##7、 HashMap的size为啥2的倍数? (常见程度:★★★★★) 因hash是对0xFFFFFFFFF进行或运算,这样会对空间利用高,提高防止重复key

九、LinkHashMap

blog.csdn.net/qq_16691531… afterNodeAccess处理顺序:插入到链表最后方法,hashmap有使用这个方法,但是没有逻辑 removeEldestEntry 处理删除元素条件:为了保持队列中元素总数

  1. Entry{HashMap.Node, Entry before, after}
  2. Entry head, tail
  3. 重写newNode()添加节点时,除像HashMap中添加外,保存before、after信息

lru

lru重写删除方法,到达5就删除,保持5个。

十、hashmap加分项

1、为什么hashmap不用val

数据结构

AVL树就是平衡树 B树是平衡树 B+树是多路平衡树、数据在叶子节点链表上 红黑树是二叉搜索树 hashmap的TreeMap是红黑树

原因

红黑树为了插入时减少旋转放弃平衡,平衡二叉树为了平衡插入时增加旋转次数 blog.csdn.net/theshowlen/… zhidao.baidu.com/question/90…

红黑树不追求"完全平衡", 而AVL是严格平衡树(B树),因此在增加或者删除节点的时候,根据不同情况,旋转的次数比红黑树要多。

红黑树最大路劲高不超过最短路径的2倍。 先考虑查找速度,后再考虑变换,红黑树时个不错的折中选择

红黑树在数据量大就会高瘦,很深,深的时候就是数据量大的时候,查找速度慢。 如果数据量多才会用avl平衡树=B树,B树是多叉的,所以数据量大会横向扩展,深度小,查找速度相对快。部分平衡

所以数据库用mysql用B+树多平衡树。 HashMap少量数据,少量冲突,用红黑。量少,占用空间少,插入速度快,查找速度不平均

2、为什么hashmap中的链表需要转成红黑树

www.cnblogs.com/rgever/p/96… 为了查找速度,但是影响插入速度 红黑树logn 输入8 =3 链表n/2 输入8=4

juejin.im/entry/68449…

3、为什么16默认长度

如果太小,4或者8,扩容比较频繁;如果太大,32或者64甚至太大,又占用内存空间

4、为什么2的倍数

因此,默认初始化大定义为2的幂,就是为了使用更高效的与运算。

5、默认加载因子为什么是0.75

如果是0.5,就是说哈希表填到一半就开始扩容了,这样会导致扩容频繁,并且空间利用率比较低。 如果是1,就是说哈希表完全填满才开始扩容,这样虽然空间利用提高了,但是哈希冲突机会却大了。

6、最大容量为什么是1 << 30

int占四个字节,一个字节占8位,所以是32位整型,也就是说最多32位 System.out.println(1<<31);有一个是 负数,所以最大31位,所以只能左移30

7、树转链表

链表树化阀值是8,那么树还原为链表为什么是6而不是7呢? 这是为了防止链表和树之间频繁的转换

#十一、 TreeMap(常见程度:★)

1)红黑树,即自平衡二叉查找树,时间复杂度O(logn) 2)Entry{K k, V v, Entry parent, left, right, boolean color} 3)Entry root,int size, int modeCount

www.cnblogs.com/williamjie/…

##1、结构 blog.csdn.net/f641385712/…

##2、实现原理

blog.csdn.net/itmyhome199… 比较大小插入 平衡二叉查找树 TreeMap数据内部

blog.csdn.net/Hubery_Jame… 有颜色 左右 循环比较然后插入

TreeMap元素必须实现Comparable接口 www.cnblogs.com/Booker808-j… 比较和重复通过compareTo 看看HashMap源码

##3、与HashMap 效率对比

ytrgmj.iteye.com/blog/147806…

HashMap续写效率高,排序TreeMap高

blog.csdn.net/m0_37797282…

有序用TreeMap

www.cnblogs.com/williamjie/…

#十二、Hashtable

  1. 结构实现与HashMap基本一致
  2. 通过synchronized方法保证线程安全

十三、HashSet

委托给HashMap,其Value是同一个默认对象

十四、LinkedHashSet

LinkedHashSet继承HashSet:不知道如何实现的顺序? LinkList和LinkSet都是双向链表

十五、ConcurrentHashMap (常见程度:★★★★★)

1、 JDK1.7及以前:

blog.csdn.net/qq_33589510…

其中segments是Segment的原生数组,此数组的长度可以在ConcurrentHashMap的构造函数中使用并发度参数指定,其默认值为DEFAULT_CONCURRENCY_LEVEL=16

egments数组的长度ssize是通过concurrencyLevel计算得出的  为了能通过按位与的散列算法来定位segments数组的索引,必须保证segments数组的长度是2的N次方,所以必须计算出一个大于或等于concurrencyLevel的最小的2的N次方值来作为segments数组的长度

a、Segment[] ,HashEntry[] , HashEntry{hash, k, v, next}  b、根据key算hash,根据hash和Segment的大小算位置,每个segment拥有一个自己的HashEntry[]  c、get():不加锁,volatile类型 的HashEntry以后叫node d、put(): 对相应segment加锁

1、判断是否需要对Segment里的HashEntry数组进行扩容

2、Unsafe的getObjectVolatile来读取segments中的元素

3、ConcurrentHashMap使用分段锁Segment来保护不同段的数据,那么在插入和获取元素时,必须先通过散列算法定位到Segment.

4、HashEntry使用UNSAFE.putOrderedObject来设置它的next成员变量,这样既可以提高性能,又能保持并发可见性。同时,entryAt方法和setEntryAt方法也使用了UNSAFE.getObjectVolatile和UNSAFE.putOrderedObject来读取和写入指定索引的HashEntry。

e、size():各HashEntry[] 之和,先不加锁算两遍,若一致则返回,若不一致则加锁重新计算

modCount进行加1,那么在统计size前后比较modCount是否发生变化,从而得知容器的大小是否发生变化.

Segment继承reeteenLock

2. JDK1.8

关键 桶,cas插入,树,size www.importnew.com/23610.html

www.cnblogs.com/aspirant/p/…

segmentfault.com/a/119000001…

数据结构

a、Node{hash, key, value, next}  b、Node[] table

流程

只有在执行第一次put方法时才会调用initTable()初始化Node数组

c、大多数操作类似于HashMap,不同CAS方式设置,根据key算hash,在根据hash和容量算index,对table[index]加锁,从而达到更大的并发量  d、get(): 同HashMap  e、put(): 对table[index]加锁

如果相应位置的Node还未初始化,则通过CAS插入相应的数据;

果相应位置的Node不为空

则对该节点加synchronized锁,如果该节点的hash不小于0,则遍历链表更新节点或插入新节点

如果该节点是TreeBin类型的节点,说明是红黑树结构,则通过putTreeVal方法往红

binCount不为0,说明put操作对数据产生了影响,如果当前链表的个数达到8个,则通过treeifyBin方法转化为红黑树

只对高16位进行hash与计算

hash算法
(h = key.hashCode()) ^ (h >>> 16);
hashcode 异或,右移
复制代码

image.gif

size:

1、两条线程add数据后,cas修改baseCount,只有一条成功,失败的那一条会使CounterCell记录元素的变化。

2、通过CAS设置cellsBusy字段,只有设置成功的线程才能初始化CounterCell数组

3、如果通过CAS设置cellsBusy字段失败的话,则继续尝试通过CAS修改baseCount字段,如果修改baseCount字段成功的话,就退出循环,否则继续循环插入CounterCell对象;

4、所以他统计size的时候,需要统计baseCount和 CounterCell数组中元素的个数

key能为null

blog.csdn.net/qingmengwuh…

为什么没有ConcurrentArrayList

www.sypopo.com/post/qbQl9a… ConcurrentHashMap是因为可以多个位置不同情况插入 但是array只能队列后面插入,所以锁的时候就会会变成vector或者copyOnwriteArray 主要判断方法contain 、 remove 判断方法contain 锁为了少判断contain某个东西 ArrayList有remove吗 有 锁为了判断size 有

十六、 Collections

www.jianshu.com/p/51ce612db…

binarySearch方法

该方法会根据条件的不同调用两个私有的方法来进行查找。如果List支持随机访问,并且小于二分查找的阈值5000,则调用indexedBinarySearch,否则,调用iteratorBinarySearch,借助迭代器来进行访问。

shuffle方法

shuffle翻译过来是重新洗牌的意思,该方法是将list原有数据打乱生成一个新的乱序列表。通俗点来说,旧相当于重新洗牌,打乱原来的顺序。还有一点,shuffle方法再生成乱序列表的时候,所有元素发生交换的可能性是近似相等的。

十七 、Arrays

#十八 、SubList

#十九、 同步容器

www.cnblogs.com/dolphin0520…

在Java中,同步容器主要包括2类:

  1)Vector、Stack、HashTable

  2)Collections类中提供的静态工厂方法创建的类

Collections.synchronizedCollection 同步容器

#二十、 Vetor同步容器 由于Vector中的add方法和get方法都进行了同步,因此,在有多个线程进行访问时,如果多个线程都只是进行读取操作,那么每个时刻就只能有一个线程进行读取,其他线程便只能等待,这些线程必须竞争同一把锁。

因为可以remove,并且不是同步的,所以会导致读取的时候,同时被remove了

  Vector实现了List接口,Vector实际上就是一个数组

www.cnblogs.com/zedosu/p/66…

capacity是Vector的默认容量大小。当由于增加数据导致容量增加时,每次容量会增加一倍。=

复制代码

image.gif

只有remove不是synchronized,所以删除数据不同步。

二十一、并发同步容器

www.cnblogs.com/shijiaqi106…

CopyOnWriteArrayList 与 CopyOnWriteArraySet

就是写的时候做锁操作,读的时候不加锁。

写的时候复制一份集合,先修改,后赋值给旧引用,为了读的时候读到原来的。

CopyOnWriteArrayList

copyonwriteArrayList blog.csdn.net/weixin_3441… 靠reentranLock和 复制数组

使用

www.cnblogs.com/geekdc/p/10…

解决并发写的问题,用reentranLock方式

底层实现添加的原理是先copy出一个容器(可以简称副本),再往新的容器里添加这个新的数据,最后把新的容器的引用地址赋值给了之前那个旧的的容器地址,但是在添加这个数据的期间,其他线程如果要去读取数据,仍然是读取到旧的容器里的数据。

blog.csdn.net/hua63115087…

线程与同步并发容器面试题

mp.weixin.qq.com/s?__biz=MjM…

二十二、迭代器(常见程度:★★)

用集合的删除会有问题,因为modCount(记录了对集合修改的次数)的值不等于expectedModCount(通过迭代器对集合修改的次数),所以在next或者remove的时候checkForComodification检查就报错

foreach

所以在判断hasnext的时候,当前游标和size,集合本身的remove方法只有删除倒数第二个的时候不会报错

www.cnblogs.com/liyong888/p…

www.cnblogs.com/YYCat/p/467…

迭代器Iterator不能remove后又remove,要么只remove一次,要么remove前执行next。

为了更改lastRet

next的时候就已经指向下一个位置

blog.csdn.net/qq_34115899…

二十二、HashCode(常见程度:★★)

代表内存空间地址,原本默认根据内存地址生成 也就是说对于两个对象,如果调用equals方法得到的结果为true,则两个对象的hashcode值必定相等; 如果equals方法得到的结果为false,则两个对象的hashcode值不一定不同; 如果两个对象的hashcode值不等,则equals方法得到的结果必定为false; 如果两个对象的hashcode值相等,则equals方法得到的结果未知。

blog.csdn.net/a745233700/…

为什么重写equal就要重写hashCode

www.cnblogs.com/skywang1234…

www.cnblogs.com/ouym/p/8963… hash值相同,但是比较对象引用地址不同 作为hash运算的中间参数。这点很关键,这是为了遵守:2个对象equals,那么 hashCode一定相同规则。 为了两个方法中的参数要一致,为了保持比较的内容不是地址。

为什么 HashMap 常用 String 对象作 key blog.csdn.net/codejas/art… 其实主要就是String的hash 和equal比较好算,如果用对象就要重写两个方法

二十三、automic long与Longadder

blog.csdn.net/f641385712/…

累加循环试错-》各自增加的数量sum

weakHashMap

www.jianshu.com/p/6598f065e… blog.csdn.net/qiuhao9527/… www.jianshu.com/p/6904d6325… www.cnblogs.com/micrari/p/8… 实验 blog.csdn.net/zjq_1314520… key没有被引用时,gc时会有另外一个线程就放入队列中 下次操作时,就会删除。


欢迎关注公众号,文章更快一步

我的公众号 :地藏思维

掘金:地藏Kelvin

简书:地藏Kelvin

我的Gitee: 地藏Kelvin gitee.com/kelvin-cai