赞
踩
转载请注明出处:https://blog.csdn.net/mymottoissh/article/details/82826709
目的是总结一下 JAVA 和 C++几种常用的数据结构内存模型,知道了内存结构,才能在实战中更好滴选用数据结构。通过总结,日后也避免把两种语言的结构混为一谈。闲言少叙,开始正文。
关键字:JAVA C++ 数据结构
目录
首先做一个整体的概括:
底层数据存储只分成两大类,一种是连续存储,如数组。一种是链式存储,如链表。在这基础之上,结合结构和算法,才衍生出如树、图之类的结构。前者的优点:随机寻址快。后者优点:插删速度快。这里也会结合源码做一些剖析。
代表结构:
JAVA :ArrayList、Vector、HashMap/HashTable/LinkedHashMap、HashSet
C++ : vector、deque<特>、stack/queue <特>
开始接触的时候很困惑,Java里明明写的是list,为什么还算作数组。这就是恶心的地方,所以千万不要别外表欺骗了。
Java::ArrayList
ArrayList无疑是项目中最常用的结构,为了查找他底层的存储原理,直接看他的add方法
- /**
- * Appends the specified element to the end of this list.
- *
- * @param e element to be appended to this list
- * @return <tt>true</tt> (as specified by {@link Collection#add})
- */
- public boolean add(E e) {
- ensureCapacityInternal(size + 1); // Increments modCount!!
- elementData[size++] = e;
- return true;
- }
显然,存储结构为一数组 elementData。这里有一个关键方法实现动态内存
- private void ensureCapacityInternal(int minCapacity) {
- if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
- minCapacity = Math.max(DEFAULT_CAPACITY, minCapacity);
- }
-
- ensureExplicitCapacity(minCapacity);
- }
-
- private void ensureExplicitCapacity(int minCapacity) {
- modCount++;
-
- // overflow-conscious code
- if (minCapacity - elementData.length > 0)
- grow(minCapacity);
- }
-
- private void grow(int minCapacity) {
- // overflow-conscious code
- int oldCapacity = elementData.length;
- int newCapacity = oldCapacity + (oldCapacity >> 1);
- if (newCapacity - minCapacity < 0)
- newCapacity = minCapacity;
- if (newCapacity - MAX_ARRAY_SIZE > 0)
- newCapacity = hugeCapacity(minCapacity);
- // minCapacity is usually close to size, so this is a win:
- elementData = Arrays.copyOf(elementData, newCapacity);
- }

每次添加一个数据之前,都要保证数组中有比现有数据长度多1的容量,如果保证不了,则会创建一个新的容量为以前1.5倍的数组,并把元素进行复制。可想而知,如果需要反复扩容的话,ArrayList的效率是非常低的。同时,ArrayList也保留了数组本身的特点:如果在某一位置增删数据,需要把后面的数据全都复制一遍。这部分代码就不贴了。
Java::Vector
- /**
- * Appends the specified element to the end of this Vector.
- *
- * @param e element to be appended to this Vector
- * @return {@code true} (as specified by {@link Collection#add})
- * @since 1.2
- */
- public synchronized boolean add(E e) {
- modCount++;
- ensureCapacityHelper(elementCount + 1);
- elementData[elementCount++] = e;
- return true;
- }
在add方法下,可以看到与ArrayList类似的存储:数组。增加之前,首先确保容量,唯一不同的在于在前面加入了synchronized关键字。所以在多线程环境下,应该选用Vector。
JAVA::HashMap/HashTable/LinkedHashMap
这里把HashMap及相关结归结到了动态数组里,这关系到HashMap的实现原理。直接放源码
- public V put(K key, V value) {
- return putVal(hash(key), key, value, false, true);
- }
-
- /**
- * Implements Map.put and related methods
- *
- * @param hash hash for key
- * @param key the key
- * @param value the value to put
- * @param onlyIfAbsent if true, don't change existing value
- * @param evict if false, the table is in creation mode.
- * @return previous value, or null if none
- */
- final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
- boolean evict) {
- Node<K,V>[] tab; Node<K,V> p; int n, i;
- if ((tab = table) == null || (n = tab.length) == 0)
- n = (tab = resize()).length;
- if ((p = tab[i = (n - 1) & hash]) == null) //索引下标计算
- tab[i] = newNode(hash, key, value, null);
- else {
- Node<K,V> e; K k;
- if (p.hash == hash &&
- ((k = p.key) == key || (key != null && key.equals(k))))
- e = p;
- else if (p instanceof TreeNode)
- e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
- else {
- 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;
- }
- }
- if (e != null) { // existing mapping for key
- V oldValue = e.value;
- if (!onlyIfAbsent || oldValue == null)
- e.value = value;
- afterNodeAccess(e);
- return oldValue;
- }
- }
- ++modCount;
- if (++size > threshold)
- resize();
- afterNodeInsertion(evict);
- return null;
- }

