摘要

对于Java开发人员来说,能够熟练地掌握java的集合类是必须的,本节想要跟大家共同学习一下JDK1.8中HashMap的底层实现与源码分析。HashMap是开发中使用频率最高的用于映射(键值对)处理的数据结构,而在JDK1.8中HashMap采用位桶数组+链表+红黑树实现的,现在我们深入探究一下HashMap的结构实现

一、HashMap简介

1、特点

HashMap根据键的hashcode值存储数据,大多数情况可以直接定位到它的值,因而具有很快的访问速度,但遍历顺序是不确定的

想要使得遍历的顺序就是插入的顺序,可以使用LinkedHashMap,LinkedHashMap是HashMap的一个子类,保存了记录的插入顺序,在用Iterator遍历LinkedHashMap时,先得到的记录肯定是先插入的,也可以在构造时带参数,按照访问次序排序。

public classHashMapTest {public static voidmain(String[] args) {

HashMap hashMap= newHashMap();

hashMap.put(2,"bbb");

hashMap.put(3,"ccc");

hashMap.put(1,"aaa");

System.out.println("HashMap的遍历顺序:"+hashMap);

LinkedHashMap linkedHashMap= newLinkedHashMap();

linkedHashMap.put(2,"bbb");

linkedHashMap.put(3,"ccc");

linkedHashMap.put(1,"aaa");

System.out.println("LinkedHashMap的遍历顺序:"+linkedHashMap);

}

}

Console输出

HashMap的遍历顺序:{1=aaa, 2=bbb, 3=ccc}

LinkedHashMap的遍历顺序:{2=bbb, 3=ccc, 1=aaa}

HashMap最多只允许一条记录的键为null,允许多条记录的值为null

HashMap非线程安全,如果需要满足线程安全,可以一个Collections的synchronizedMap方法使HashMap具有线程安全能力,或者使用ConcurrentHashMap。

HashTable容器在竞争激烈的并发环境下表现出效率低下的原因,是因为所有访问HashTable的线程都必须竞争同一把锁,那假如容器里有多把锁,每一把锁用于锁容器其中一部分数据,那么当多线程访问容器里不同数据段的数据时,线程间就不会存在锁竞争,从而可以有效的提高并发访问效率,这就是ConcurrentHashMap所使用的锁分段技术,首先将数据分成一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据的时候,其他段的数据也能被其他线程访问。

顺便说一下Hashtable,Hashtable是遗留类,很多映射的常用功能与HashMap类似,不同的是它承自Dictionary类,并且是线程安全的,任一时间只有一个线程能写Hashtable,并发性不如ConcurrentHashMap,因为ConcurrentHashMap引入了分段锁。Hashtable不建议在新代码中使用,不需要线程安全的场合可以用HashMap替换,需要线程安全的场合可以用ConcurrentHashMap替换。

2、结构

从实现结构上看,HashMap是数组+链表+红黑树(JDK1.8增加了红黑树部分)实现的,如上图所示,当链表长度超过阙值(8)时,将链表转化成红黑树,这样大大减少了查找时间

实现原理

首先每一个元素都是链表的数组,当添加一个元素(key-value)时, 就首先计算元素key的hash值,以此确定插入数组的位置,但是可能存在同一hash值的元素已经被放到数组的同一位置,这是就添加到同一hash值的元素的后面,他们在数组的同一位置形成链表,同一链表上的Hash值是相同的,所以说数组存放的是链表,而当链表长度太长时,链表就转换为红黑树这样大大提高了查找效率。

当链表数组的容量超过初始容量的0.75时,再散列将链表数组扩大2倍,把原链表数组的元素搬移到新的数组中

二、HashMap源码分析

1、核心成员变量

transient Node[] table;        //HashMap的哈希桶数组,非常重要的存储结构,用于存放表示键值对数据的Node元素。

transient Set> entrySet; //HashMap将数据转换成set的另一种存储形式,这个变量主要用于迭代功能。

transient int size;             //HashMap中实际存在的Node数量,注意这个数量不等于table的长度,甚至可能大于它,因为在table的每个节点上是一个链表(或RBT)结构,可能不止有一个Node元素存在。

transient int modCount;

//HashMap的数据被修改的次数,这个变量用于迭代过程中的Fail-Fast机制,其存在的意义在于保证发生了线程安全问题时,能及时的发现(操作前备份的count和当前modCount不相等)并抛出异常终止操作。

