小知识,大挑战!本文正在参与「程序员必备小知识」创作活动
ConcurrentHashMap是HashMap的线程安全的版本,其内部使用的是数组+链表+红黑树的结构来存储元素。
带着问题来看源码,可能效果更好,所以开篇我先放几个问题,让大家带着思考去看源码
01
开篇问题
-
ConcurrentHashMap与HashMap的数据结构是否一样
-
ConcurrentHashMap如何解决并发安全问题
-
ConcurrentHashMap使用了哪些锁
-
ConcurrentHashMap扩容是如何进行的
-
ConcurrentHashMap是否是强一致性的
-
ConcurrentHashMap不能解决哪些问题
02
ConCurrentHashMap整体结构
其继承结构和HashMap差不多,这里就不多介绍。先来看下其存储元素的结构图
图的上半部分是ConcurrentHashmap在jdk1.8的存储元素的结构,下半部分是1.8之前存储元素的逻辑,相互对比,其中一个最主要的差异就是1.8之前是锁住一段数组的桶位去保证线程安全,而在1.8开始是锁住每一个桶位,这样效率就高很多,同时1.8开始运用到了红黑树来存储元素,效率有更进一步的提升。
03
属性分析
/* ---------------- Constants -------------- */
// 散列表数组最大限制
private static final int MAXIMUM_CAPACITY = 1 << 30;
/**
* 散列表默认初始值
*/
private static final int DEFAULT_CAPACITY = 16;
/**
* 最大数组长度
*/
static final int MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8;
/**
* 并发级别,jdk1.7遗留下来的,1.8只有在初始化的时候用了一用
* 不代表并发级别
*/
private static final int DEFAULT_CONCURRENCY_LEVEL = 16;
/**
* 负载因子,在jdk1.8中,ConcurrentHashMap是固定值,不能改变
*/
private static final float LOAD_FACTOR = 0.75f;
/**
* 转为红黑树的阈值,链表长度达到8,有可能发生树化操作
*/
static final int TREEIFY_THRESHOLD = 8;
/**
* 红黑树转化为链表的阈值
*/
static final int UNTREEIFY_THRESHOLD = 6;
/**
* 结合TREEIFY_THRESHOLD控制桶位是否树化,只有当table数组长度达到64,
* 且某个桶位中链表长度达到8才会发生树化
*/
static final int MIN_TREEIFY_CAPACITY = 64;
/**
* 当进行扩容时,可能会有多个线程参与,用来给每个线程分配区间
* 线程迁移数据最小步长,控制线程迁移任务最小区间的一个值
*/
private static final int MIN_TRANSFER_STRIDE = 16;
/**
* 扩容相关,计算扩容时生成的一个标识戳
*/
private static int RESIZE_STAMP_BITS = 16;
/**
* 65535 代表并发扩容最多线程数
*/
private static final int MAX_RESIZERS = (1 << (32 - RESIZE_STAMP_BITS)) - 1;
/**
* 扩容相关
*/
private static final int RESIZE_STAMP_SHIFT = 32 - RESIZE_STAMP_BITS;
/*
* Encodings for Node hash fields. See above for explanation.
*/
// 当node节点的hash值为-1 时,表示当前节点正在扩容转移数据
static final int MOVED = -1; // hash for forwarding nodes
// 当node节点的hash值为-2 时,表示当前节点已经树化,且当前节点
// 为TreeBin对象,TreeBin对象代理换做红黑树
static final int TREEBIN = -2; // hash for roots of trees
static final int RESERVED = -3; // hash for transient reservations
// 0x7fffffff ->0111 1111 1111 1111 1111 1111 1111 1111 可以将一个
// 负数通过位与运算得到正数,但不是取绝对值
static final int HASH_BITS = 0x7fffffff; // usable bits of normal node hash
/**
* 当前系统CPU数量
*/
static final int NCPU = Runtime.getRuntime().availableProcessors();
/**
* 散列表,长度一定是2的次方
*/
transient volatile Node<K, V>[] table;
/**
* 扩容过程中,会将扩容中的table 赋值给nextTable 保持引用
* 扩容结束后,这里会被设置为null
*/
private transient volatile Node<K, V>[] nextTable;
/**
* LongAddr中的baseCount未发生竞争时或者当前LongAdder处于加锁状态时
* 增量累计到baseCount中
*/
private transient volatile long baseCount;
/**
* sizeCtl <0
* 1. -1表示当前table数组正在初始化(有线程再创建table数组),当前线程需要自旋等待
* 2. 表示当前table正在扩容 高16位表示: 扩容的标识戳 低16位表示(1+nThread) 当前参与并发扩容的线程数量
* sizeCtl=0 ,表示创建table数组时,使用DEFAULT CAPACITY为大小
* sizeCtl>0
* 1. 如果table未初始化,表示初始化大小
* 2. 如果table已经初始化,表示下次扩容时的触发条件(阈值)
*/
private transient volatile int sizeCtl;
/**
* 扩容过程中,记录当前进度,所有线程都需要从transFerIndex中分配区间任务
* 去执行自己的任务
*/
private transient volatile int transferIndex;
/**
* LongAdder中的cellsBusy 0表示当前LongAdder对象无锁状态
* 1表示当前LongAdder对象加锁状态
*/
private transient volatile int cellsBusy;
/**
* LongAdder中的cells数组,当baseCount发生竞争后,会创建cells数组
* 线程会通过计算hash值 取到自己的cell,将增量累计到指定的cell中
* 总数= sum(cells)+basecount
*/
private transient volatile CounterCell[] counterCells;
/**
* concurrencyLevel代表并发级别,是为了兼容1.7
*/
public ConcurrentHashMap(int initialCapacity,
float loadFactor, int concurrencyLevel) {
if (!(loadFactor > 0.0f) || initialCapacity < 0 || concurrencyLevel <= 0)
throw new IllegalArgumentException();
//如果传的初始化容量小于并发级别就将并发级别赋值给初始化容量
if (initialCapacity < concurrencyLevel) // Use at least as many bins
initialCapacity = concurrencyLevel; // as estimated threads
/**计算size,假设传进来的初始化容量是16,负载因子是0.75
* (long) (1.0 + (long) initialCapacity / loadFactor=23
* cap : 32
* */
long size = (long) (1.0 + (long) initialCapacity / loadFactor);
int cap = (size >= (long) MAXIMUM_CAPACITY) ?
MAXIMUM_CAPACITY : tableSizeFor((int) size);
//当 sizeCtl大于0,且 当前table未初始化,sizeCtl表示初始化容量
this.sizeCtl = cap;
}
这些属性中有个要重点强调下,sizeCtl,这个属性贯穿整个ConcurrentHashMap,也很重要,其不同的值代表不同的含义。
1 sizeCtl>0
如果table未初始化,表示初始化大小,如果已经初始化代表下次扩容的阈值
2 sizeCtl=0
表示创建table数组时,使用DEFAULT CAPACITY为大小
3 sizeCtl<0
等于-1,表示当前数组正在初始化
不等于-1, 表示当前table正在扩容 高16位表示: 扩容的标识戳 低16位表示(1+nThread) 当前参与并发扩容的线程数量。
04
put方法
public V put(K key, V value) {
// onlyIfAbent是false,代表如果key相等就做替换操作
//false:key相等就插入不进去
return putVal(key, value, false);
}
/**
* Implementation for put and putIfAbsent
*/
final V putVal(K key, V value, boolean onlyIfAbsent) {
if (key == null || value == null) throw new NullPointerException();
// 通过spread方法,可以让高位参与寻址运算
int hash = spread(key.hashCode());
// binCount标识当前k-v 封装成node后插入到指定桶位后
//在桶位中所属链表的下标位置
//0 表示当前桶位为NULL,node可以直接放
//2 表示当前桶位可能是红黑树
int binCount = 0;
for (Node<K, V>[] tab = table; ; ) {
// f:当前桶位头结点
// n: 表示散列表数组长度
// i: 表示key通过寻址计算后,得到的桶位下标
// fh: 表示桶位头结点的hash值
Node<K, V> f;
int n, i, fh;
// CASE1: 成立, 表示当前map中的table尚未初始化。。
if (tab == null || (n = tab.length) == 0)
tab = initTable();
// CASE2: 成立,i表示key使用路由寻址算法得到key对应table数组的下标位置
// tableAt 获取指定桶位的头结点
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
// cas方式插入数据,四个参数依次是当前数组,要插入位置数组下标,期望值,新值
if (casTabAt(tab, i, null,
new Node<K, V>(hash, key, value, null)))
break;
//CASE3:成立,表示当前桶位的头结点为FWD节点,表示目前map
// 正在处于扩容
} else if ((fh = f.hash) == MOVED)
// 看到fwd结点后,当前节点有义务帮助当前map对象完成迁移数据的工作
tab = helpTransfer(tab, f);
//CASE4 :成立,当前桶位可能是链表,也可能是红黑树代理结点TreeBin
else {
//当前key存在时,会将旧值赋值给oldVal,返回给put方法调用处
V oldVal = null;
// 使用sync 加锁 “头节点” ,理论上是“头节点”
synchronized (f) {
// 为什么又要对比一下,看看当前桶位的头节点,是否为之前获取的头节点?
// 是为了避免其他线程将该桶位的头节点修改掉,导致当前线程从sync加锁就有问题了,之后所有操作就不用做了
//条件成立,说明加锁对象没有问题,可以进来操作了
if (tabAt(tab, i) == f) {
// 条件成立,说明当前桶位就是普通链表桶位
if (fh >= 0) {
// 1. 当前插入key与链表当中所有元素的key都不一致时
//当前的插入操作是追加到链表的末尾,binCount表示链表长度
// 2. 当前插入key与链表当中的某个元素的key一致时,当前插入操作可能就是替换了
// binCount表示冲突位置(binCount-1)
binCount = 1;
// 迭代循环当前桶位的链表,e是每次循环处理节点
for (Node<K, V> e = f; ; ++binCount) {
// 当前循环节点key
K ek;
//条件一:e.hash==hash成立,表示循环的当前元素的hash值与插入节点的hash值一致,需要进一步判断
// 条件二:成立,说明循环的当前节点与插入节点的key一致,发生冲突了
if (e.hash == hash &&
((ek = e.key) == key ||
(ek != null && key.equals(ek)))) {
//将当前循环的元素的值赋值给oldVal
oldVal = e.val;
if (!onlyIfAbsent)
e.val = value;
break;
}
// 当前元素与插入元素的key不一致时,会走下面程序
// 1. 更新循环处理节点为当前节点的下一个结点
// 2. 判断下一个结点是否为null,如果是null,说明当前节点已经是队尾,插入数据需要追加到队尾节点的后面
Node<K, V> pred = e;
if ((e = e.next) == null) {
pred.next = new Node<K, V>(hash, key,
value, null);
break;
}
}
}
//前置条件:该桶位一定不是链表
// 条件成立,表示当前桶位是红黑树代理结点TreeBin
else if (f instanceof TreeBin) {
// p 表示红黑树中如果与你插入节点key有冲突节点,则putTreeVal方法会返回冲突节点的引用
Node<K, V> p;
// 强制设置binCount=2,因为binCount<=1时还有其他含义,
binCount = 2;
// 成立:说明当前插入节点的key与红黑树中的某个节点的key一致,冲突了
if ((p = ((TreeBin<K, V>) f).putTreeVal(hash, key,
value)) != null) {
// 将冲突节点的值赋值给oldVal
oldVal = p.val;
if (!onlyIfAbsent)
p.val = value;
}
}
}
}
// 说明当前桶位不为null,可能是红黑树,也可能是链表
if (binCount != 0) {
//如果binCount>=8,表示处理的桶位一定是链表
if (binCount >= TREEIFY_THRESHOLD)
//调用转化链表为红黑树的方法
treeifyBin(tab, i);
// 说明当前线程插入的key域原有key冲突,需要返回原有的value
if (oldVal != null)
return oldVal;
break;
}
}
}
// 1.统计当前 table 一共有多少数据
// 2.判断是否达到扩容阀值标准,触发扩容
addCount(1L, binCount);
return null;
}
整体流程跟HashMap比较类似,大致分为以下几步
1 如果数组未初始化,进行初始化
首先通过spread方法进行h对key的hash值进行扰动计算,这个和hashMap基本一致
tab为空进行初始化,调用initTable方法进行初始化,这个方法在后面会单独讲解
2 待插入元素所在桶位为空
如果待插入位置为空,就通过cas机制尝试将元素直接插入到桶位头结点
3 判断map是否正在进行扩容
如果当前的桶位头结点hash值为-1,则代表当前桶位是FWD节点,当前线程需要帮忙一起迁移元素,调用helpTransfer方法
4 如果待插入元素所在桶位不为空,并且没有迁移元素,则锁住这个桶位头结点
通过synchronized 来加锁,然后完成元素插入的工作
5 如果当前桶中元素是以链表方式进行存储,则在链表中寻找该元素或者插入元素
如何判断桶位是链表呢,就是判断桶位头结点hash值是否大于等于0,是的话就是链表。然后就是常规的for循环遍历寻找元素,没有的话就在链表尾部添加元素。
6 如果当前桶中元素是以红黑树方式存储,则在红黑树中寻找该元素或者插入元素
7 插入操作完成后判断链表长度是否超过8,超过的话就调用treeifyBin方法进行树化操作
8 最后对整个map元素个数加1,并检查是否需要扩容
调用addCount方法来完成对元素加1及扩容操作,这里就用到了我上篇文章写得LongAdder类,计数就是通过这个类完成,计数完成后然后判断是否需要扩容;
整个put方法主要用到了cas和synchronized这两种锁,为什么要用synchronized而不是ReentrantLock呢,在jdk1.6之前可能synchronized效率不是很高,但现在已经做了很多的优化,在某些情况下其实不比ReentrantLock差,这个synchronized会在后期文章单独讲解。
05
initTable方法
private final Node<K, V>[] initTable() {
// tab:引用map.table
Node<K, V>[] tab;
// sizeCtl的临时值
int sc;
// 自旋条件:map.tale 尚未初始化
while ((tab = table) == null || tab.length == 0) {
if ((sc = sizeCtl) < 0)
// 大概率是-1,表示其他线程正在进行创建table的过程,当前线程没有竞争到初始化table的锁
Thread.yield(); // lost initialization race; just spin
// 1. sizeCtl=0,表示创建table数组时,使用DEFAULT_CAPCITY大小
// 2.如果table未初始化,表示初始化大小
// 3. 如果table已经初始化,表示下次扩容时的触发条件
else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
try {
// 这里为什么又要判断呢?防止其他线程已经初始化完毕看,然后当前线程
// 再次初始化,导致丢失数据
// 条件成立。说明其他线程都没有进入过这个if块,当前线程就具备
// 初始化table权利了
if ((tab = table) == null || tab.length == 0) {
// sc大于0,创建table使用sc做为指定大小,否则使用16默认值
int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
@SuppressWarnings("unchecked")
Node<K, V>[] nt = (Node<K, V>[]) new Node<?, ?>[n];
table = tab = nt;
// n>>>2=0.25n
//sc=0.75n,表示再次扩容的触发条件
sc = n - (n >>> 2);
}
} finally {
//1. 如果当前线程是第一次创建map.table的线程的话,sc表示的是下一次扩容的阈值
// 2. 表示当前线程并不是第一次创建map.table的线程,当前线程进入到else if块时,
// 将sizeCtl设置为-1,那么这时需要将其修改为进入时的值
sizeCtl = sc;
}
break;
}
}
return tab;
}
1 进行自旋,判断sizeCtl是否小于0
sie=zeCtl如果小于0,那么就有两种可能,要么是数组正在初始化,要么是数组正在扩容,在这里,大概率是-1,就是数组在初始化,如果数组在初始化,表示其他线程正在创建table,当前线程没有竞争到初始化table的锁
2 通过cas操作获取初始化table的锁
初始化操作,table的默认容量就是16,初始化完成,sizeCtl就赋值为0.75n,代表扩容的阈值。
06
addCount方法
addCount方法我们在put方法代码最后可以看到有调用,主要就是完成两个操作,一个是计数,一个是扩容,先来看下源码。
private final void addCount(long x, int check) {
//as 表示 LongAdder.cells
//b 表示LongAdder.base
//s 表示当前map.table中元素的数量
CounterCell[] as;
long b, s;
// cells数组不为空或者线程写入base时竞争失败,
if ((as = counterCells) != null ||
!U.compareAndSwapLong(this, BASECOUNT, b = baseCount, s = b + x)) {
CounterCell a;
long v;
int m;
boolean uncontended = true;
// as == null || (m = as.length - 1) < 0 cells数组还没有初始化,需要进行初始化
// = as[ThreadLocalRandom.getProbe() & m]) == null:当前线程命中的cell为空,需要new一个Cell
//uncontended =U.compareAndSwapLong(a, CELLVALUE, v = a.value, v + x:
// 表示当前线程使用cas方式更新当前命中的cell失败,需要重试或者扩容cells数组
if (as == null || (m = as.length - 1) < 0 ||
(a = as[ThreadLocalRandom.getProbe() & m]) == null ||
!(uncontended =
U.compareAndSwapLong(a, CELLVALUE, v = a.value, v + x))) {
fullAddCount(x, uncontended);
return;
}
if (check <= 1)
return;
//获取当前散列表元素个数,这是一个期望值
s = sumCount();
}
// 表示put操作调用的addCount方法
if (check >= 0) {
//tab 表示map.table
//nt 表示map.nextTable
//n 表示map.table数组的长度
//sc 表示sizeCtl的临时值
Node<K, V>[] tab, nt;
int n, sc;
/**
* sizeCtl < 0
* 1. -1 表示当前table正在初始化(有线程在创建table数组),当前线程需要自旋等待..
* 2.表示当前table数组正在进行扩容 ,高16位表示:扩容的标识戳 低16位表示:(1 + nThread) 当前参与并发扩容的线程数量
*
* sizeCtl = 0,表示创建table数组时 使用DEFAULT_CAPACITY为大小
*
* sizeCtl > 0
*
* 1. 如果table未初始化,表示初始化大小
* 2. 如果table已经初始化,表示下次扩容时的 触发条件(阈值)
*/
//自旋
//条件一:s >= (long)(sc = sizeCtl)
// true-> 1.当前sizeCtl为一个负数 表示正在扩容中..
// 2.当前sizeCtl是一个正数,表示扩容阈值
// false-> 表示当前table尚未达到扩容条件
//条件二:(tab = table) != null
// 恒成立 true
//条件三:(n = tab.length) < MAXIMUM_CAPACITY
// true->当前table长度小于最大值限制,则可以进行扩容。
while (s >= (long) (sc = sizeCtl) && (tab = table) != null &&
(n = tab.length) < MAXIMUM_CAPACITY) {
// 获取扩容批次唯一标识符
int rs = resizeStamp(n);
//表示当前数组正在扩容中
if (sc < 0) {
//条件一:(sc >>> RESIZE_STAMP_SHIFT) != rs
// true->说明当前线程获取到的扩容唯一标识戳 非 本批次扩容
// false->说明当前线程获取到的扩容唯一标识戳 是 本批次扩容
//条件二: JDK1.8 中有bug jira已经提出来了 其实想表达的是 = sc == (rs << 16 ) + 1
// true-> 表示扩容完毕,当前线程不需要再参与进来了
// false->扩容还在进行中,当前线程可以参与
//条件三:JDK1.8 中有bug jira已经提出来了 其实想表达的是 = sc == (rs<<16) + MAX_RESIZERS
// true-> 表示当前参与并发扩容的线程达到了最大值 65535 - 1
// false->表示当前线程可以参与进来
//条件四:(nt = nextTable) == null
// true->表示本次扩容结束
// false->扩容正在进行中
if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 ||
sc == rs + MAX_RESIZERS || (nt = nextTable) == null ||
transferIndex <= 0)
break;
//前置条件:当前table正在执行扩容中.. 当前线程有机会参与进扩容。
//条件成立:说明当前线程成功参与到扩容任务中,并且将sc低16位值加1,表示多了一个线程参与工作
//条件失败:1.当前有很多线程都在此处尝试修改sizeCtl,有其它一个线程修改成功了,导致你的sc期望值与内存中的值不一致 修改失败
// 2.transfer 任务内部的线程也修改了sizeCtl。
if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1))
//协助扩容,持有nextTable引用
transfer(tab, nt);
}
// 条件成立,说明当前线程是触发扩容的第一个线程,在transfer方法需要做一些扩容准备工作
else if (U.compareAndSwapInt(this, SIZECTL, sc,
(rs << RESIZE_STAMP_SHIFT) + 2))
//触发扩容条件的线程 不持有nextTable
transfer(tab, null);
s = sumCount();
}
}
}
1 计算元素个数
元素个数存储方式基本和LongAdder一致,看过我上面文章的这里应该都会清楚,这里我就不再进行讲解,不知道计数原理的看下我上篇文章就清楚了。
2 协助扩容
如果当前sizeCtl小于0或者当前容量大于sizeCtl,说明需要扩容,如果sizeCtl小于0,就代表当前正在处于扩容中,那么当前线程就会参与扩容,通过cas操作获取锁,并且sizeCtl会加1,这个加1就是将sizeCtl低16位加1,表示多了一个线程参与扩容,此时sizeCtl高位存储扩容邮戳(resizeStamp),低位存储扩容线程数加1(1+nThreads)
3 触发扩容
如果当前满足扩容条件,然后还没有进行扩容此时当前线程就会触发扩容操作
07
helpTransfer方法
/** * Helps transfer if a resize is in progress. */final Node<K, V>[] helpTransfer(Node<K, V>[] tab, Node<K, V> f) { //nextTab 引用的是 fwd.nextTable == map.nextTable //sc 保存map.sizeCtl Node<K, V>[] nextTab; int sc; //条件一:tab != null 恒成立 true //条件二:(f instanceof ForwardingNode) 恒成立 true //条件三:((ForwardingNode<K,V>)f).nextTable) != null 恒成立 true if (tab != null && (f instanceof ForwardingNode) && (nextTab = ((ForwardingNode<K, V>) f).nextTable) != null) { //拿当前标的长度 获取 扩容标识戳 假设 16 -> 32 扩容:1000 0000 0001 1011 int rs = resizeStamp(tab.length); //条件一:nextTab == nextTable //成立:表示当前扩容正在进行中 //不成立:1.nextTable被设置为Null 了,扩容完毕后,会被设为Null // 2.再次出发扩容了...咱们拿到的nextTab 也已经过期了... //条件二:table == tab //成立:说明 扩容正在进行中,还未完成 //不成立:说明扩容已经结束了,扩容结束之后,最后退出的线程 会设置 nextTable 为 table //条件三:(sc = sizeCtl) < 0 //成立:说明扩容正在进行中 //不成立:说明sizeCtl当前是一个大于0的数,此时代表下次扩容的阈值,当前扩容已经结束。 while (nextTab == nextTable && table == tab && (sc = sizeCtl) < 0) { //条件一:(sc >>> RESIZE_STAMP_SHIFT) != rs // true->说明当前线程获取到的扩容唯一标识戳 非 本批次扩容 // false->说明当前线程获取到的扩容唯一标识戳 是 本批次扩容 //条件二: JDK1.8 中有bug jira已经提出来了 其实想表达的是 = sc == (rs << 16 ) + 1 // true-> 表示扩容完毕,当前线程不需要再参与进来了 // false->扩容还在进行中,当前线程可以参与 //条件三:JDK1.8 中有bug jira已经提出来了 其实想表达的是 = sc == (rs<<16) + MAX_RESIZERS // true-> 表示当前参与并发扩容的线程达到了最大值 65535 - 1 // false->表示当前线程可以参与进来 //条件四:transferIndex <= 0 // true->说明map对象全局范围内的任务已经分配完了,当前线程进去也没活干.. // false->还有任务可以分配。 if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 || sc == rs + MAX_RESIZERS || transferIndex <= 0) break; if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1)) { transfer(tab, nextTab); break; } } return nextTab; } return table;}
只有当桶数组不为空,并且当前桶位头结点是ForwardingNode类型,并且nextTable不为空才有可能参与扩容。这个nextTable就是新桶数组。
有话说
ConcurrentHashMap挺复杂的,会分为两篇文章来讲,这里说下我学习源码的方法,学习源码一定不能只看书或者视频,一定要自己亲自去看源码,自己看源码看不懂的时候结合博客,视频去理解,这样才能理解的透彻,如果只是单纯看我文章,看文章的同时没有去结合代码,这样一篇文章下来其实学不到太多东西,也很容易遗忘。学习苦涩的,坚持就是胜利,现在看不到成果,日积月累必定会有你想象不到的回报。