首先可以看到存储结构为一Node数组。在这里有点小疑问,Java里的Node是一个单向链表节点,而这里却用了一个Node数组存储。究其原因,还有分析一下HashMap的原理。当向map中put一个键值对时,首先通过hash方法计算key的哈希值,以 (数组长度-1)&哈希值 作为数组索引下标。如果该下标对应的数组值为空,则new一个新节点。如果已经有值,那该值对应的key一定等于新增的可以吗?当然不是,因为可能会有哈希碰撞。这时候就用到单向链表结构的特性了,如果当前的数组值对应的key与新增key不相等,则会遍历以当前Node值为首节点的链表,直到找到与新key相等的节点为止。其中TREEIFY_THRESHOLD 定义为8,如果超过次链表长度,将转为树存储。更形象的表示,HashMap的结构应该是这样滴。
在put源码中,有两个HashMap未实现的函数,但是在LinkedHashMap中实现了,这也保证了其有序的元素存储。
- // Callbacks to allow LinkedHashMap post-actions
- void afterNodeAccess(Node<K,V> p) { }
- void afterNodeInsertion(boolean evict) { }
- void afterNodeRemoval(Node<K,V> p) { }
至于HashTable与HashMap的区别,这里就不造轮子了
引用一段 : https://blog.csdn.net/qq_29631809/article/details/72599708
HashMap和Hashtable的主要的区别有:线程安全性,同步(synchronization),以及速度。
- HashMap几乎可以等价于Hashtable,除了HashMap是非synchronized的,并可以接受null(HashMap可以接受为null的键值(key)和值(value),而Hashtable则不行)。 HashMap是非synchronized,而Hashtable是synchronized,这意味着Hashtable是线程安全的,多个线程可以共享一个Hashtable;而如果没有正确的同步的话,多个线程是不能共享HashMap的。Java 5提供了ConcurrentHashMap,它是HashTable的替代,比HashTable的扩展性更好。
- 另一个区别是HashMap的迭代器(Iterator)是fail-fast迭代器,而Hashtable的enumerator迭代器不是fail-fast的。所以当有其它线程改变了HashMap的结构(增加或者移除元素),将会抛出ConcurrentModificationException,但迭代器本身的remove()方法移除元素则不会抛出ConcurrentModificationException异常。但这并不是一个一定发生的行为,要看JVM。这条同样也是Enumeration和Iterator的区别。 由于Hashtable是线程安全的也是synchronized,所以在单线程环境下它比HashMap要慢。如果你不需要同步,只需要单一线程,那么使用HashMap性能要好过Hashtable。 HashMap不能保证随着时间的推移Map中的元素次序是不变的。
Java::HashSet
废话不多说,贴一个源码就知道了
- /**
- * Constructs a new, empty set; the backing <tt>HashMap</tt> instance has
- * default initial capacity (16) and load factor (0.75).
- */
- public HashSet() {
- map = new HashMap<>();
- }
-
- /**
- * Adds the specified element to this set if it is not already present.
- * More formally, adds the specified element <tt>e</tt> to this set if
- * this set contains no element <tt>e2</tt> such that
- * <tt>(e==null ? e2==null : e.equals(e2))</tt>.
- * If this set already contains the element, the call leaves the set
- * unchanged and returns <tt>false</tt>.
- *
- * @param e element to be added to this set
- * @return <tt>true</tt> if this set did not already contain the specified
- * element
- */
- public boolean add(E e) {
- return map.put(e, PRESENT)==null;
- }