int threshold;                //HashMap的扩容阈值,在HashMap中存储的Node键值对超过这个数量时,自动扩容容量为原来的二倍。

final float loadFactor;           //HashMap的负载因子,可计算出当前table长度下的扩容阈值:threshold = loadFactor * table.length。

2、HashMap常量

//默认的初始容量为16,必须是2的幂次

static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;//最大容量即2的30次方

static final int MAXIMUM_CAPACITY = 1 << 30;//默认加载因子

static final float DEFAULT_LOAD_FACTOR = 0.75f;//当put一个元素时,其链表长度达到8时将链表转换为红黑树

static final int TREEIFY_THRESHOLD = 8;//链表长度小于6时,解散红黑树

static final int UNTREEIFY_THRESHOLD = 6;//默认的最小的扩容量64,为避免重新扩容冲突,至少为4 * TREEIFY_THRESHOLD=32,即默认初始容量的2倍

static final int MIN_TREEIFY_CAPACITY = 64;

3、构造函数

//构造函数1 指定初始容量以及负载因子

public HashMap(int initialCapacity, floatloadFactor) {if (initialCapacity < 0)throw new IllegalArgumentException("Illegal initial capacity: " +initialCapacity);if (initialCapacity >MAXIMUM_CAPACITY)

initialCapacity=MAXIMUM_CAPACITY;if (loadFactor <= 0 ||Float.isNaN(loadFactor))throw new IllegalArgumentException("Illegal load factor: " +loadFactor);this.loadFactor =loadFactor;this.threshold =tableSizeFor(initialCapacity);

}//构造函数2 指定初始容量

public HashMap(intinitialCapacity) {this(initialCapacity, DEFAULT_LOAD_FACTOR);

}//构造函数3 什么都不指定

publicHashMap() {this.loadFactor = DEFAULT_LOAD_FACTOR; //all other fields defaulted

}//构造函数4 指定一个map用来初始化

public HashMap(Map extends K, ? extends V>m) {this.loadFactor =DEFAULT_LOAD_FACTOR;

putMapEntries(m,false);

}

4、设计到的数据结构

(1)数组元素Node实现了Entry接口

//Node是单向链表,它实现了Map.Entry接口

static class Node implements Map.Entry{final inthash;finalK key;

V value;

Nodenext;//构造函数Hash值 键 值 下一个节点

Node(int hash, K key, V value, Nodenext) {this.hash =hash;this.key =key;this.value =value;this.next =next;

}public final K getKey() { returnkey; }public final V getValue() { returnvalue; }public final String toString() { return key + = +value; }public final inthashCode() {return Objects.hashCode(key) ^Objects.hashCode(value);

}public finalV setValue(V newValue) {

V oldValue=value;

value=newValue;returnoldValue;

}//判断两个node是否相等,若key和value都相等,返回true。可以与自身比较为true

public final booleanequals(Object o) {if (o == this)return true;if (o instanceofMap.Entry) {

Map.Entry e = (Map.Entry)o;if (Objects.equals(key, e.getKey()) &&Objects.equals(value, e.getValue()))return true;

}return false;

}

从这个Node内部类可知,它实现了Map.Entry接口。内部定义的变量 有hash值、key/value键值对和实现链表和红黑树所需要的指针索引

(2)红黑树

//红黑树

static final class TreeNode extends LinkedHashMap.Entry{

TreeNode parent; //父节点

TreeNode left; //左子树

TreeNode right;//右子树

TreeNode prev; //needed to unlink next upon deletion

boolean red; //颜色属性

TreeNode(int hash, K key, V val, Nodenext) {super(hash, key, val, next);

}//返回当前节点的根节点

final TreeNoderoot() {for (TreeNode r = this, p;;) {if ((p = r.parent) == null)returnr;

r=p;

}

}

5、HashMap的常用方法(put、get)

(1)put方法

publicV put(K key, V value) {return putVal(hash(key), key, value, false, true);

}final V putVal(int hash, K key, V value, booleanonlyIfAbsent,booleanevict) {

Node[] tab; Node p; intn, i;

//判断键值对数组table[i]是否为空或为null,否则执行resize()进行扩容if ((tab = table) == null || (n = tab.length) == 0)

n= (tab =resize()).length;

//根据键值key计算hash值得到插入的数组索引i,如果table[i]==null,直接新建节点添加,否则如果table[i]不为null,看下面注释if ((p = tab[i = (n - 1) & hash]) == null)

tab[i]= newNode(hash, key, value, null);else{

Nodee; K k;

//如果table[i]不为null,则判断table[i]的首个元素是否和key一样,如果相同直接覆盖value,否则如果不一样,则看下面注释if (p.hash == hash &&((k= p.key) == key || (key != null &&key.equals(k))))

e=p;

//判断table[i]是否为treeNode,即table[i]是否是红黑树,如果是红黑树,则直接在树中插入键值对,否则,看下面注释else if (p instanceofTreeNode)

e= ((TreeNode)p).putTreeVal(this, tab, hash, key, value);else{

//遍历table[i],判断链表长度是否大于8,大于8的话把链表转换为红黑树,在红黑树中执行插入操作,否则进行链表的插入操作,遍历过程中若发现key已经存在直接覆盖value即可。for (int binCount = 0; ; ++binCount) {if ((e = p.next) == null) {

p.next= newNode(hash, key, value, null);if (binCount >= TREEIFY_THRESHOLD - 1) //-1 for 1st

treeifyBin(tab, hash);break;

}if (e.hash == hash &&((k= e.key) == key || (key != null &&key.equals(k))))break;

p=e;

}

}

//key已经存在,将新value替换旧value值具体操作if (e != null) { //existing mapping for key

V oldValue =e.value;if (!onlyIfAbsent || oldValue == null)

e.value=value;

afterNodeAccess(e);returnoldValue;

}

}++modCount;

