Java Collection - Map
Giới thiệu về Map
Kiến trúc của Map
Dòng họ Map bao gồm các thành viên chính sau:
Map
là gốc của dòng họ Map, là một giao diện dùng để lưu trữ các cặp khóa-giá trị (key-value). Map không thể chứa các khóa trùng lặp; mỗi khóa chỉ có thể ánh xạ tới một giá trị duy nhất.AbstractMap
kế thừa từ lớp trừu tượngMap
, nó triển khai các API cốt lõi củaMap
. Các lớp triển khaiMap
khác có thể kế thừa từAbstractMap
để tránh việc lặp lại mã.SortedMap
kế thừa từ giao diệnMap
. Nội dung củaSortedMap
được sắp xếp theo cặp khóa-giá trị, việc sắp xếp được thực hiện thông qua việc triển khai một bộ so sánh (Comparator).NavigableMap
kế thừa từSortedMap
. So vớiSortedMap
,NavigableMap
cung cấp một loạt các phương thức “điều hướng”; ví dụ như “lấy các cặp khóa-giá trị lớn hơn hoặc bằng một đối tượng nào đó”, “lấy các cặp khóa-giá trị nhỏ hơn hoặc bằng một đối tượng nào đó”, v.v.HashMap
kế thừa từAbstractMap
, nhưng không triển khai giao diệnNavigableMap
.HashMap
chủ yếu được sử dụng để lưu trữ các cặp khóa-giá trị không có thứ tự, và việc tìm kiếm trongHashMap
rất hiệu quả.HashMap
là một trong nhữngMap
được sử dụng rộng rãi nhất.Hashtable
mặc dù không kế thừa từAbstractMap
, nhưng nó kế thừa từDictionary
(cũng là giao diện khóa-giá trị), và triển khai giao diệnMap
. Do đó,Hashtable
chủ yếu được sử dụng để lưu trữ các cặp khóa-giá trị không có thứ tự. So vớiHashMap
,Hashtable
sử dụng từ khóasynchronized
trong các phương thức chính để đảm bảo đồng bộ hóa luồng. Tuy nhiên, do độ chi tiết của khóa quá lớn, nó ảnh hưởng đến tốc độ đọc và ghi, vì vậy trong các ứng dụng Java hiện đại, hầu như không sử dụngHashtable
. Thay vào đó, nếu cần đảm bảo đồng bộ hóa, thường sử dụngConcurrentHashMap
.TreeMap
kế thừa từAbstractMap
và triển khai giao diệnNavigableMap
.TreeMap
chủ yếu được sử dụng để lưu trữ các cặp khóa-giá trị có thứ tự, sắp xếp dựa trên một bộ so sánh của kiểu phần tử.WeakHashMap
kế thừa từAbstractMap
. Khóa củaWeakHashMap
là các tham chiếu yếu (WeakReference), chức năng chính của nó là khi bộ nhớ GC không đủ,WeakHashMap
sẽ tự động thu hồi các khóa trong nó, tránh việc lãng phí không gian bộ nhớ. Rõ ràng,WeakHashMap
thích hợp để sử dụng như một bộ nhớ cache.
Giao diện Map
Định nghĩa của Map như sau:
Map là một giao diện dùng để lưu trữ các cặp khóa-giá trị (key-value).
Giao diện Map cung cấp ba loại xem Collection
, cho phép truy cập dữ liệu dưới dạng tập hợp khóa, tập hợp giá trị hoặc tập hợp cặp khóa-giá trị.
Các lớp triển khai của Map có thể lưu trữ các phần tử theo thứ tự hoặc không theo thứ tự. Ví dụ, lớp TreeMap
sẽ lưu trữ các phần tử theo thứ tự, trong khi HashMap
không đảm bảo thứ tự.
Các lớp triển khai của Map nên cung cấp hai phương thức xây dựng “tiêu chuẩn”:
- Phương thức xây dựng không tham số, dùng để tạo một Map trống.
- Phương thức xây dựng với một tham số kiểu Map, dùng để tạo một Map mới có cùng cặp khóa-giá trị với tham số đầu vào.
Thực tế, phương thức xây dựng thứ hai cho phép người dùng sao chép bất kỳ Map nào để tạo ra một Map tương tự. Mặc dù không thể bắt buộc thực hiện đề xuất này (vì giao diện không thể chứa phương thức xây dựng), nhưng tất cả các triển khai Map thông thường trong JDK đều tuân thủ nó.
Giao diện Map.Entry
Map.Entry
thường được sử dụng để truy cập Map thông qua Iterator.
Map.Entry
là một giao diện nội bộ của Map, Map.Entry
đại diện cho một cặp khóa-giá trị, Map sử dụng phương thức entrySet()
để lấy một tập hợp các Map.Entry
, từ đó thực hiện các thao tác trên cặp khóa-giá trị.
Lớp trừu tượng AbstractMap
Định nghĩa của AbstractMap
như sau:
Lớp trừu tượng AbstractMap
cung cấp triển khai cốt lõi của giao diện Map
, giúp giảm thiểu công việc cần thiết để triển khai giao diện Map
.
Để triển khai một Map không thể sửa đổi, người lập trình chỉ cần mở rộng lớp này và cung cấp triển khai của phương thức entrySet()
, phương thức này sẽ trả về một tập hợp các cặp khóa-giá trị của Map. Thông thường, tập hợp trả về sẽ được triển khai trên AbstractSet
. Tập hợp này không hỗ trợ các phương thức add()
hoặc remove()
, và trình lặp của nó cũng không hỗ trợ phương thức remove()
.
Để triển khai một Map có thể sửa đổi, người lập trình phải ghi đè phương thức put()
của lớp này (nếu không sẽ ném ra UnsupportedOperationException
), và cũng phải triển khai phương thức remove()
của trình lặp trả về từ entrySet().iterator()
.
Giao diện SortedMap
Định nghĩa của SortedMap
như sau:
SortedMap
kế thừa từ Map
, đây là một Map có thứ tự.
SortedMap
có hai cách sắp xếp: sắp xếp tự nhiên hoặc sắp xếp theo bộ so sánh được chỉ định bởi người dùng. Tất cả các phần tử được chèn vào SortedMap phải triển khai giao diện Comparable (hoặc được chấp nhận bởi bộ so sánh được chỉ định).
Ngoài ra, tất cả các lớp triển khai của SortedMap
nên cung cấp 4 phương thức xây dựng “tiêu chuẩn”:
- Phương thức xây dựng
void
không tham số, tạo mộtSortedMap
trống và sắp xếp theo thứ tự tự nhiên của khóa. - Phương thức xây dựng với một tham số kiểu
Comparator
, tạo mộtSortedMap
trống và sắp xếp theo bộ so sánh được chỉ định. - Phương thức xây dựng với một tham số kiểu
Map
, tạo mộtSortedMap
mới với các cặp khóa-giá trị tương tự như tham số đầu vào và sắp xếp theo thứ tự tự nhiên của khóa. - Phương thức xây dựng với một tham số kiểu
SortedMap
, tạo một SortedMap mới với các cặp khóa-giá trị và phương thức sắp xếp tương tự nhưSortedMap
đầu vào. Mặc dù không thể bắt buộc thực hiện đề xuất này (vì giao diện không thể chứa phương thức khởi tạo), nhưng tất cả các triển khaiSortedMap
thông thường trong JDK đều tuân thủ nó.
Giao diện NavigableMap
NavigableMap
được định nghĩa như sau:
NavigableMap
kế thừa từ SortedMap
, nó cung cấp các phương thức tìm kiếm phong phú.
NavigableMap
cung cấp các chức năng sau:
- Lấy cặp khóa-giá trị
- Các phương thức
lowerEntry
,floorEntry
,ceilingEntry
vàhigherEntry
lần lượt trả về các đối tượngMap.Entry
liên quan đến khóa nhỏ hơn, nhỏ hơn hoặc bằng, lớn hơn hoặc bằng, lớn hơn khóa đã cho. - Các phương thức
firstEntry
,pollFirstEntry
,lastEntry
vàpollLastEntry
lần lượt trả về và/hoặc xóa các cặp khóa-giá trị nhỏ nhất và lớn nhất (nếu tồn tại), nếu không tồn tại trả về null.
- Các phương thức
- Lấy khóa. Tương tự như chức năng thứ nhất.
- Các phương thức
lowerKey
,floorKey
,ceilingKey
vàhigherKey
lần lượt trả về các khóa nhỏ hơn, nhỏ hơn hoặc bằng, lớn hơn hoặc bằng, lớn hơn khóa đã cho.
- Các phương thức
- Lấy tập hợp khóa
- Các phương thức
navigableKeySet
vàdescendingKeySet
lần lượt trả về tập hợp khóa theo thứ tự tăng dần/giảm dần.
- Các phương thức
- Lấy tập hợp cặp khóa-giá trị con
Lớp trừu tượng Dictionary
Định nghĩa của lớp trừu tượng Dictionary
như sau:
Dictionary
là một lớp trừu tượng được định nghĩa từ JDK 1.0, nó chứa các phương thức cơ bản để thao tác với các cặp khóa-giá trị.
Lớp HashMap
Lớp HashMap
là một trong những lớp Map
được sử dụng phổ biến nhất.
Điểm chính của HashMap
Từ tên của lớp HashMap
, chúng ta có thể thấy rằng HashMap
lưu trữ các cặp khóa-giá trị theo cách phân tán.
HashMap
cho phép sử dụng giá trị null và khóa null. (HashMap
tương đương với Hashtable
, ngoại trừ việc nó không đồng bộ và cho phép giá trị null). Lớp này không đảm bảo thứ tự; đặc biệt là thứ tự các phần tử có thể thay đổi theo thời gian.
HashMap
có hai tham số ảnh hưởng đến hiệu suất của nó: dung lượng ban đầu và hệ số tải.
- Dung lượng là số lượng ngăn trong bảng băm, dung lượng ban đầu là dung lượng khi bảng băm được tạo.
- Hệ số tải là mức tối đa cho phép của bảng băm trước khi nó được tự động mở rộng. Khi số lượng entry trong bảng băm vượt quá tích của hệ số tải và dung lượng hiện tại, bảng băm sẽ được tái cấu trúc (tức là tái xây dựng cấu trúc dữ liệu nội bộ), thông thường bảng băm có khoảng gần bằng gấp đôi dung lượng hiện tại.
Thường thì, hệ số tải mặc định (0.75) cung cấp một sự cân bằng tốt giữa thời gian và không gian. Giá trị cao hơn sẽ giảm thiểu không gian nhưng tăng thời gian tìm kiếm (phản ánh trong hầu hết các hoạt động của lớp HashMap
, bao gồm get
và put
). Khi đặt dung lượng ban đầu, nên xem xét số lượng entry trong bản đồ và hệ số tải để giảm thiểu số lần thực hiện lại. Nếu dung lượng ban đầu lớn hơn số lượng entry tối đa chia cho hệ số tải, không có sự thay đổi lại sẽ xảy ra.
Nếu có nhiều bản đồ cần lưu trữ trong một HashMap
instance, việc tạo một dung lượng đủ lớn cho bản đồ sẽ cho phép hiệu suất lưu trữ của bản đồ cao hơn so với việc thực hiện lại tự động khi tăng kích thước bảng. Lưu ý rằng việc sử dụng nhiều khóa có cùng hashCode()
là một cách đáng tin cậy để làm giảm hiệu suất của bất kỳ bảng băm nào. Để cải thiện vấn đề này, khi khóa là Comparable
, lớp này có thể sử dụng thứ tự so sánh giữa các khóa để giúp phân tách chúng.
HashMap
không phải là an toàn luồng.
Nguyên lý hoạt động của HashMap
Cấu trúc dữ liệu của HashMap
Các trường chính của HashMap
:
table
-HashMap
sử dụng một mảngNode<K,V>[]
kiểu dữ liệutable
để lưu trữ các phần tử.size
- Dung lượng ban đầu. Ban đầu là 16 và tự động mở rộng khi không đủ dung lượng.loadFactor
- Hệ số tải. Mức tối đa cho phép của bảng băm trước khi nó được tự động mở rộng, mặc định là 0.75.
Các phương thức khởi tạo của HashMap
Phương thức put
Phương thức put có cách thức triển khai như sau:
- Tính toán hash code của key bằng cách gọi phương thức
hash(key)
, sau đó tính toán vị trí lưu trữ của Node dựa trên giá trị hash. - Nếu không có va chạm hash, Node mới được đặt trực tiếp vào bucket. Nếu có va chạm hash, Node mới sẽ được thêm vào cuối danh sách liên kết.
- Nếu danh sách liên kết trở nên quá dài (lớn hơn hoặc bằng
TREEIFY_THRESHOLD
, giá trị là 8), danh sách liên kết sẽ được chuyển đổi thành cây đỏ-đen (Red-Black Tree). - Nếu Node đã tồn tại, giá trị cũ sẽ được thay thế bằng giá trị mới.
- Nếu số lượng bucket vượt quá dung lượng * hệ số tải (
threshold
),HashMap
sẽ tự động mở rộng gấp đôi dung lượng.
Đoạn mã cụ thể như sau:
Tại sao tính toán hash sử dụng phép dịch bit không dấu 16 bit của hashcode.
Giả sử chúng ta muốn thêm hai đối tượng a và b vào HashMap
, nếu độ dài mảng là 16, lúc này đối tượng a và b sẽ được tính toán thông qua công thức (n - 1) & hash
, tức là (16-1) & a.hashCode
và (16-1) & b.hashCode
, 15 ở dạng nhị phân là 0000000000000000000000000001111
, giả sử hashCode của đối tượng A là 1000010001110001000001111000000
, hashCode của đối tượng B là 0111011100111000101000010100000
, bạn sẽ nhận thấy kết quả của phép AND trên đều là 0. Điều này khiến kết quả băm rất thất vọng, rõ ràng không phải là một thuật toán băm tốt.
Tuy nhiên, nếu chúng ta dịch bit phải hashCode 16 bit (h >>> 16
) (h >>> 16
đại diện cho dịch bit phải không dấu 16 bit), tức là lấy một nửa giá trị của kiểu int, chúng ta có thể chia đôi chuỗi nhị phân này và sử dụng phép XOR bit (nếu hai số tại vị trí tương ứng là khác nhau, kết quả sẽ là 1, ngược lại sẽ là 0), điều này sẽ tránh tình huống trên xảy ra. Đó chính là cách thức cụ thể của phương thức hash(). Tóm lại, mục đích là cố gắng làm lộn xộn giá trị hashCode tham gia tính toán của 16 bit thấp nhất.
Phương thức get
Sau khi hiểu về phương thức put, phương thức get trở nên đơn giản hơn. Ý tưởng chính như sau:
-
Tính toán hash code của key bằng cách gọi phương thức
hash(key)
, sau đó tính toán vị trí của Node trong bucket. -
Nếu Node đầu tiên trong bucket trùng khớp với key, trả về giá trị tương ứng.
-
Nếu có xung đột hash, tìm kiếm entry tương ứng trong danh sách liên kết.
-
Nếu là cây, tìm kiếm trong cây đỏ-đen bằng cách sử dụng
key.equals(k)
, độ phức tạp O(logn). -
Nếu là danh sách liên kết, tìm kiếm trong danh sách liên kết bằng cách sử dụng
key.equals(k)
, độ phức tạp O(n).
-
Cụ thể, mã nguồn triển khai như sau:
Phương thức hash
Công thức tính chỉ số của bucket trong HashMap là key.hashCode() ^ (h >>> 16)
.
Dưới đây là một giải thích chi tiết về công thức này.
Trong quá trình get
và put
, khi tính toán chỉ số, trước tiên ta thực hiện phép hash trên hashCode
, sau đó tính toán chỉ số dựa trên giá trị hash, như hình dưới đây:
Phương thức hash được thực hiện như sau:
Có thể thấy rằng phương thức này thực hiện việc giữ nguyên 16 bit cao nhất và thực hiện phép XOR giữa 16 bit thấp và 16 bit cao.
Khi thiết kế phương thức hash này, vì độ dài của bảng table
hiện tại là một lũy thừa của 2, và trong quá trình tính toán chỉ số, ta sử dụng phép AND (&
) thay vì phép chia lấy dư (%
):
Nhà thiết kế cho rằng phương pháp này dễ gây va chạm. Tại sao lại như vậy? Hãy suy nghĩ xem, khi n - 1
là 15 (0x1111), thực tế chỉ có 4 bit thấp là có hiệu lực, dễ gây ra va chạm.
Do đó, nhà thiết kế đã nghĩ ra một phương pháp tổng thể (kết hợp tốc độ, hiệu quả và chất lượng) là thực hiện phép XOR giữa 16 bit cao và 16 bit thấp. Nhà thiết kế cũng giải thích rằng vì phân bố hashCode hiện tại đã khá tốt, ngay cả khi có va chạm, ta vẫn sử dụng cây có độ phức tạp O(logn) để xử lý. Chỉ cần thực hiện phép XOR đơn giản, giảm thiểu chi phí hệ thống và không gây ra va chạm do việc không có sự tham gia của bit cao trong tính toán chỉ số (khi độ dài của bảng nhỏ), dẫn đến va chạm.
Nếu vẫn có va chạm thường xuyên, điều gì sẽ xảy ra? Nhà thiết kế đã giải thích rằng họ sử dụng cây để xử lý va chạm thường xuyên (we use trees to handle large sets of collisions in bins). Trong JEP-180, mô tả vấn đề này như sau:
Cải thiện hiệu suất của
java.util.HashMap
trong điều kiện va chạm hash cao bằng cách sử dụng cây cân bằng thay vì danh sách liên kết để lưu trữ các mục của bản đồ. Thực hiện cải tiến tương tự trong lớpLinkedHashMap
.
Trước đây, trong việc lấy phần tử của HashMap, ta thực hiện hai bước cơ bản:
- Đầu tiên, thực hiện hash dựa trên hashCode(), sau đó xác định chỉ số của bucket.
- Nếu khóa của nút trong bucket không phải là khóa ta cần, ta tìm kiếm trong chuỗi bằng cách sử dụng keys.equals().
Trong việc triển khai trước JDK8, va chạm được giải quyết bằng cách sử dụng danh sách liên kết. Trong trường hợp va chạm nghiêm trọng, khi thực hiện lấy phần tử, độ phức tạp của hai bước là O(1) + O(n). Do đó, khi va chạm nghiêm trọng và n rất lớn, tốc độ O(n) sẽ ảnh hưởng đến hiệu suất.
Vì vậy, trong JDK8, ta sử dụng cây đỏ-đen thay thế danh sách liên kết. Điều này làm cho độ phức tạp trở thành O(1) + O(logn), giải quyết vấn đề này một cách tương đối tốt. Kết quả của các bài kiểm tra hiệu suất có trong bài viết Tăng hiệu suất của HashMap trong JDK8 đã được thể hiện.
Triển khai resize
Khi thực hiện put
, nếu tỷ lệ sử dụng bucket hiện tại vượt quá giới hạn Load Factor mong muốn, ta sẽ thực hiện resize. Trong quá trình resize, đơn giản là mở rộng số bucket lên gấp đôi, sau đó tính toán lại chỉ số và đặt lại nút vào bucket mới.
Khi vượt quá giới hạn, ta thực hiện resize. Tuy nhiên, vì ta sử dụng mở rộng lũy thừa của 2 (tức là độ dài mở rộng gấp đôi), vị trí của các phần tử sẽ nằm ở vị trí ban đầu hoặc vị trí ban đầu cộng với 2 lũy thừa.
Làm thế nào để hiểu điều này? Ví dụ, khi ta mở rộng từ 16 lên 32, các thay đổi cụ thể như sau:
Do đó, sau khi tính toán lại hash, vì n tăng gấp đôi, phần mở rộng mới có thể được coi là bit mới được thêm vào có giá trị 1 hoặc 0, với xác suất là ngẫu nhiên. Nếu là 0, chỉ số không thay đổi, nếu là 1, chỉ số sẽ trở thành “chỉ số ban đầu + oldCap”. Hãy xem ví dụ dưới đây về việc mở rộng từ 16 lên 32:
Thiết kế này thực sự rất tinh vi, không chỉ tiết kiệm thời gian tính toán lại hash mà còn đồng thời, vì bit mới được thêm vào có thể coi là ngẫu nhiên (có giá trị 0 hoặc 1), do đó quá trình resize đã phân tán đồng đều các nút va chạm vào bucket mới.
Lớp LinkedHashMap
Điểm chính của LinkedHashMap
LinkedHashMap
duy trì một danh sách liên kết hai chiều để lưu trữ tất cả các mục (Entry) và đảm bảo thứ tự của các phần tử khi duyệt qua chúng (tức là thứ tự chèn).
Điểm quan tâm | Kết luận |
---|---|
Cho phép cặp khóa-giá trị là null | Cả Khóa và Giá trị đều cho phép null |
Cho phép dữ liệu trùng lặp | Khóa trùng sẽ ghi đè, Giá trị cho phép trùng lặp |
Có thứ tự | Lưu trữ theo thứ tự chèn |
An toàn đa luồng | Không an toàn đa luồng |
Điểm chính của LinkedHashMap
Cấu trúc dữ liệu của LinkedHashMap
LinkedHashMap
duy trì một cặp con trỏ đầu cuối của kiểu LinkedHashMap.Entry<K,V>
, lưu trữ tất cả dữ liệu dưới dạng danh sách liên kết hai chiều.
Nếu bạn đã học qua danh sách liên kết hai chiều trong cấu trúc dữ liệu, bạn sẽ hiểu rằng việc lưu trữ và truy cập các phần tử sẽ được thực hiện theo một thứ tự nhất định.
LinkedHashMap
kế thừa phương thức put
của HashMap
, nhưng không triển khai phương thức put
riêng.
Lớp TreeMap
Điểm chính của TreeMap
TreeMap
được triển khai dựa trên cây đỏ-đen.
TreeMap
là một cấu trúc dữ liệu có thứ tự. Thứ tự được xác định bằng cách sử dụng thứ tự tự nhiên của khóa trong bản đồ hoặc thứ tự tùy chỉnh được cung cấp bởi bộ so sánh (Comparator
).
TreeMap không an toàn đa luồng.
Nguyên lý của TreeMap
Phương thức put
Phương thức get
Phương thức remove
Ví dụ TreeMap
WeakHashMap
Định nghĩa của WeakHashMap như sau:
WeakHashMap kế thừa từ AbstractMap và triển khai giao diện Map.
Giống như HashMap, WeakHashMap cũng là một bảng băm (hash table) và lưu trữ các cặp khóa-giá trị (key-value) trong đó cả khóa và giá trị đều có thể là null.
Tuy nhiên, khóa trong WeakHashMap là khóa yếu (weak key). Trong WeakHashMap, khi một khóa không còn được tham chiếu bởi đối tượng khác, nó sẽ tự động bị xóa khỏi WeakHashMap. Cụ thể hơn, đối với một khóa cụ thể, việc ánh xạ của nó không ngăn chặn việc thu gom rác bởi bộ thu gom rác, điều này làm cho khóa trở thành khóa có thể bị hủy, bị hủy và sau đó bị thu gom. Khi một khóa bị hủy, cặp khóa-giá trị tương ứng cũng sẽ được loại bỏ khỏi ánh xạ.
Nguyên lý của khóa yếu là gì? Đại khái là sử dụng WeakReference và ReferenceQueue để thực hiện.
Khóa trong WeakHashMap là khóa yếu (weak key), có nghĩa là nó là một WeakReference. ReferenceQueue là một hàng đợi, nó sẽ lưu trữ các khóa yếu (weak key) bị thu gom rác từ WeakHashMap. Các bước thực hiện như sau:
- Tạo một WeakHashMap và thêm các cặp khóa-giá trị vào WeakHashMap. Thực tế, WeakHashMap lưu trữ các Entry (cặp khóa-giá trị) thông qua một mảng table; Mỗi Entry thực tế là một danh sách liên kết đơn, tức là Entry là danh sách liên kết của các cặp khóa-giá trị.
- Khi một khóa yếu (weak key) không còn được tham chiếu bởi đối tượng khác và bị thu gom rác, nó sẽ được thêm vào hàng đợi ReferenceQueue.
- Khi chúng ta cần thao tác với WeakHashMap lần tiếp theo, chúng ta sẽ đồng bộ hóa table và queue. Table lưu trữ tất cả các cặp khóa-giá trị, trong khi queue lưu trữ các cặp khóa-giá trị bị thu gom rác; Đồng bộ hóa chúng có nghĩa là xóa các cặp khóa-giá trị bị thu gom rác khỏi table.
Đó là các bước để tự động xóa khóa yếu (weak key) khỏi WeakHashMap.
Giống như HashMap, WeakHashMap không đồng bộ. Chúng ta có thể sử dụng phương thức Collections.synchronizedMap để tạo ra một WeakHashMap đồng bộ.