HashSet内部维护了一个HashMap的,对HashSet的操作,实际上就是多HashMap的操作。
C++::vector
- void push_back(_Ty&& _Val)
- { // insert element at end
- if (_Inside(_STD addressof(_Val)))
- { // push back an element
- size_type _Idx = _STD addressof(_Val) - this->_Myfirst;
- if (this->_Mylast == this->_Myend)
- _Reserve(1);
- _Orphan_range(this->_Mylast, this->_Mylast);
- _Cons_val(this->_Alval,
- this->_Mylast,
- _STD forward<_Ty>(this->_Myfirst[_Idx]));
- ++this->_Mylast;
- }
- else
- { // push back a non-element
- if (this->_Mylast == this->_Myend)
- _Reserve(1);
- _Orphan_range(this->_Mylast, this->_Mylast);
- _Cons_val(this->_Alval,
- this->_Mylast,
- _STD forward<_Ty>(_Val));
- ++this->_Mylast;
- }
- }

pushback时,首先判断该元素地址是否属于数组内部,如果是,则直接在改地址对应位置更新数据,同时在最末端位置压入数据。如果不是,则在数组末端添加数据。
首先,vector是单端数组,只提供了push_back函数,虽然也提供了insert函数,但是插入时,会导致后续元素进行交换反转,所以插入效率极低。
其次,_Reserve函数用于数组扩容,扩大为原来的1.5倍。在扩容的过程中会new一块新的内存空间。这意味着一旦被扩容,指向原有元素的迭代器将失效。
C++::deque
双端数组,提供前端压入函数push_front和末端压入函数push_back。虽然命名为双端数组,而且从某种意义上来说是内存连续的,但是关于deque有必要重点说明一下。首先来做一个实验。
有如下代码,编译运行
- int main() {
- std::deque<int> deq;
- for(int i = 0; i < 10; i++) {
- deq.push_back(i);
- }
- for(int i = 0; i < 10; i++) {
- cout << deq[i] << " " << &deq[i] << endl;
- }
- system("pause");
- return 0;
- }
来看一运行结果
- 0 00000000002CAF10
- 1 00000000002CAF14
- 2 00000000002CAF18
- 3 00000000002CAF1C
- 4 00000000002CAF60
- 5 00000000002CAF64
- 6 00000000002CAF68
- 7 00000000002CAF6C
- 8 00000000002DD2D0
- 9 00000000002DD2D4
奇怪吧,从运行结果可以看到,每四个元素的内存是连续的,从第五个开始,会跳过一段空间,重新组织内存。那关于deque实现到底是怎样的呢。用一句话说,deque是数组和指针(并不能说是链表)的组合实现。上源码。
- void push_front(_Ty&& _Val)
- { // insert element at beginning
- this->_Orphan_all();
- _PUSH_FRONT_BEGIN;
- _Cons_val(this->_Alval,
- this->_Map[_Block] + _Newoff % _DEQUESIZ,
- _STD forward<_Ty>(_Val));
- _PUSH_FRONT_END;
- }
-
- void push_back(_Ty&& _Val)
- { // insert element at end
- this->_Orphan_all();
- _PUSH_BACK_BEGIN;
- _Cons_val(this->_Alval,
- this->_Map[_Block] + _Newoff % _DEQUESIZ,
- _STD forward<_Ty>(_Val));
- _PUSH_BACK_END;
- }