//插入成功之后,判断实际存在的键值对数量size是否超过了最大容量threshold,如果超过,进行扩容if (++size >threshold)

resize();

afterNodeInsertion(evict);return null;

}

为了更好的理解hashmap如何进行过put操作,可以看下图

重点理解(求元素在node数组的下标)

主要分为三个阶段:计算hashcode、高位运算与取模运算

i = (n - 1) & hash

·  首先上面的hash是由put方法中的hash(key)产生的,源码为:

static final inthash(Object key) {inth;return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);

}

这里通过key.hashCode()计算出key的哈希值,然后将哈希值h右移16位,再与原来的h做异或^运算——这一步是高位运算。设想一下,如果没有高位运算,那么hash值将是一个int型的32位数。而从2的-31次幂到2的31次幂之间,有将近几十亿的空间,如果我们的HashMap的table有这么长,内存早就爆了。所以这个散列值不能直接用来最终的取模运算,而需要先加入高位运算,将高16位和低16位的信息"融合"到一起,也称为"扰动函数"。这样才能保证hash值所有位的数值特征都保存下来而没有遗漏,从而使映射结果尽可能的松散。最后,根据 n-1 做与操作的取模运算。这里也能看出为什么HashMap要限制table的长度为2的n次幂,因为这样,n-1可以保证二进制展示形式是(以16为例)0000 0000 0000 0000 0000 0000 0000 1111。在做"与"操作时,就等同于截取hash二进制值得后四位数据作为下标。这里也可以看出"扰动函数"的重要性了,如果高位不参与运算,那么高16位的hash特征几乎永远得不到展现,发生hash碰撞的几率就会增大,从而影响性能。

(2)get方法

publicV get(Object key) {

Nodee;return (e = getNode(hash(key), key)) == null ? null : e.value;      //根据key及其hash值查询node节点,如果存在,则返回该节点的value值。

}final Node getNode(int hash, Object key) {                 //根据key搜索节点的方法。记住判断key相等的条件:hash值相同 并且 符合equals方法。

Node[] tab; Node first, e; intn; K k;if ((tab = table) != null && (n = tab.length) > 0 &&         //根据输入的hash值,可以直接计算出对应的下标(n - 1)& hash,缩小查询范围,如果存在结果,则必定在table的这个位置上。

(first = tab[(n - 1) & hash]) != null) {if (first.hash == hash && //always check first node

((k = first.key) == key || (key != null && key.equals(k))))    //判断第一个存在的节点的key是否和查询的key相等。如果相等,直接返回该节点。

returnfirst;if ((e = first.next) != null) {                       //遍历该链表/红黑树直到next为null。

if (first instanceof TreeNode)       //当这个table节点上存储的是红黑树结构时,在根节点first上调用getTreeNode方法,在内部遍历红黑树节点,查看是否有匹配的TreeNode。

return ((TreeNode)first).getTreeNode(hash, key);do{if (e.hash == hash &&                        //当这个table节点上存储的是链表结构时,用跟第11行同样的方式去判断key是否相同。

((k = e.key) == key || (key != null &&key.equals(k))))returne;

}while ((e = e.next) != null);                     //如果key不同,一直遍历下去直到链表尽头,e.next == null。

}

}return null;

}

