JavaSE-03-集合框架(详细版)
编程世界有这么一句话:算法+数据结构 = 程序。可以看出数据结构是非常重要的,常见的数据结构包括:数组、链表、栈、队列、树等。在Java中,又是如何实现这些常见的数据结构的呢?答案是:集合框架。
Java开发中,经常会遇到处理数据的情况,使用合适的数据结构,将有助于高效处理问题。Java的集合框架是我们开发中的利器,本文将深入了解集合框架的内容。涉及Array数组、list列表、set集合、queue队列、以及键值对的map映射,辅以丰富的案例帮助学习,同时给出最佳实践和使用建议。既能帮助初学者学习,也能帮助老鸟查漏补缺,作为开发过程中的参考。
一、Array数组
在 Java 中,数组(Array) 是一种基础且常用的数据结构,用于存储固定大小的相同类型元素。数组原则上不属于Java集合框架的一员,但其和List列表有挺多的相似之处,所以这里也展开来讲讲。数组,可以看做是多个数据的有限集合,长度有限,是一个有边界的容器,该边界初始化的时候就确立了。数组在内存中是整片连续内存占用的,所以查询性能非常高。
数组特性
-
数组初始化
-
int[] arr = {1,2,3}直接实例化并初始化 -
int[] arr2 = new int[3]使用new实例化,通过int[0] = 1给赋值 -
数组下标从0开始
-
数组元素的数据类型要一致
-
数组赋值和引用元素,不能超过最大长度,否则包数组索引越界
-
数组赋值与取值,赋值
int[2] = 34,取值int a = int[2]使用变量a接收 -
数组长度,
int len = arr.length -
数组遍历
代码示例:
package com.water.collect.array; import java.util.Arrays; public class ArrayTest { public static void main (String[] args) { // 数组初始化 int [] arr = { 1 , 2 , 3 , 45 }; int [] arr2 = new int [ 5 ]; // 数组赋值与取值 arr2[ 0 ] = 1 ; arr2[ 1 ] = 25 ; int a = arr2[ 0 ]; // 数组下标 = 索引,从0开始 System.out.println( "变量a:" +a); // 超出索引,报错 // arr2[5] = 1; // ArrayIndexOutOfBoundsException,数组索引越界 // 数组长度 System.out.println( "arr长度:" +arr.length); System.out.println( "arr2长度:" +arr2.length); // 数组遍历 for ( int i = 0 ; i < arr.length; i++) { System.out.println(arr[i]); } // Arrays工具格式化输出数组 System.out.println( "格式化输出:" +Arrays.toString(arr)); } }
输出:
变量a: 1 arr长度: 4 arr2长度: 5 1 2 3 45 格式化输出:[ 1 , 2 , 3 , 45 ]
基本特性
| 特性 | 说明 |
|---|---|
| 类型一致 | 所有元素必须是相同数据类型(如 int[] 、 String[] 等) |
| 固定长度 | 初始化后长度不可变 |
| 连续内存 | 元素在内存中是连续存储的 |
| 支持索引访问 | 通过下标访问元素(从 0 开始) |
常用操作
| 操作 | 示例代码 |
|---|---|
| 声明数组 | int[] arr; 或 int arr[]; |
| 初始化数组 | int[] arr = new int[5]; |
| 赋值 | arr[0] = 10; |
| 访问 | System.out.println(arr[0]); |
| 获取长度 | arr.length |
| 遍历 | 使用 for 循环或增强型 for 循环 |
int [] numbers = { 1 , 2 , 3 , 4 , 5 }; for ( int num : numbers) { System.out.println(num); }
数组拷贝
使用native方法Arrays.copyof()进行数组拷贝,数组是非常常用的容器,操作也非常频繁,所以对性能要求高,而又因为长度一开始被限制,所以两个数组直接若存在拷贝迁移,就得需要高效操作,而本地方法调用就是针对性能敏感设计。
多维数组
-
Java 支持多维数组(实际是数组的数组):
int [][] matrix = new int [ 3 ][ 3 ]; matrix[ 0 ][ 0 ] = 1 ;
数组实现原理
底层结构
- 数组是 JVM 内部直接支持的数据结构。
- 在堆内存中分配一块连续的空间来存放元素。
- 数组对象本身包含一个指向该内存块的引用和一个长度字段。
存储机制
- 元素按顺序存储,每个元素占用固定大小的空间。
- 可以通过索引快速定位元素:
address = baseAddress + index * elementSize
类型信息
- 数组对象保存了其元素类型的 Class 对象信息,因此可以进行运行时类型检查(如
instanceof)。
默认初始化值
- 数值类型默认初始化为
0或0.0 - 布尔类型默认为
false - 引用类型默认为
null
数组与集合类的区别(简单对比)
| 特性 | 数组 | ArrayList / List |
|---|---|---|
| 长度 | 固定 | 动态扩容 |
| 类型 | 必须一致 | 支持泛型 |
| 方法支持 | 少(需手动操作) | 丰富(add/remove/contains等) |
| 性能 | 更快(索引访问 O(1)) | 稍慢但灵活 |
| 线程安全 | 否 | Vector 是线程安全的 |
使用场景
1. 存储固定数量的同类数据
-
如配置参数、枚举值、颜色列表等。
String[] colors = { "red" , "green" , "blue" };
2. 高性能需求的场合
-
当需要极致性能时(如游戏开发、高频计算),数组比集合更高效。
int [] buffer = new int [ 1024 ];
3. 接口参数传递
-
某些 API 设计中要求传入数组,如反射调用方法参数、JNI 参数等。
method.invoke(obj, new Object []{arg1, arg2});
4. 数据缓存/缓冲区
-
图像处理、音频处理等场景中作为临时缓冲区。
byte [] buffer = new byte [ 1024 ];
5. 构建其他数据结构的基础
-
栈、队列、堆、图的邻接矩阵表示等都可以基于数组实现。
class Stack { private int [] arr; private int top; public Stack ( int capacity) { arr = new int [capacity]; top = - 1 ; } public void push ( int val) { if (top < arr.length - 1 ) { arr[++top] = val; } } }
6. 排序与查找算法
-
数组是排序算法(如冒泡、快排)和查找算法(如二分查找)的基础结构。
Arrays.sort(arr); // 快速排序 int index = Arrays.binarySearch(arr, target);
数组工具类 java.util.Arrays
Java 提供了丰富的数组操作工具类 Arrays,常用方法包括:
| 方法 | 描述 |
|---|---|
| Arrays.sort(arr) | 排序 |
| Arrays.binarySearch(arr, key) | 二分查找 |
| Arrays.equals(arr1, arr2) | 判断两个数组是否相等 |
| Arrays.fill(arr, value) | 填充数组 |
| Arrays.toString(arr) | 返回数组字符串表示 |
注意事项
| 注意点 | 说明 |
|---|---|
| 不可扩展 | 数组一旦创建,长度不可变,若需扩容,需新建数组并复制 |
| 索引越界 | 下标超出范围会抛出 ArrayIndexOutOfBoundsException |
| 类型安全 | 编译器会在赋值时进行类型检查 |
| 传递是引用 | 修改数组内容会影响所有引用它的变量 |
数组小结
| 场景 | 是否推荐使用数组 | 说明 |
|---|---|---|
| 固定数量数据 | 推荐 | 长度不变,性能高 |
| 需要动态扩容 | 不推荐 | 应使用 ArrayList |
| 高性能计算 | 推荐 | 无额外开销 |
| 存储不同类型 | 不推荐 | 必须统一类型 |
| 接口参数传递 | 推荐 | Java 原生支持 |
| 构建自定义结构 | 推荐 | 如栈、队列等 |
| 排序/查找算法 | 推荐 | 易于实现 |
二、集合框架类图一览
三、List列表
List列表,底层是使用数组存储,数组初始化长度默认为16。初始化时建议都指定所需要的长度,一般是2的多少次方。减少扩容次数。List作为列表容器,就是一个无限长度的数组,属于引用类型,是一种引用数据类型。用得最多的实现类是ArrayList。
List特点:
- 元素有序可重复
- 查询快,操作慢
- 常用于读多写少的场景
- 大小使用
size()方法 - 元素增删改查,
add添加元素,默认添加在最后一位,remove删除元素,但是需要再迭代器iterator中操作;或者使用removeif,普通循环内删除会报错 - 实现类:
ArrayList适合读多写少;LinkList适合读少写多
常用ArrayList
无容量限制,按添加顺序存储,可重复,有下标索引,所以有get/set,常用方法如下:
add,添加一个元素remove,删除一个元素,返回布尔contains,判断是否存在这个元素,地址值判断get,根据索引获取该元素,的地址值(引用类型)set,根据索引设置元素,保存新的地址值,指向新的对象indexof,找出元素的下标addAll,全部添加,同类型容器里面的所有元素size,容器大小,元素数量的多少toArray,转为数组
示例代码:
package com.water.container; import java.util.ArrayList; import java.util.Arrays; import java.util.List; public class Demo { public static void main (String[] args) { //list集合存的是对象的地址值 List<Hero> heroes = new ArrayList <>(); //这种匿名对象,没办法进行引用,应当避免 heroes.add( new Hero ( "bob" , 12.56f )); Hero mike = new Hero ( "Mike" , 15.99f ); heroes.add(mike); for (Hero hero : heroes) { System.out.println(hero.toString()); } Hero judy = mike; judy.name = "jhud" ; //contains方法,判断是的地址值,不是对象的内容 if (heroes.contains(judy)){ System.out.println( "有这个英雄" ); } //删除的是地址值,因此judy所指向的对象成为了无引用对象,等着被GC吧;mike只是这个对象的别名而已 boolean result = heroes.remove(judy); System.out.println( "是否删除了judy:" +result); //再次遍历 for (Hero hero : heroes) { System.out.println(hero.toString()); } //size这个词更形象,代表尺码,空间,容器大小,三维。length,表示线性长度,如数组和字符串,一维。 System.out.println( "容器现在大小:" +heroes.size()); //clear清空容器 heroes.clear(); System.out.println( "容器现在大小:" +heroes.size()); List<Hero> heroList = new ArrayList <>(); Hero a1 = new Hero ( "a1" , 89.9f ); Hero b1 = new Hero ( "b1" , 89.9f ); Hero c1 = new Hero ( "c1" , 89.9f ); Hero d1 = new Hero ( "d1" , 89.9f ); heroList.add(a1); heroList.add(a1); heroList.add(a1); heroList.add(a1); heroList.add(b1); heroList.add(c1); heroList.add(d1); //有序存储,可重复 System.out.println( "heroList的size有多大:" +heroList.size()); //get,获取指定索引的元素 System.out.println(heroList.get( 1 ).toString()); //set,重新设置指定索引的元素,并且返回原来的元素 System.out.println(heroList.set( 1 ,b1).toString()); System.out.println(heroList.get( 1 ).toString()); //addAll,把一个容器里面的元素全部添加到另一个容器里 boolean b = heroes.addAll(heroList); System.out.println( "是否添加全部成功:" +b); for (Hero hero : heroes) { System.out.println(hero.toString()); } //集合转为数组 Object[] heroArr = heroes.toArray(); //数组工具类,已经重写了toString方法,可以直接打印数组 System.out.println(Arrays.toString(heroArr)); } } class Hero { public String name; private float hp; public Hero () {} public Hero (String name, float hp) { this .name = name; this .hp = hp; } public String toString () { return "{name:" +name+ ",hp:" +hp+ "}" ; } }
基本数据类型对应包装类(引用类型)
List<Integer> numList = new ArrayList <>(); //对基本数据类型进行自动装箱和拆箱 numList.add( 1 ); numList.add( 23 ); numList.add( 45 ); numList.add( 67 ); numList.add( 190 ); System.out.println( "numList的size:" +numList.size()); for (Integer item : numList) { System.out.println(item); //直接打印值 } System.out.println(numList.contains( 190 )); //true //让程序这里时正常停止 System.exit( 0 ); --------------- numList的size: 5 1 23 45 67 190 true
三种遍历方式
-
普通for循环
-
增强for循环
-
迭代器循环
package com.water.container; import java.util.ArrayList; import java.util.Iterator; import java.util.List; //三种集合遍历方法 public class IteratorTest { public static void main (String[] args) { //产生5个元素的容器 List<Hero3> list = new ArrayList <>(); for ( int i = 0 ; i < 5 ; i++) { list.add( new Hero3 ( "hero " +i)); } //1、for 循环,有get取得对应元素 System.out.println( "我是for循环遍历!-------------" ); for ( int i = 0 ; i < list.size(); i++) { Hero3 hero3 = list.get(i); System.out.println(hero3.toString()); } System.out.println( "我是迭代器遍历!------------------" ); //2、迭代器iterator,从-1开始判断下个是否有元素,有则移动并取出元素 Iterator<Hero3> iterator = list.iterator(); while (iterator.hasNext()){ Hero3 h = iterator.next(); System.out.println(h.toString()); } System.out.println( "for来搞定迭代器!-----" ); for (Iterator<Hero3> iterator1 = list.iterator();iterator1.hasNext(); ){ Hero3 hero3 = iterator1.next(); System.out.println(hero3.toString()); } System.out.println( "增强for循环来也---------------" ); //3、增强for循环,性能最好,但没办法指定下标,需要配合indexOf for (Hero3 hero3 : list) { System.out.println( "我的下标是:" +list.indexOf(hero3)); System.out.println(hero3.toString()); } } } class Hero3 { public String name; public int age; public Hero3 () {} public Hero3 (String name) { this .name = name; } public String toString () { return name; } }
删除后索引会变
列表删除后,元素重新移动,因此其对应的下标会改变。
-
remove,根据索引删除,返回该元素
-
remove,删除元素,返回布尔
-
使用
removeIf或迭代器内删除List<Integer> ints = new ArrayList <>(); for ( int i = 0 ; i < 100 ; i++) { ints.add(i); } //这里很漂亮,一开始的元素和索引确实是同一个值,但删了一个之后,元素的索引都变了啊!数组就不会,因为固定长度! for (Integer anInt : ints) { System.out.print( "下标是:" +ints.indexOf(anInt)); System.out.print( "元素值为:" +anInt); System.out.println(); } System.out.println( "ints的size:" +ints.size()); System.out.println( "0下标是个啥:" +ints.get( 0 )); for ( int i = 0 ; i < ints.size(); i++) { if (i % 7 == 0 ){ System.out.println( "能进来的i:" +i); //有毒,这里的remove根据索引删除,返回的值不是该索引的值?因为删除后大家的索引都改变了啊!!! Integer remove = ints.remove(i); System.out.println( "删除后还能获得对应i的元素?" +ints.get(i)); System.out.print( "删除了:" +remove+ "、" ); } } System.out.println( "ints的size:" +ints.size()); for (Integer anInt : ints) { System.out.println(anInt); } System.exit( 0 );
数组因为固定长度且有默认值,没有所谓的删除,更改值就是在其下标对应的位置重新赋值即可。
//数组固定长度,并且有默认值 int [] arr = new int [ 10 ]; //默认值为0 System.out.println(Arrays.toString(arr)); for ( int i = 0 ; i < 10 ; i++) { arr[i] = i; } arr[ 8 ] = 99 ; System.out.println(Arrays.toString(arr)); //小技巧,让程序正常停止运行! System.exit( 0 );
能力超群LinkedList
链表,具有列表、队列、双端队列的能力,十分强大。实现接口,继承父类如下类图:
-
实现了
List接口,Deque接口,Queue接口 -
数组像电影院座位,固定区域,标记好编码,有初始值,
ArrayList -
LinkedList,像手链,每颗珠子,只管前后即可,所以可以前后增加元素,addFirst,addLast -
因为实现了队列接口,所以先进先出,也是ok的,
peek、pull,pop -
同理,也可以实现栈的数据结构,先进后出
示例代码:
import java.util.Iterator; import java.util.LinkedList; import java.util.List; public class LinkedListTest { public static void main (String[] args) { LinkedList<Student> linkList = new LinkedList <>(); //创建5个学生 for ( int i = 0 ; i < 5 ; i++) { linkList.add( new Student ( "mike " +i)); } //增强for循环遍历 for (Student student : linkList) { System.out.println(student); } //前面加 linkList.addFirst( new Student ( "bob" )); //后面加 linkList.addLast( new Student ( "mary" )); //迭代遍历 for (Iterator<Student> iterator = linkList.iterator();iterator.hasNext();){ Student s = iterator.next(); System.out.println(s); } //获取第一个 System.out.println(linkList.getFirst()); //获取最后一个 System.out.println(linkList.getLast()); //删除第一个 System.out.println(linkList.removeFirst()); //删除最后一个 System.out.println(linkList.removeLast()); //取出会导致学生被删除 System.out.println(linkList); System.out.println( "----------queue------------------" ); /*2、还实现了Queue队列,先进先出*/ //查看第一个 System.out.println(linkList.peek()); //取出第一个 System.out.println(linkList.poll()); //添加最后一个 System.out.println(linkList.offer( new Student ( "water" ))); System.out.println(linkList); System.out.println( "-----------stack---------------" ); /*3、也可以实现先进后出的stack栈*/ Stack myStack = new MyStack (); for ( int i = 0 ; i < 5 ; i++) { myStack.push( new Student ( "stack " +i)); } System.out.println(myStack.toString()); //查看栈顶 System.out.println(myStack.peek()); //取出栈顶 System.out.println(myStack.pop()); //容器大小,若pop出一个,就会容量-1,所以遍历前,先用变量存初始容量 System.out.println(myStack.size()); //弹出遍历,不要直接用myStack.size(),否则只能弹出部分 int size = myStack.size(); for ( int i = 0 ; i < size; i++) { System.out.println( "第" +i+ "次" ); System.out.println(myStack.pop()); } } } class Student { public String name; public Integer age; public Student () {} public Student (String name) { this .name = name; } //重写该方法后,打印对象时会自己调用输出,不必显式toString() public String toString () { return this .name; } } interface Stack { public void push (Student s) ; public Student peek () ; public Student pop () ; public int size () ; } /*Stack实现类*/ class MyStack implements Stack { LinkedList<Student> list = new LinkedList <>(); @Override public void push (Student s) { list.addLast(s); } @Override public int size () { return list.size(); } @Override public Student peek () { return list.getLast(); } @Override public Student pop () { return list.removeLast(); } }
内部节点类Node
这就是为啥,一个元素其实有三部分组成:前+数据本身+后;这个对象,本身就套娃。链表每个元素,使用如下内部类作为包装,item是自己,next是下一个节点的引用,prev是上一个引用,这不就串联起来了么。
private static class Node <E> { E item; Node<E> next; Node<E> prev; Node(Node<E> prev, E element, Node<E> next) { this .item = element; this .next = next; this .prev = prev; } }
二叉树
实现二叉树
package com.water.container; import java.util.ArrayList; import java.util.List; public class NodeTest { public static void main (String[] args) { int [] nums = { 34 , 89 , 12 , 67 , 99 , 5 , 7 , 77 }; Node node = new Node (); for ( int num : nums) { node.add(num); } System.out.println(node.getAll()); } } class Node { public Node leftNode; public Node rightNode; public Object value; public void add (Object v) { //判断是否有值 if (value == null ){ value = v; } else { //有,比较大小,比较小,放左边,且添加 if ((Integer)value - (Integer)v >= 0 ){ if (leftNode == null ) leftNode = new Node (); leftNode.add(v); } else { //比较大,放右边,且添加 if (rightNode == null ) rightNode = new Node (); rightNode.add(v); } } } //中序遍历所有结果 public List<Object> getAll () { List<Object> list = new ArrayList <>(); //左节点遍历结果 if ( null != leftNode) list.addAll(leftNode.getAll()); //中间节点遍历结果 list.add(value); //右节点遍历结果 if ( null != rightNode) list.addAll(rightNode.getAll()); return list; } }
三种方法排序
实践代码:
package com.water.container; import java.util.Arrays; import java.util.List; public class CompareTest { public static void main (String[] args) { int [] nums = new int [ 100000 ]; for ( int i = 0 ; i < 100000 ; i++) { nums[i] = ( int )Math.round(Math.random()* 90000 + 10000 ); } // System.out.println(Arrays.toString(nums)); //复制三份同样的数组 int [] nums1 = Arrays.copyOf(nums, nums.length); int [] nums2 = Arrays.copyOf(nums, nums.length); int [] nums3 = Arrays.copyOf(nums, nums.length); System.out.println( "三个数组的地址:" +nums1+ "," +nums2+ "," +nums3); System.out.println( "内容是否一致:" +Arrays.equals(nums1,nums2)); System.out.println( "内容是否一致:" +Arrays.equals(nums2,nums3)); sortMethod( new SelectSort (nums1), "选择排序" ); sortMethod( new MaoPaoSort (nums2), "冒泡排序" ); sortMethod( new NodeSort (nums3), "二叉树排序" ); /*SelectSort selectSort = new SelectSort(nums); selectSort.sort(); System.out.println(Arrays.toString(selectSort.values()));*/ /*MaoPaoSort maoPaoSort = new MaoPaoSort(nums); maoPaoSort.sort(); System.out.println(Arrays.toString(maoPaoSort.values()));*/ /* NodeSort nodeSort = new NodeSort(nums); nodeSort.sort(); System.out.println(Arrays.toString(nodeSort.values()));*/ } //封装统一操作步骤,用时多长 private static void sortMethod (Sort sort,String name) { System.out.println( "--------" +name+ "排序开始-----------" ); long start = System.currentTimeMillis(); sort.sort(); int [] values = sort.values(); // System.out.println(Arrays.toString(values)); long time = System.currentTimeMillis()-start; System.out.println( "---------用时" +time+ "ms" ); } //共同接口,定义统一方法,实现类各自实现 interface Sort { void sort () ; int [] values(); } //选择替换方法 static class SelectSort implements Sort { int [] numbers; public SelectSort () {} public SelectSort ( int [] numbers) { this .numbers = numbers; } @Override public void sort () { int temp ; //外层控制轮数 for ( int i = 0 ; i < numbers.length- 1 ; i++) { //内层,两两比较,前面的(左边)数会比较小,左边确定次序 for ( int j = i+ 1 ; j < numbers.length; j++) { if (numbers[i] > numbers[j]){ temp = numbers[i]; numbers[i] = numbers[j]; numbers[j] = temp; } } } } @Override public int [] values() { return numbers; } } static class MaoPaoSort implements Sort { int [] numbers; public MaoPaoSort ( int [] numbers) { this .numbers = numbers; } @Override public void sort () { int temp; //外层,遍历的轮数,每轮都会比较出一个最值 for ( int i = 0 ; i < numbers.length; i++) { //每轮比较的次数,发现是逐步减少的,因为前面已经排好了i个最值,右边确定次序 for ( int j = 0 ; j < numbers.length-i- 1 ; j++) { if (numbers[j]>numbers[j+ 1 ]){ temp = numbers[j]; numbers[j] = numbers[j+ 1 ]; numbers[j+ 1 ] = temp; } } } } @Override public int [] values() { return numbers; } } static class NodeSort implements Sort { int [] numbers; Node node; public NodeSort ( int [] numbers) { this .numbers = numbers; node = new Node (); } @Override public void sort () { for ( int number : numbers) { node.add(number); } } @Override public int [] values() { List<Object> all = node.getAll(); int [] nums = new int [all.size()]; for ( int i = 0 ; i < nums.length; i++) { nums[i] = Integer.parseInt(all.get(i).toString()); } return nums; } } }
比较结果:
类比生活
生活中,大大小小的瓶子,就是容器,装东西,空间,范围。世界是三维的,也就是容器。时间,熵增,让一切变得更无序(只因为无序的概率比有序的概率大)想要保持形状,得外力做功,减熵。
链表,队列,栈,都很像一条绳子,顺藤摸瓜,按图索骥,佛珠,手链,联系上下文,定位问题。所以这个结构,从生活中抽象出来,很明显就是不必借助下标,索引,就能存储东西。联想记忆,就是如此。
记忆宫殿,借助熟悉的东西=下标=1,2,3.。。。从而将需要记忆的,陌生的东西,联结在一起。一栋楼,有很多层,每层有很多房间,门牌号,就是其地址编码,数字-字母-特殊符号,构成。
四、Set集合
Set集合,无序唯一,其唯一性可以用于业务上的去重操作。
使用要点
-
- 唯一性 :
Set集合不允许重复的元素。添加重复元素时,会自动忽略。
- 唯一性 :
-
- 无序性 :
Set不保证元素的插入顺序,例如HashSet。
- 无序性 :
-
- 实现类选择:
-
HashSet:基于哈希表实现,查询速度快,但不保证顺序。 -
LinkedHashSet:维护插入顺序,性能略逊于HashSet。 -
TreeSet:基于红黑树实现,可以按自然顺序或自定义顺序排序。 -
- 常用方法:
-
add(element):添加元素。 -
remove(element):移除指定元素。 -
contains(element):判断是否包含某个元素。 -
size():返回集合中元素的数量。 -
isEmpty():判断集合是否为空。 -
- 遍历方式:
-
使用增强型
for循环。 -
使用迭代器
Iterator。 -
Java 8 及以上可使用
forEach方法。 -
- 线程安全:
-
Set的实现类(如HashSet)不是线程安全的。如果需要多线程环境下使用,可以使用Collections.synchronizedSet或者CopyOnWriteArraySet。 -
- 应用场景:
-
去重数据存储。
-
快速查找是否存在某个元素。
-
需要有序的场景下选择
LinkedHashSet或TreeSet。 -
- 注意事项:
-
如果使用自定义对象作为元素,需要正确重写
equals()和hashCode()方法(针对HashSet和LinkedHashSet)。 -
TreeSet要求元素必须是可比较的,或者提供自定义的Comparator。
实现唯一性的原理
基于HashMap的key实现,本质是使用了HashMap的key特性。从源码可以看出,常用Set实现类HashSet是一个value固定值,key不同的HashMap。
构造方法
public class HashSet <E> extends AbstractSet <E> implements Set <E>, Cloneable, java.io.Serializable { @java .io.Serial static final long serialVersionUID = - 5024744406713321676L ; transient HashMap<E,Object> map; // Dummy value to associate with an Object in the backing Map static final Object PRESENT = new Object (); /** * Constructs a new, empty set; the backing { @code HashMap} instance has * default initial capacity (16) and load factor (0.75). */ public HashSet () { map = new HashMap <>(); }
添加方法
public boolean add (E e) { return map.put(e, PRESENT)== null ; }
1. HashSet
-
底层结构:基于
HashMap实现。 -
唯一性原理:
-
使用对象的
hashCode()方法计算哈希值,决定存储位置。 -
如果两个对象的
hashCode()相同,则调用equals()方法进一步比较内容。 -
如果
equals()返回true,则认为是重复元素,不会添加到集合中。 -
要求:自定义类必须重写
hashCode()和equals()方法,以确保正确判断对象是否重复。
2. LinkedHashSet
- 底层结构:继承自
HashSet,内部使用双向链表维护插入顺序。 - 唯一性原理:
- 与
HashSet一致,也是通过hashCode()和equals()方法来判断重复。 - 唯一性由
HashSet提供,顺序由链表维护。
3. TreeSet
-
底层结构:基于
TreeMap实现,使用红黑树。 -
唯一性原理:
-
不依赖
hashCode()和equals(),而是通过比较器 (Comparable或Comparator)来判断元素是否重复。 -
插入元素时,会根据
compareTo()或compare()的返回值判断是否相同(返回 0 表示重复)。 -
要求:元素必须实现
Comparable接口,或者在创建TreeSet时提供一个Comparator。
| 实现类 | 判断唯一性的依据 | 是否需要重写方法 |
|---|---|---|
| HashSet | hashCode() + equals() | 是 ( hashCode , equals ) |
| LinkedHashSet | hashCode() + equals() | 是 ( hashCode , equals ) |
| TreeSet | compareTo() / Comparator | 是 ( Comparable 或提供 Comparator ) |
示例代码
// HashSet 示例 Set<String> hashSet = new HashSet <>(); hashSet.add( "A" ); hashSet.add( "A" ); // 重复元素不会被添加 System.out.println(hashSet); // 输出: [A] // TreeSet 示例 Set<Integer> treeSet = new TreeSet <>(); treeSet.add( 1 ); treeSet.add( 1 ); // 重复元素不会被添加 System.out.println(treeSet); // 输出: [1]
保证唯一性的注意事项
- 如果使用自定义类作为元素,未正确重写
hashCode()和equals()方法,可能导致HashSet无法识别重复对象。 - 对于
TreeSet,如果元素没有实现Comparable接口或未提供Comparator,插入时会抛出ClassCastException。 - 高并发支持,可考虑使用线程安全的
CopyOnWriteArraySet或通过Collections.synchronizedSet()包装。
基于唯一性的使用场景
Set 集合由于其元素唯一性的特性,在实际开发中有很多典型的应用场景:
1. 去重处理
-
用途:将重复数据去除,保留唯一值。
-
示例:
-
从列表中提取不重复的用户ID。
-
过滤日志中重复的访问IP。
List<String> list = Arrays.asList( "a" , "b" , "a" , "c" ); Set<String> uniqueSet = new HashSet <>(list); // ["a", "b", "c"]
2. 快速查找是否存在某个元素
-
用途:判断某个元素是否存在于集合中,效率高(时间复杂度接近 O(1))。
-
适用类:
HashSet、LinkedHashSet -
示例:
-
判断用户输入的关键字是否在敏感词库中。
-
检查某个用户名是否已被注册。
Set<String> bannedWords = Set.of( "bad" , "spam" , "hack" ); if (bannedWords.contains(input)) { System.out.println( "包含非法内容!" ); }
3. 集合运算
-
用途:进行交集、并集、差集等操作。
-
常用方式:结合
retainAll()、addAll()、removeAll()等方法实现。 -
示例:
-
用户标签匹配。
-
权限控制中的权限交集判断。
Set<Integer> set1 = new HashSet <>(Arrays.asList( 1 , 2 , 3 )); Set<Integer> set2 = new HashSet <>(Arrays.asList( 2 , 3 , 4 )); set1.retainAll(set2); // 交集 [2, 3]
** 4. 维护插入顺序或排序顺序**
-
用途:需要保持插入顺序或按自然/自定义顺序排列的场景。
-
适用类:
-
LinkedHashSet:保持插入顺序。 -
TreeSet:按自然顺序或自定义顺序排序。Set<String> orderedSet = new LinkedHashSet <>(); orderedSet.add( "first" ); orderedSet.add( "second" ); // 插入顺序会被保留 Set<Integer> sortedSet = new TreeSet <>(); sortedSet.add( 3 ); sortedSet.add( 1 ); // 自动排序为 [1, 3]
-
- 作为 Map 的 Key 集合
-
用途 :获取
Map中所有键的唯一集合。 -
示例:
Map<String, Integer> map = new HashMap <>(); Set<String> keys = map.keySet(); // 获取所有键的唯一集合
-
- 状态标记 / 枚举去重
-
用途:记录某些状态、枚举值,避免重复。
-
示例:
-
记录已执行的任务类型。
-
存储用户拥有的角色(如 admin、user、guest)。
Set<String> roles = new HashSet <>(); roles.add( "admin" ); roles.add( "user" );
-
- 缓存去重 ID 或对象
-
用途:在缓存中存储唯一标识,防止重复加载。
-
示例:
-
缓存已加载的文章ID,避免重复查询数据库。
Set<Long> loadedArticleIds = new HashSet <>(); if (!loadedArticleIds.contains(articleId)) { // 加载文章 loadedArticleIds.add(articleId); }
Set小结
| 使用场景 | 推荐实现类 | 特点说明 |
|---|---|---|
| 去重 | HashSet | 快速唯一性判断 |
| 插入顺序保留 | LinkedHashSet | 保持添加顺序 |
| 排序集合 | TreeSet | 支持自然排序或自定义排序 |
| 快速存在判断 | HashSet | 时间复杂度低 |
| 集合运算 | HashSet / TreeSet | 支持交集、并集、差集等操作 |
| 状态/标签管理 | HashSet | 轻量级状态集合 |
五、Map映射
Map接口中,其实现类HashMap使用最多。k-v键值对,映射,在开发中很常见。
使用要点
基本特性
- 存储 键值对(Key-Value),每个键唯一,值可以重复。
- 不保证顺序(具体实现类决定)。
核心方法
| 方法名 | 描述 |
|---|---|
| put(K key, V value) | 添加或更新键值对 |
| get(Object key) | 获取指定键对应的值 |
| remove(Object key) | 移除指定键 |
| containsKey(Object key) | 判断是否包含指定键 |
| containsValue(Object value) | 判断是否包含指定值 |
| size() | 返回键值对数量 |
| isEmpty() | 判断是否为空 |
| keySet() | 获取所有键的集合( Set ) |
| values() | 获取所有值的集合( Collection ) |
| entrySet() | 获取键值对集合( Set> ) |
遍历方式
Map<String, Integer> map = new HashMap <>(); map.put( "a" , 1 ); map.put( "b" , 2 ); // 方式1:遍历 entrySet for (Map.Entry<String, Integer> entry : map.entrySet()) { System.out.println(entry.getKey() + " -> " + entry.getValue()); } // 方式2:Java 8+ forEach map.forEach((k, v) -> System.out.println(k + " -> " + v));
HashMap 实现原理(JDK 8+)
底层结构
- 数组 + 链表 + 红黑树(链表长度 > 8 时转为红黑树)。
- 每个数组元素是一个桶(bucket),存放
Node<K,V>类型的数据。
核心机制
-
- 哈希计算:
-
使用
key.hashCode()计算哈希值。 -
经过扰动函数(
hash())处理后定位到数组索引。 -
- 冲突解决:
-
同一位置多个键使用链表存储。
-
当链表长度超过阈值(默认 8),转换为红黑树,提高查找效率。
-
- 扩容机制:
-
默认初始容量为 16,负载因子 0.75。
-
当元素数量超过
capacity * loadFactor时,进行扩容(2倍)并重新哈希(rehash)。
线程安全
HashMap不是线程安全的。- 多线程环境下推荐使用:
ConcurrentHashMap- 或使用
Collections.synchronizedMap(map)
实现类对比
| 实现类 | 是否有序 | 线程安全 | 特点说明 |
|---|---|---|---|
| HashMap | 无序 | ❌ | 最常用,性能高 |
| LinkedHashMap | 保持插入/访问顺序 | ❌ | 支持按插入顺序或访问顺序遍历 |
| TreeMap | 按 Key 自然顺序排序 | ❌ | 支持自定义比较器,适合需要排序的场景 |
| Hashtable | 无序 | ✅ | 已过时,建议用 ConcurrentHashMap 替代 |
| ConcurrentHashMap | 无序 | ✅ | 线程安全,高性能并发访问 |
使用场景
1. 缓存数据
-
存储临时键值对数据,如用户登录信息、配置项等。
Map<String, User> userCache = new HashMap <>(); userCache.put(token, user);
2. 统计计数
-
统计字符串出现次数、IP访问次数等。
Map<String, Integer> wordCount = new HashMap <>(); words.forEach(word -> wordCount.put(word, wordCount.getOrDefault(word, 0 ) + 1 ));
3. 构建字典/映射关系
-
如将状态码映射为描述、国家代码对应国家名称。
Map<Integer, String> statusMap = new HashMap <>(); statusMap.put( 200 , "OK" ); statusMap.put( 404 , "Not Found" );
4. 快速查找与判断是否存在
-
判断某个键是否存在于集合中,效率高(O(1))。
if (map.containsKey(userId)) { // 用户存在 }
5. 作为其他结构的基础
Set内部实现基于HashMap。ConcurrentHashMap是构建线程安全缓存、注册中心等的基础组件。
注意事项
| 注意事项 | 说明 |
|---|---|
| 键必须重写 hashCode() 和 equals() | 否则可能导致相同逻辑对象无法正确识别 |
| 不要使用可变对象作为 Key | 若 Key 在放入 Map 后发生改变,可能造成无法获取该值 |
| Null 键和值 | HashMap 允许一个 null 键和多个 null 值; TreeMap 不允许 null 键 |
| 扩容影响性能 | 如果知道大致数据量,建议初始化时指定容量,避免频繁扩容 |
| 多线程下慎用 | 使用 ConcurrentHashMap 更安全高效 |
分析不可变对象作为key
在使用 Map 时,"不要使用可变对象作为 Key" 是一个非常重要的最佳实践。这里的 "可变对象" 指的是:
在放入
Map后,其内容(状态)可能会被修改的对象。
什么是可变对象?
一个对象如果在其创建后,内部字段的值可以被修改 ,则称为可变对象 。相对地,不可变对象 是指一旦创建后,其内部状态就不能再改变(如 String、Integer 等)。
为什么不能用可变对象作为 Map 的 Key?
HashMap 等基于哈希的 Map 实现,在存储键值对时会根据 Key 的 hashCode()值计算其在数组中的位置(桶索引)。当 Key 被修改后:
hashCode()可能发生变化;- 导致后续无法通过
get()正确找到该 Key 对应的值; - 或者即使找到了,
equals()也可能返回false,从而造成数据"丢失"。
示例说明
定义一个可变类:
public class Person { private String name; public Person (String name) { this .name = name; } public String getName () { return name; } public void setName (String name) { this .name = name; } @Override public int hashCode () { return name.hashCode(); } @Override public boolean equals (Object obj) { if ( this == obj) return true ; if (!(obj instanceof Person)) return false ; return name.equals(((Person) obj).name); } }
使用这个类作为 Key:
Map<Person, String> map = new HashMap <>(); Person key = new Person ( "Alice" ); map.put(key, "Developer" ); // 修改 key 对象的状态 key.setName( "Bob" ); // 尝试获取值 System.out.println(map.get(key)); // 输出: null
问题分析:
-
- 初始插入时,
key的name = "Alice",所以hashCode()是"Alice".hashCode()。
- 初始插入时,
-
- 修改为
"Bob"后,再次调用get(key)会使用"Bob".hashCode()计算桶位置。
- 修改为
-
- 因为之前是按
"Alice"存储的,此时去别的桶找,自然找不到,返回null。
- 因为之前是按
这就是所谓的"Key 被改了之后找不到值"的问题。
正确做法:
使用不可变对象作为 Key,比如使用 String、Integer、枚举等:
Map<String, String> map = new HashMap <>(); map.put( "Alice" , "Developer" ); // 即使你重新赋值,原 Key 不会被修改 String key = "Alice" ; System.out.println(map.get(key)); // 输出: Developer
或者自定义类时设计为不可变类:
public final class Person { private final String name; public Person (String name) { this .name = name; } public String getName () { return name; } @Override public int hashCode () { return name.hashCode(); } @Override public boolean equals (Object obj) { if ( this == obj) return true ; if (!(obj instanceof Person)) return false ; return name.equals(((Person) obj).name); } }
推荐不可变对象
| 类型 | 是否推荐作为 Key | 说明 |
|---|---|---|
| String | 推荐 | JDK 内置不可变类 |
| Integer , Long | 推荐 | 不可变包装类型 |
| 枚举类型 | 推荐 | 不可变且高效 |
| 自定义不可变类 | 推荐 | 需要重写 hashCode() 和 equals() |
| 自定义可变类 | 不推荐 | 修改后可能导致 Key 失效或无法查找 |
如果你必须使用自定义对象作为 Key,请确保它是不可变的 。如果确实需要使用可变对象,建议每次修改后重新 put 进 Map,或者考虑使用其他结构(如 Map<Id, Object>)。
Map小结
| 场景 | 推荐实现类 | 说明 |
|---|---|---|
| 快速查找、缓存 | HashMap | 性能高,适合大多数场景 |
| 保持插入顺序 | LinkedHashMap | 适用于日志记录、LRU 缓存等 |
| 需要排序的键 | TreeMap | 按自然顺序或自定义顺序排列 |
| 多线程环境 | ConcurrentHashMap | 线程安全且高效 |
| 状态码映射 | HashMap | 快速查询状态描述 |
| 统计计数 | HashMap | 可结合 computeIfAbsent() 等方法 |
六、总结
List列表,Set集合,Map映射,Java集合框架是我们处理数据时经常使用的技术,熟悉其实现原理,注意实现,使用场景,有助于我们正确高效处理数据。日常开发,可以偶尔回顾本文,比对一下是否有踩坑。
同时,LinkedList一些队列、栈的用法比较少见,但理解其实现原理,对于数据结构Queue、Stack有更深的理解。HashSet无序集合,对于一些去重,交集等操作有重大意义。HashMap的key,常用String、Integer、Long等这些基础类型的包装类型,觉得习以为常,主要因为他们都是不可变对象才没出问题,而使用了可变对象作为key,则会发生问题。
总之,有些集合实践我们是无意中规避了风险和坑,但是我们不能侥幸未来不会犯错,最好的解决方法就是时不时复习,了解其原理,比对比对自己的代码,审查使用方式,才能真正掌握Java集合框架的精髓。
当然这里没有过多涉及并发集合框架的原理讲解,本质也是通过一些锁、非锁机制去控制并发,在往后并发编程的学习中,可以深入研究体会。