从源码中可以看到,插入的时候,实际上是取的_Map[_Block]所存储的地址。
_Mapptr _Map; // pointer to array of pointers to blocks
而通过注释看到,_Map实际上是一个二级指针,指向的是一个数组,这个数组存放的是指向block的指针。那么问题又来了,block是什么鬼。
- size_type _Getblock(size_type _Off) const
- { // determine block from offset
- // NB: _Mapsize and _DEQUESIZ are guaranteed to be powers of 2
- return ((_Off / _DEQUESIZ) & (_Mapsize - 1));
- }
看看这个获取block的函数就大致明白了,block是一块存储内存。其实在deque内部,数组里的每个元素存储的都是一个指向这种block的指针。block是一段连续的存储空间,在block内部的元素,内存都是连续的,但是block与block之间内存不连续。那么block的大小是怎么确定的呢。
- #define _DEQUESIZ (sizeof (value_type) <= 1 ? 16 \
- : sizeof (value_type) <= 2 ? 8 \
- : sizeof (value_type) <= 4 ? 4 \
- : sizeof (value_type) <= 8 ? 2 \
- : 1) /* elements per block (a power of 2) */
就是通过上述这个算法计算得到。这也解释了为什么我们实验中,针对deque<int>对象,block的大小为4。
另外,关于前插和后插,offset的计算可以区分。在插入数据的前后,分别通过宏定义做了一些操作,相信这就是区分前还是后的关键。来看一下宏定义中都做了什么。
- #define _PUSH_FRONT_BEGIN \
- if (this->_Myoff % _DEQUESIZ == 0 \
- && this->_Mapsize <= (this->_Mysize + _DEQUESIZ) / _DEQUESIZ) \
- _Growmap(1); \
- size_type _Newoff = this->_Myoff != 0 ? this->_Myoff \
- : this->_Mapsize * _DEQUESIZ; \
- size_type _Block = --_Newoff / _DEQUESIZ; \
- if (this->_Map[_Block] == 0) \
- this->_Map[_Block] = this->_Alval.allocate(_DEQUESIZ)
- #define _PUSH_BACK_BEGIN \
- if ((this->_Myoff + this->_Mysize) % _DEQUESIZ == 0 \
- && this->_Mapsize <= (this->_Mysize + _DEQUESIZ) / _DEQUESIZ) \
- _Growmap(1); \
- size_type _Newoff = this->_Myoff + this->_Mysize; \
- size_type _Block = _Newoff / _DEQUESIZ; \
- if (this->_Mapsize <= _Block) \
- _Block -= this->_Mapsize; \
- if (this->_Map[_Block] == 0) \
- this->_Map[_Block] = this->_Alval.allocate(_DEQUESIZ)
对比发现,逻辑基本一致。首先判断容量是否够用,不够用则扩容。然后计算偏移量,即写入数据的位置,最后调用_Cons_val写入数据。不过这个代码是有讲究的,在分析源码之前,我对这个结构也有些误解。
首先是扩容判断条件。既然是双端数组,以前认为首末两端都会预留一些空位用于写入数据,所以如果有一端空间不足,则会扩容。而实际的情况是,容量不分前后,只有在总容量不够用时才会重新分配。
那么第二个问题来了,既然不是首末都有空位,那怎么实现push_back和push_front呢。这里deque维护了几个成员变量
- _Mapptr _Map; // 指向block指针数组的指针
- size_type _Mapsize; // _Map的尺寸
- size_type _Myoff; // 首元素的偏移地址
- size_type _Mysize; // 当前数组的尺寸
其中,_Myoff记录了首元素的偏移位置,而且从宏_PUSH_FRONT_BEGIN的源码中可以看到,一旦首部空间不足,则会将push_front的数据写加到数据最末端,从这角度来讲,deque类似于一个循环数组。所以这是一个圈圈,而不是一条直线。
同时,与vector一样,一旦内存因扩容而重新分配,指向原有元素的迭代器将失效。
C++::stack/queue
把这两个放在一起说,一个先进后出,一个先进先出。两个结构内部使用的容器其实都是deque,所以不再深入分析了。.
class _Container = deque<_Ty> >
代表结构
JAVA :LinkedList
C++ : list
Java::LinkedList
- /**
- * Appends the specified element to the end of this list.
- *
- * <p>This method is equivalent to {@link #addLast}.
- *
- * @param e element to be appended to this list
- * @return {@code true} (as specified by {@link Collection#add})
- */
- public boolean add(E e) {
- linkLast(e);
- return true;
- }
- /**
- * Links e as last element.
- */
- void linkLast(E e) {
- final Node<E> l = last;
- final Node<E> newNode = new Node<>(l, e, null);
- last = newNode;
- if (l == null)
- first = newNode;
- else
- l.next = newNode;
- size++;
- modCount++;
- }