因为查询过程不涉及到HashMap的结构变动,所以get方法的源码显得很简洁。核心逻辑就是遍历table某特定位置上的所有节点,分别与key进行比较看是否相等。

(3)resize方法(扩容机制)

扩容时机

在jdk1.8中,resize方法是在hashmap中的键值对大于阙值时,

初始化时,

链表转红黑树时,

putAll时,就会调用resize()方法进行扩容

final Node[] resize() {//保存旧的 Hash 数组

Node[] oldTab =table;int oldCap = (oldTab == null) ? 0: oldTab.length;int oldThr =threshold;int newCap, newThr = 0;if (oldCap > 0) {//超过最大容量,不再进行扩充

if (oldCap >=MAXIMUM_CAPACITY) {

threshold=Integer.MAX_VALUE;returnoldTab;

}//容量没有超过最大值,容量变为原来的两倍

else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&oldCap>=DEFAULT_INITIAL_CAPACITY)//阀值变为原来的两倍

newThr = oldThr << 1;

}else if (oldThr > 0)

newCap=oldThr;else{//阀值和容量使用默认值

newCap =DEFAULT_INITIAL_CAPACITY;

newThr= (int)(DEFAULT_LOAD_FACTOR *DEFAULT_INITIAL_CAPACITY);

}if (newThr == 0) {//计算新的阀值

float ft = (float)newCap *loadFactor;//阀值没有超过最大阀值,设置新的阀值

newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?(int)ft : Integer.MAX_VALUE);

}

threshold=newThr;

@SuppressWarnings({"rawtypes","unchecked"})//创建新的 Hash 表

Node[] newTab = (Node[])newNode[newCap];

table=newTab;//遍历旧的 Hash 表

if (oldTab != null) {for (int j = 0; j < oldCap; ++j) {

Nodee;if ((e = oldTab[j]) != null) {//释放空间

oldTab[j] = null;//当前节点不是以链表的形式存在

if (e.next == null)

newTab[e.hash& (newCap - 1)] =e;//红黑树的形式,略过

else if(e instanceof TreeNode)

((TreeNode)e).split(this, newTab, j, oldCap);else{//以链表形式存在的节点;//这一段就是新优化的地方,见下面分析

Node loHead = null, loTail = null;

Node hiHead = null, hiTail = null;

Nodenext;do{

next=e.next;if ((e.hash & oldCap) == 0) {if (loTail == null)

loHead=e;elseloTail.next=e;

loTail=e;

}else{if (hiTail == null)

hiHead=e;elsehiTail.next=e;

hiTail=e;

}

}while ((e = next) != null);if (loTail != null) {//最后一个节点的下一个节点做空

loTail.next = null;

newTab[j]=loHead;

}if (hiTail != null) {//最后一个节点的下一个节点做空

hiTail.next = null;

newTab[j+ oldCap] =hiHead;

}

}

}

}

}returnnewTab;

}

由代码可以看出,其实就是通过复制,将table数据保存到旧的hash数组oldTab,然后循环遍历oldTab,根据oldTab.next判断有没有值,没有值就意味着就是桶数组元素,直接复制到新建的newTab,然后有值的话则判断是否是红黑树,若是红黑树的话调用split修剪方法进行拆分放置,若是链表的话,根据一定的规则分两种情况,一种是留在旧链表,一种是去新链表(do-while循环)

链表情况详解

假如现在容量为初始容量16,再假如5,21,37,53的hash自己(二进制),

所以在oldTab中的存储位置就都是 hash & (16 - 1)【16-1就是二进制1111,就是取最后四位】,

5  :00000101

21:00010101

37:00100101

53:00110101

四个数与(16-1)相与后都是0101

即原始链为:5--->21--->37--->53---->null

此时进入代码中 do-while 循环,对链表节点进行遍历,判断是留下还是去新的链表:

lo就是扩容后仍然在原地的元素链表

hi就是扩容后下标为  原位置+原容量  的元素链表,从而不需要重新计算hash。

因为扩容后计算存储位置就是  hash & (32 - 1)【取后5位】,但是并不需要再计算一次位置,

此处只需要判断左边新增的那一位(右数第5位)是否为1即可判断此节点是留在原地lo还是移动去高位hi:(e.hash & oldCap) == 0 (oldCap是16也就是10000,相与即取新的那一位)

5  :00000101——————》0留在原地  lo链表

21:00010101——————》1移向高位  hi链表

37:00100101——————》0留在原地  lo链表

53:00110101——————》1移向高位  hi链表

退出循环后只需要判断lo,hi是否为空,然后把各自链表头结点直接放到对应位置上即可完成整个链表的移动。

(4)remove(Object key)方法0

publicV remove(Object key) {

Nodee;return (e = removeNode(hash(key), key, null, false, true)) == null ?

null: e.value;

}final Node removeNode(inthash, Object key, Object value,boolean matchValue, booleanmovable) {

Node[] tab; Node p; intn, index;if ((tab = table) != null && (n = tab.length) > 0 &&(p= tab[index = (n - 1) & hash]) != null) {

Node node = null, e; K k; V v;if (p.hash == hash &&((k= p.key) == key || (key != null &&key.equals(k))))

node=p;//待删除元素在桶中,但不是桶中首元素

else if ((e = p.next) != null) {//待删除元素在红黑树结构的桶中

if (p instanceofTreeNode)//查找红黑树

node = ((TreeNode)p).getTreeNode(hash, key);else{//遍历链表,查找待删除元素

do{if (e.hash == hash &&((k= e.key) == key ||(key!= null &&key.equals(k)))) {

node=e;break;

}//p保存待删除节点的前一个节点,用于链表删除操作

p =e;

}while ((e = e.next) != null);

}

}/*** matchValue为true:表示必须value相等才进行删除操作

* matchValue为false:表示无须判断value,直接根据key进行删除操作*/

if (node != null && (!matchValue || (v = node.value) == value ||(value!= null &&value.equals(v)))) {//桶为红黑数结构,删除节点

if (node instanceofTreeNode)//movable参数用于红黑树操作

((TreeNode)node).removeTreeNode(this, tab, movable);//待删除节点是桶链表表头,将子节点放进桶位

else if (node ==p)

tab[index]=node.next;//待删除节点在桶链表中间

elsep.next=node.next;++modCount;--size;

afterNodeRemoval(node);returnnode;

}

}//待删除元素不存在,返回null

return null;

}

(5)还有size()、isEmpty()、clear()、containsValue(Object value)、values()等等方法,在这就不一一列举了,大家可以查看JDK1.8 HashMap源码

三、HashMap为什么要改进使用红黑树

在jdk1.7中,HashMap处理“碰撞”的时候,都是采用链表来存储的,当碰撞的结点很多的时候(也就是hash值相同、key不同的元素很多时),查询时间是O(n)(最坏的情况)。查询时间从O(1)到O(n)。

而在jdk1.8中,HashMap处理“碰撞”增加了红黑树这种数据结构,当碰撞结点少时,采用链表存储,当较大的时候(>8),采用红黑树存储,查询时间是O(log n)。

到这里,我们一起学习了HashMap的结构实现以及核心源码,HashMap还有一些重要的知识要了解,比如说并发安全问题、内部红黑树的实现、与其他Map子类、其他集合类的联系等等,之后会陆续剖析,大家一起学习,共同进步吧!

