数据结构
1.数组
特点 :顺序存储结构,元素类型是一致的,每个元素的占用内存长度是相同的,i 存放 的是head元素的内存地址,通过下标访问,也叫随机访问,查询效率比较高,增删慢
静态数组,长度固定
int [] i = new int[4];
基本数据类型的话是存储数据
User[] i =new user [4];
引用数据类型的话,存放的是引用数据类型堆内的地址值
int [] i = new int[4];
当进行get(3)操作时,就是找 head元素的内存地址+3*每个元素占用的内存长度
公式 :get(i) 就是head元素的内存地址+i*每个元素占用的内存长度 时间复杂度一直都是O(1)
动态数组
扩容因子 时间复杂度O(n),
2.链表
可以利用内存零碎空间,查询效率低,增删快
单向链表
链表需要在全局上维护一个 head 变量并指向0的内存地址,然后0指向1的内存地址,依次类推,就能维护起来。
内部类 中维护 Node ,里面放着(item=0,next=1)
时间复杂度O(N) get(3) O(3)
可以维护 last(tail) 从尾部找,更快
双向链表
单向链表加上指针,就是双向链表
一般使用Integer.maxValue来限制链表的最大值
3.索引
查询效率高,增删也快
4.散列
元素的全部或者一部分 通过散列算法 例如 hash散列算法 转换成一个数值 然后用这个数值 对数组长度进行取模得到的就是存储下标
锁
通过锁解决多线程并发的安全
悲观锁
synchronized 用在方法上 让方法变成串行,不让多线程进来,一个个进来
synchronized 在内部维护 一个队列 线程进来时需要一个个拿锁资源 其他的在队列里面排队(先自旋在排队)
抢到synchronized 就是加上了锁,执行完之后,就会释放锁 自动的
Object wait notify(释放有一定随机性)
加锁
ReentrantLock lock = new ReentrantLock (); 阻塞时加到队列里
Condotion condotion = lock.newCondition(); //通过锁获取Condotion实例
lock.lock ();
释放锁
condotion.await //当前对象的await方法,唤醒当前对象锁
condotion.signal
lock.unLock()
乐观锁
无锁的机制 运用主要时cas+自旋的机制 compare and set 比较然后设置
利用 JVM 底层 Unsafe类 操作系统提供的指令 来保证 上面两个compare and set 的原子性
只有一个线程cas能执行成功
单线程进来不加锁 ,没有争抢资源的情况 ,多线程进来才会加锁
Thread
LockSupport.park();阻塞
LockSupport.unPark(指定唤醒线程);
List
ArrayList
动态数组
源码 : ArrayList 在全局变量里面有一个数组 elementData存储元素
扩容
未指定初始长度
ArrayList 无参构造器
new 一个空参的时候 会指向一个空的数组
数组长度为0,存储元素时无法存储,需要扩容
指定初始长度
传入容量
判断是否合法,如果为零的话,指向下图数组
为什么指定长度为零的话指定这个数组 EMPTY_ELEMENTDATA,而无参构造器指向DEFAULTCAPACITY_EMPTY_ELEMENTDATA?
实际上是为了判断用了哪个构造器来构造的List,判断是否指定了初始容量。也就是说如果指向的数组是EMPTY_ELEMENTDATA这个数组的话就表明有初始容量,之后再进行对应的扩容操作(按照1.5倍来扩容)。如果指向的数组是这个DEFAULTCAPACITY_EMPTY_ELEMENTDATA的话,说明是无参构造,没有初始容量,那么在第一次扩容的时候会默认将新数组扩容为十。
grow 方法
ArrayList
数据结构:动态数组,自动扩容
- 添加元素时容量不够
- 新建一个数组,长度为原数组的长度*扩容因子
- 新元素放入新数组,并将新数组赋值给集合,原数组丢弃
两个常量空数组:节省集合创建时占用的内存 每new一个都指向这两个常量空数组其中一个,减少内存空间的使用
-
EMPTY_ELEMENTDATA
构造器指定了初始容量,容量不够时按1.5倍扩容
-
DEFAULTCAPACITY_EMPTY_ELEMENTDATA
无参构造器,初次添加元素时容量默认初始化为10,容量不够时按1.5倍扩容
迭代器:fail-fast 快速失败
modCount是操作数,无论进行添加还是修改,底层会对其进行++,用于记录操作数
为什么需要记录操作数?
- iterator()获取迭代器时,保存modCount(操作次数)快照,游标cursor初始化为0
- hasNext判断条件:cursor != size,如果有其他线程修改了集合长度(新增删除元素),会导致size变化,modCount和快照也会有差
- next()取元素时,为防下标越界,先判断modCount和快照是否相同,不同意味着size已变化
public class Demo02 {
public static void main(String[] args) {
List l = new ArrayList();
l.add(new Person("张三",11));
l.add(new Person("李四",18));
l.add(new Person("王五",20));
l.add(new Person("赵六",22));
Iterator it = l.iterator();
while(it.hasNext()){
Object o = it.next();
Person p = (Person)o;
if(p.getName().equals("赵六")){
//ConcurrentModificationException 并发修改异常
//产生的原因就是在迭代器的遍历的过程中,使用集合对象进行了增删集合中的元素
//修改了集合的长度
//此时在迭代器中进行遍历集合的每一个元素
//迭代器依存于集合
//而我们直接修改了集合的内容,导致迭代器遍历判断就发生了问题
//l.remove(new Person("王五",20));
//迭代器中想要删除目标元素,一定使用迭代器对象提供的remove()方法
//删除当前指向位置的元素
it.remove();//it指向哪个元素,就是删除哪个元素
}
System.out.println(p);
}
System.out.println(l);
}
}
Iterator 在ArrayList中 是以内部类的形式存在 ,维护了三个变量!
cursor 游标 用来记录迭代器循环到哪个地方了 相当于for循环遍历的 i
游标从0开始 到最后一位的时候 cursor为5 再cursor++ 就变成6了, size(大小)为6
当cursor和size相等就说明后面没有元素可以取了
while(it.hasNext()){
}
hasNext 底层就是判断 cursor!=size
lastRet 最后访问的元素 expectedModCount 期望操作数 modCount 实际操作数
再创建时会将modCount 赋值给expectedModCount
原因 :如果我们不进行modCount和expectedModCount(创建迭代器的时候将当时的modCount赋值给expectedModCount),这个程序肯定会报ArrayIndexOutOfBoundsException,所以迭代器设计时不支持多线程,当迭代器检测到集合的操作就会立马抛出ConcurrentModificationException 并发修改异常,进行快速失败,避免下面的操作。
拷贝:拷贝栈中的内容
不管什么拷贝都是栈中的内容
浅拷贝 互相不独立,实现Cloneable接口,重写super.clone 方法,方法默认
-
拷贝前后变量是否独立
1)基本类型:独立 在栈中分配,拷贝一份栈中的内容,因此拷贝前后的变量完全独立
2)引用类型:不独立 栈中只记录了引用地址,拷贝一份栈中内容,拷贝前后都是指向堆中的同一块内存
3)String: 独立 栈中记录了内存地址,但是指向的内容在常量池中,如果修改该常量,不会直接修改, 而是生成一个新的常量并赋值给变量,因此拷贝前后的变量指向了不同的内存地址
-
正常拷贝,实现Cloneable接口,clone方法默认,则为浅拷贝
-
深拷贝:需要递归所有的引用类型,递归到基本类型和String,数组为止,都拷贝一次
序列化
重写了序列化方法,因为存放元素的数组elementData里面有很多占位用的null,序列化时只需要按照size读写
Arrays.asList
接收的是可变参数,基本类型不支持泛型化,会把整个数组当成一个元素放入新的数组,传入可变参数
CopyOnWriteArrayList
效率高 推荐使用
- 数据结构同ArrayList,数组array
- 写(除了读)时加锁复制:ReentrantLock 保证线程安全,修改数组之前先将数组拷贝一份,操作新数组,并赋值给array,旧数组丢弃
- 读操作无锁 读的是旧数组,写不会阻塞读,读写分离
- 弱一致性:读不一定读到新的值 写操作会生成新的数组,读的数据可能已经被修改,迭代器也是弱一致性,读的是快照
写和写之间是会排斥的,写的是新数组,读的是旧数组,读写不阻塞
LinkedList
- 数据结构:链表,维护一个内部类Node,元素以Node节点的item属性存在,同时维护next和prev记录前驱后继的指针
- 双端队列:实现了Deque接口 支持从首部和尾部存取元素
Set
HashSet
底层使用HashMap存储数据,用map的key存储元素
- 元素不重复,key不能重复 因为HashMap的key是不能重复的
- 元素是无序的,key是无序的 因为HashMap的key是无序的
- 元素允许一个null值,key允许一个null 因为HashMap的key是允许一个null的
- 非线程安全,没有get()方法 因为HashMap的key是无法通过get取出的
向HashSet里面存储一个数据 就是向HashMap中存储一个 key
底层都是操作的Map,将值存到key里,values是PRESENT常量
public boolean add(E e) {
return map.put(e, PRESENT)==null;
}
public boolean remove(Object o) {
return map.remove(o)==PRESENT;
}
虚拟对象PRESENT,存map时,value固定为此值
LinkedHashSet
没有成员变量及api,全部使用LinkedHashMap替代
TreeSet
底层使用NavigableMap存储元素,实际上大部分情况就是TreeMap
CopyOnWriteArraySet
底层使用CopyOnWriteArrayList
调addIfAbsent方法保证元素的不可重复
ConcurrentSkipListSet
底层使用ConcurrentSkipListMap
HashMap
数据结构 Node类型数组和单向链表链表 内部维护一个数组 一个Node内部类
当put(K,V) 时 通过 hash算法 hash(key) = hashcode 进行两次hash 得到一个hashcode值
然后得到的这个hashcode值和数组长度取模(类似取余,不过结果的符合应该和被除一致)得到一个比数组长度更小的数 就是 下标值
为了更快定位到数组下标位置
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
Node<K,V> next;
Node(int hash, K key, V value, Node<K,V> next) {
this.hash = hash;
this.key = key;
this.value = value;
this.next = next;
}
K值和V值是通过Node数组存储的,Node数组有两个属性,一个key 一个 value
当存储的key值为null时(只能存储一个key值为null的) 一般直接存储在第一个位置
当通过计算下标存入一组数据后 再次存储时有可能计算的一直 会造成hash冲突
当计算的下标一直时 会使用Node的另一个属性 next 会在此下标上用链表存储
get(k)时 在同一个下标上进行 equals方法比对 链表的查询效率低 需要一个个进行equals 效率非常低
所以在jdk8之后 当链表的高度达到了8,数组的长度达到了64 时 这个链表就会转换为 红黑树
JDK7 Node 数组+单向链表
JDK8 Node 数组+单向链表+红黑树
红黑树就是平衡二叉树中一个比较好的实现
扩容
负载因子 默认是0.75 数组长度*负载因子
将原数组进行扩容 需要再次计算hash值,因为数组长度改变了
JDK7
进行扩容复制时使用头插法,会将原来链表顺序发生一个改变,将链表第二个元素放在第一个元素前面
在这里HashMap会出现一个并发的问题 死锁
HashMap不是线程安全的 不同线程进来都有可能发现数组的大小不够 需要扩容
当线程1扩容时 添加过第一个元素A后 第二个元素 通过 Node next = e.next A.next=B获取 ,A的next指向的是B 此时线程1 挂起
线程2进来的时候进行扩容 将B元素进行了头插法 使得 B.next = A
当线程1 唤醒时,继续进行扩容 发现 原来的 A.next=B 再添加时 B.next = A 就产生的死锁
实际上就是链表的闭链
JDK8
所以JDK8使用尾插法进行解决
就算线程2已经执行完了 也会将 A.next=B 就这样规避了死锁的问题
JDK8为什么不能直接采用红黑树,非要采用链表加入中间?
因为节点数太少的时候,数组长度低于64,节点数低于8 时 引用红黑树进行存储的话,红黑树的拆分和创建比链表的时间还要长
初始容量
要求为2的n次方,不是的话会默认找最近的一个2的n次方 源码会进行一个位运算
负载因子loadFactor
static final float DEFAULT_LOAD_FACTOR = 0.75f;
扩容的阈值 默认是0.75
equals
hashCode 定位下标,equals定位点元素
使用HashMap什么时候需要重写equals方法?
当需要把自己声明的类的对象用作HashMap中的key值时,由于HashMap中get()与put()方法的实现中大量运用了equals方法对key元素的散列与查找进行比较操作,若希望携带自创类型为key或value的HashMap在散列时能够均匀、在存取时能够精准,需要在自己创建的类中重写类的equals方法,进而不去调用Object类的equals方法。
class Person{
private String name;
private String IDcard;
public boolean equals(Person p){
if(this.IDcard!=null || this.IDcard,equals(p.IDcard))
return true;
return false;
}
}
源码分析
public final boolean equals(Object o) {
if (o == this)
return true;
if (o instanceof Map.Entry) {
Map.Entry<?,?> e = (Map.Entry<?,?>)o;
if (Objects.equals(key, e.getKey()) &&
Objects.equals(value, e.getValue()))
return true;
}
return false;
}
上述的equals方法是 位于objets类中静态的比较方法,是为了避免NullPointerException异常
public static boolean equals(Object a, Object b) {
return (a == b) || (a != null && a.equals(b));
}
在实际调用equals方法是,HashMap使用父类AbstractMap的equals,下面是AbstractMap中的equals方法:
public boolean equals(Object o) {
//首先判断两个对象的内存地址是否相同,若相同返回true
if (o == this)
return true;
//判断参数是否为Map类或它的子类
if (!(o instanceof Map))
return false;
//判断两个map的size是否相同,size()是同一个类中的方法
Map<?,?> m = (Map<?,?>) o;
if (m.size() != size())
return false;
try {
//使用迭代器遍历调用equal方法的那个map,注意entrySet方法
//m即为上面参数赋值过的那个传入的map对象
Iterator<Entry<K,V>> i = entrySet().iterator();
while (i.hasNext()) {
Entry<K,V> e = i.next();
K key = e.getKey();
V value = e.getValue();
if (value == null) {
//当这个节点的value值为null时,比较key
if (!(m.get(key)==null && m.containsKey(key)))
return false;
} else {
//当节点的值不为空时,比较"调用方法的对象的值"与在"参数对象"中同一key取出的值
if (!value.equals(m.get(key)))
return false;
}
}
} catch (ClassCastException unused) {
return false;
} catch (NullPointerException unused) {
return false;
}
return true;
}
containsKey方法
public boolean containsKey(Object key) {
//此处是一个简单的判断key的过程,首先获取调用此方法的对象的迭代器
Iterator<Map.Entry<K,V>> i = entrySet().iterator();
//当传入key为null时,判断调用对象的key是否为null
if (key==null) {
while (i.hasNext()) {
Entry<K,V> e = i.next();
if (e.getKey()==null)
return true;
}
//当传入key不为null时,判断调用对象的key是否相同,调用的equals就到了Object的,按照不同数据类型的比较方法再次比较
} else {
while (i.hasNext()) {
Entry<K,V> e = i.next();
if (key.equals(e.getKey()))
return true;
}
}
return false;
}
ConcurrentHashMap
JDK7
线程安全
数据结构:ReentrantLock+Segment+HashEntry,一个Segment中包含一个HashEntry数组,每个HashEntry又是一个链表结构
有一个内部类 Segment 相当于维护了一个Segment数组 继承自ReentrantLock
还有个内部类 HashEntry 相当于以前的(Node)
关系如图
分段锁
可以直接锁住第一个Segment ,保证线程安全,性能提升,比synchronized更细粒度,
元素查询
比HashMap慢一点
- 第一次Hash定位到Segment
- 第二次Hash定位到元素所在的链表的头部
锁:Segment分段锁
- Segment继承了ReentrantLock
- 锁定操作的Segment,其他的Segment不受影响
- 并发度为segment个数,可以通过构造函数指定
- 数组扩容不会影响其他的segment
get方法无需加锁
Node的val和next使用volatile修饰,当进行改变的时候会实时响应到主存中去,读线程会去主存中去读,可以读到最新的值
JDK8
数据结构:synchronized+CAS+Node+红黑树
- jdk8中对synchronized做了改变 加了自旋和偏移
- Node的val和next都用volatile修饰,保证可见性
查找,替换,赋值操作都使用CAS
- 无锁,性能比较高
锁:锁链表的head节点
- 不影响其他元素的读写,锁粒度更细,效率更高
- 扩容时,阻塞所有的写操作
并发扩容
一个线程发起扩容的时候,就会通过cas设置sizeCtl属性(volatile修饰),告知其他线程扩容状态变更。创建一个两倍容量的新数组nextTable,单线程完成。
其他线程发现一个线程正在扩容 会进行协助扩容
扩容时候会判断这个值(sizeCtl属性),如果超过阈值就要扩容
- 首先根据运算得到需要遍历的次数i,然后利用tabAt方法获得i位置的元素f
- 初始化一个forwardNode实例fwd,如果f == null,则在table中的i位置放入fwd
- 否则采用头插法的方式把当前旧table数组的指定任务范围的数据给迁移到新的数组中,然后给旧table原位置赋值fwd
- 迁移数据至少每次获取16个迁移任务,transferIndex扩容索引,迁移数据前先cas操作该变量,相当于任务头指针(游标的作用)
- 直到遍历过所有的节点以后就完成了复制工作,把table指向nextTable,并更新sizeCtl为新数组大小的0.75倍 ,扩容完成。 在此期间如果其他线程的有读写操作都会判断head节点是否为forwardNode节点,如果是就帮助扩容。
ForwardingNode节点
- 标记作用,表示其他线程正在扩容,并且此节点已经扩容完毕
- 关联了extTable,扩容期间可以通过find方法,访问已经迁移到了nextTable中的数据
- 如果遍历到的节点是forward节点,就向后继续遍历,再加上给节点上锁的机制,就完成了多线程的控制。多线程遍历节点, 处理了一个节点,就把对应点的值set为forward,另一个线程看到forward,就向后遍历。这样交叉就完成了复制工作
读操作无锁
- Node的val和next使用volatile修饰,读写线程对该变量互相可见
- 数组用volatile修饰,保证扩容时被读线程感知
TreeMap
红黑树实现
LinkedHashMap
继承自HashMap
顺序
-
accessOrder为false 插入顺序保存
-
accessOrder为true 访问顺序保存,访问会导致顺序变动
最近访问的元素在最后面
可以利用这个特性实现LRU(缓存淘汰)
-
双向链表 元素直接有指针双向链接
缓存淘汰 动态更新,最新浏览 最后浏览 访问时间
Properties
以key=value 的 键值对的形式进行存储值。 key值不能重复
xml和properties文件相互转化
...