单向链表。所以在进行LinkedList随机存取时,需要遍历链表。
C++::list
双向链表,结构不多说。这里需要重点说明一下,C++的list到底是不是循环链表呢?是,但不是典型的循环链表,因为在首Node和尾Node之间存在一个"多余"的节点。
_Nodeptr _Myhead; // pointer to head node
就是它。也是因为有它,当我们用迭代器遍历元素时,才不会遍历个没完没了。原因看这
- iterator end()
- { // return iterator for end of mutable sequence
- return (iterator(this->_Myhead, this));
- }
end返回的正是他的地址。如果画出来的话,C++中list的数据结构应该是这样子滴。画的糙,凑合看。
代表结构
JAVA :TreeMap
C++ : set/map
Java::TreeMap
- /**
- * The comparator used to maintain order in this tree map, or
- * null if it uses the natural ordering of its keys.
- *
- * @serial
- */
- private final Comparator<? super K> comparator;
-
- private transient Entry<K,V> root;
-
- /**
- * Associates the specified value with the specified key in this map.
- * If the map previously contained a mapping for the key, the old
- * value is replaced.
- *
- * @param key key with which the specified value is to be associated
- * @param value value to be associated with the specified key
- *
- * @return the previous value associated with {@code key}, or
- * {@code null} if there was no mapping for {@code key}.
- * (A {@code null} return can also indicate that the map
- * previously associated {@code null} with {@code key}.)
- * @throws ClassCastException if the specified key cannot be compared
- * with the keys currently in the map
- * @throws NullPointerException if the specified key is null
- * and this map uses natural ordering, or its comparator
- * does not permit null keys
- */
- public V put(K key, V value) {
- Entry<K,V> t = root;
- if (t == null) {
- compare(key, key); // type (and possibly null) check
-
- root = new Entry<>(key, value, null);
- size = 1;
- modCount++;
- return null;
- }
- int cmp;
- Entry<K,V> parent;
- // split comparator and comparable paths
- Comparator<? super K> cpr = comparator;
- if (cpr != null) {
- do {
- parent = t;
- cmp = cpr.compare(key, t.key);
- if (cmp < 0)
- t = t.left;
- else if (cmp > 0)
- t = t.right;
- else
- return t.setValue(value);
- } while (t != null);
- }
- else {
- if (key == null)
- throw new NullPointerException();
- @SuppressWarnings("unchecked")
- Comparable<? super K> k = (Comparable<? super K>) key;
- do {
- parent = t;
- cmp = k.compareTo(t.key);
- if (cmp < 0)
- t = t.left;
- else if (cmp > 0)
- t = t.right;
- else
- return t.setValue(value);
- } while (t != null);
- }
- Entry<K,V> e = new Entry<>(key, value, parent);
- if (cmp < 0)
- parent.left = e;
- else
- parent.right = e;
- fixAfterInsertion(e);
- size++;
- modCount++;
- return null;
- }

首先TreeMap是一棵红黑树,类的内部维护了两个主要变量:root节点和比较器(谓词)。root为红黑树的树根,当插入一个数据时,根据比较器判断向左子树还是右子树插入数据。构造TreeMap时,可以传入比较器,如果比较器为空,则使用类型默认的比较器。所如果key为自定义类型,则最好实现Comparable接口。
C++::set/map
C++中的set和map都继承自xtree,也就是说两种结构都是用红黑树存储的。我们知道,这两种存储都是有序的,且存在默认的排序规则,在构造时,可以自定义仿函数来重定义排序规则。大体上看一下xtree插入的逻辑。
- _Pairib insert(const value_type& _Val, bool _Leftish)
- { // try to insert node with value _Val, on left if _Leftish
- _Nodeptr _Trynode = _Root();
- _Nodeptr _Wherenode = this->_Myhead;
- bool _Addleft = true; // add to left of head if tree empty
- while (!this->_Isnil(_Trynode))
- { // look for leaf to insert before (_Addleft) or after
- _Wherenode = _Trynode;
- if (_Leftish)
- _Addleft = !_DEBUG_LT_PRED(this->comp,
- this->_Key(_Trynode),
- this->_Kfn(_Val)); // favor left end
- else
- _Addleft = _DEBUG_LT_PRED(this->comp,
- this->_Kfn(_Val),
- this->_Key(_Trynode)); // favor right end
- _Trynode = _Addleft ? this->_Left(_Trynode)
- : this->_Right(_Trynode);
- }
-
- if (this->_Multi)
- return (_Pairib(_Insert(_Addleft, _Wherenode, _Val), true));
- else
- { // insert only if unique
- iterator _Where = iterator(_Wherenode, this);
- if (!_Addleft)
- ; // need to test if insert after is okay
- else if (_Where == begin())
- return (_Pairib(_Insert(true, _Wherenode, _Val), true));
- else
- --_Where; // need to test if insert before is okay
-
- if (_DEBUG_LT_PRED(this->comp,
- this->_Key(_Where._Mynode()),
- this->_Kfn(_Val)))
- return (_Pairib(_Insert(_Addleft, _Wherenode, _Val), true));
- else
- return (_Pairib(_Where, false));
- }
- }

插入时,首先通过仿函数判断插入左子树还是右子树。然后判断该位置是否有数据,没有的话才会执行插入。最后调用_Pairlib进行数据的添加。
以上,对Java和C++常用的数据结构做了简要的总结。日后选用结构时可以根据其特性,选却最优的建模方式。
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。