halfstone 原理_HashMap的结构以及核心源码分析相关推荐

  1. 迷你版jQuery——zepto核心源码分析

    前言 zepto号称迷你版jQuery,并且成为移动端dom操作库的首选 事实上zepto很多时候只是借用了jQuery的名气,保持了与其基本一致的API,其内部实现早已面目全非! 艾伦分析了jQue ...

  2. 【JUC源码专题】Striped64 核心源码分析(JDK8)

    文章目录 核心变量 缓存行填充 longAccumulate 方法 方法概览 cells 数组已初始化 重新计算随机数 扩容前置条件 cells 数组未初始化 cas 更新 Base Striped6 ...

  3. Mybatis 核心源码分析

    一.Mybatis 整体执行流程 二.Mybatis 具体流程源码分析 三.源码分析 写一个测试类,来具体分析Mybatis 的执行流程: public class MybatisTest {publ ...

  4. java 多线程源码分析_JAVA 多线程核心源码分析

    首先来看最核心的execute方法,这个方法在AbstractExecutorService中并没有实现,从Executor接口,直到ThreadPoolExecutor才实现了改方法,Executo ...

  5. ConcurrentHashmap核心源码分析(一)

    常量Constants 成员属性Fields 静态代码块 内部类 Node类部分分析 TreeNode类部分分析 ForwardingNode类部分分析 内部小方法源码分析 static final ...

  6. HTTP流量神器Goreplay核心源码详解

    摘要:Goreplay 前称是 Gor,一个简单的 TCP/HTTP 流量录制及重放的工具,主要用 Go 语言编写. 本文分享自华为云社区<流量回放工具之 goreplay 核心源码分析> ...

  7. XXL-JOB核心源码解读及时间轮原理剖析

    你好,今天我想和你分享一下XXL-JOB的核心实现.如果你是XXL-JOB的用户,那么你肯定思考过它的实现原理:如果你还未接触过这个产品,那么可以通过本文了解一下. XXL-JOB的架构图(2.0版本 ...

  8. 【源码阅读计划】浅析 Java 线程池工作原理及核心源码

    [源码阅读计划]浅析 Java 线程池工作原理及核心源码 为什么要用线程池? 线程池的设计 线程池如何维护自身状态? 线程池如何管理任务? execute函数执行过程(分配) getTask 函数(获 ...

  9. 面试官系统精讲Java源码及大厂真题 - 09 TreeMap 和 LinkedHashMap 核心源码解析

    09 TreeMap 和 LinkedHashMap 核心源码解析 更新时间:2019-09-05 10:15:03 人的影响短暂而微弱,书的影响则广泛而深远. --普希金 引导语 在熟悉 HashM ...

最新文章

  1. iMeta期刊12名编委入选科睿唯安2021年度高被引学者
  2. vue读取redis 值_Jmeter连接Redis,一定很容易学会吧
  3. 计算机网络---个人笔记整理
  4. Go 开源说第五期:MOSN Go语言网络代理软件
  5. 2019中南大学考研计算机考试,中南大学2019年全国硕士研究生入学考试《计算机网络》考.PDF...
  6. 多线程解决rospy.spin()语句之后,程序不再往下执行问题
  7. s7-200与计算机modbus通讯案例,【案例】S7-200SMART MODBUS通信介绍与实例编程
  8. 清华大学计算机毕业论文,清华大学毕业论文撰写要求
  9. 用mtrace定位内存泄漏
  10. pgsql处理文档类型数据_【干货总结】:可能是史上最全的MySQL和PGSQL对比材料
  11. http post请求 参数放在路径后面 java_【思唯网络学院】网络基本概念之HTTP协议...
  12. WAF和IPS的区别
  13. HTML5 新属性的讲解
  14. 从阿尔法狗元(AlphaGo Zero)的诞生看终极算法的可能性
  15. 手机app的性能测试工具——GT、、Emmagee
  16. Linux网卡驱动(4)—DM9000网卡驱动程序完全分析
  17. 三维重建 建立客观世界的虚拟现实||时空克隆 三维视频融合 投影融合 点卯 魔镜系列
  18. 基于医疗RFID手术用品智能柜管理应用方案
  19. 有一台服务器可以做哪些很酷的事情·2
  20. 病毒木马查杀实战第014篇:U盘病毒之手动查杀

热门文章

  1. php 二维数组字母排序,PHP二维数组获取第一个中文首字母并排序 筋斗云网络
  2. Mysql基础--常见的表的约束介绍(一)
  3. 在安装one_gadget遇到 one_gadget requires Ruby version >= 2.4. 的问题解决
  4. python中取整数的四种方法
  5. python基础教程: 自定义函数
  6. Python-functools (reduce,偏函数partial,lru_cache)
  7. android 长按缩放拖动_十年Android之路面试2000人,面试准备+内部泄露核心题(中高级)...
  8. c++采集声卡输出_其实声卡不单单只有音效,更多功能看这篇就对了
  9. Yunyang tensorflow-yolov3 voc_train.txt以及voc_test.txt引用的路径位置
  10. numpy基础——对数组切片操作