2024.11.06 周三
上午没有课在炒股,持仓的科创芯片etf和标普500etf大涨,但是踏空了没有高位卖出,下午上课(有课堂小测必须去),后面一会儿学习一会儿玩,晚上跑了会步(不锻炼感觉身体要废了)
总结一下今天学习的内容:
List的数据结构、线程安全相关知识、回溯算法复习、堆排序复习(优先队列的学习)
明天目标:Java并行开发相关知识、锁相关知识、项目进展、算法刷几道简单题熟悉Java语法和数据结构相关api
八股
List
Arraylist和LinkedList的区别,哪个集合是线程安全的?
List是有序的Collection,使用此接口能够精确的控制每个元素的插入位置,用户能根据索引访问List中的元素,常用的实现List的类有LinkedList,ArrayList,Vector,Stack。
- 底层数据结构不同:ArrayList使用数组实现,通过索引进行快速访问元素。LinkedList使用链表实现,通过节点之间的指针进行元素的访问和操作。
- 插入和删除操作的效率不同:ArrayList在尾部的插入和删除操作效率较高,但在中间或开头的插入和删除操作效率较低,需要移动元素。LinkedList在任意位置的插入和删除操作效率都比较高,因为只需要调整节点之间的指针,但是LinkedList是不支持随机访问的,所以除了头结点外插入和删除的时间复杂度都是0(n),效率也不是很高所以LinkedList基本没人用。
- 随机访问的效率不同:ArrayList支持通过索引进行快速随机访问,时间复杂度为O(1)。LinkedList需要从头或尾开始遍历链表,时间复杂度为O(n)。
- 空间占用:ArrayList在创建时需要分配一段连续的内存空间,因此会占用较大的空间。LinkedList每个节点只需要存储元素和指针,因此相对较小。
- 使用场景:ArrayList适用于频繁随机访问和尾部的插入删除操作,而LinkedList适用于频繁的中间插入删除操作和不需要随机访问的场景。
- 线程安全:这两个集合都不是线程安全的,Vector是线程安全的
ArrayList的扩容机制说一下
ArrayList在添加元素时,如果当前元素个数已经达到了内部数组的容量上限,就会触发扩容操作。ArrayList的扩容操作主要包括以下几个步骤:
- 计算新的容量:一般情况下,新的容量会扩大为原容量的1.5倍(在JDK 10之后,扩容策略做了调整),然后检查是否超过了最大容量限制。
- 创建新的数组:根据计算得到的新容量,创建一个新的更大的数组。
- 将元素复制:将原来数组中的元素逐个复制到新数组中。
- 更新引用:将ArrayList内部指向原数组的引用指向新数组。
- 完成扩容:扩容完成后,可以继续添加新元素。
之所以扩容是 1.5 倍,是因为 1.5 可以充分利用移位操作,减少浮点数或者运算时间和运算次数。
// 新容量计算
// >>1 属于位运算,指右移一位,相当于将 OldCapacity 除以2
// 110 >> 1 = 011 [ 2^n -> 2^(n-1) ]
int newCapacity = oldCapacity + (oldCapacity >> 1);
为什么ArrayList不是线程安全的,具体来说是哪里不安全?
在高并发添加数据下,ArrayList会暴露三个问题;
- 部分值为null(我们并没有add null进去)
- 索引越界异常
- size与我们add的数量不符
ArrayList中add增加元素的代码如下:
/**
E e在这里表示一个通用的泛型参数,没有指定任何边界
若public <E extends Number> boolean add(E e)
则表示这是一个有界泛型参数,E 必须是 Number 类或其子类的实例。
*/
public boolean add(E e) {
ensureCapacityInternal(size + 1); // 判断长度->扩容
elementData[size++] = e;
return true;
}
- 部分值为null:当线程1走到了扩容那里发现当前size是9,而数组容量是10,所以不用扩容,这时候cpu让出执行权,线程2也进来了,发现size是9,而数组容量是10,所以不用扩容,这时候线程1继续执行,将数组下标索引为9的位置set值了,还没有来得及执行size++,这时候线程2也来执行了,又把数组下标索引为9的位置set了一遍,这时候两个先后进行size++,导致下标索引10的地方就为null了。
总结:即多线程读取到同一个size,不用扩容时,在同一个size位置set值,又将size++执行两次,此时原来size+1的位置没有set值就变成null。
- 索引越界异常:线程1走到扩容那里发现当前size是9,数组容量是10不用扩容,cpu让出执行权,线程2也发现不用扩容,这时候数组的容量就是10,而线程1 set完之后size++,这时候线程2再进来size就是10,数组的大小只有10,而你要设置下标索引为10的就会越界(数组的下标索引从0开始)。
总结:线程1和线程2同时执行判断是否要扩容,当线程1 set值时size++,线程2再set值的时候发现size已超过边界。
- size与我们add的数量不符:这个基本上每次都会发生,这个理解起来也很简单,因为size++本身就不是原子操作,可以分为三步:获取size的值,将size的值加1,将新的size值覆盖掉原来的,线程1和线程2拿到一样的size值加完了同时覆盖,就会导致一次没有加上,所以肯定不会与我们add的数量保持一致的;
size++不属于原子操作:
1.获取size的值;
2.将size的值+1;
3.将新的size值覆盖掉原来的
因此若多个线程拿到一样的size值同时覆盖就会导致一次没+上,与add的数量就不一致
把ArrayList变成线程安全有哪些方法?
- 使用
Collections
类的synchronizedList
方法将ArrayList包装成线程安全的List:
List<String> synchronizedList = Collections.synchronizedList(arrayList);
- 使用
CopyOnWriteArrayList
类代替ArrayList,它是一个线程安全的List实现:
CopyOnWriteArrayList<String> copyOnWriteArrayList = new CopyOnWriteArrayList<>(arrayList)
- 使用
Vector
类代替ArrayList,Vector是线程安全的List实现:
Vector<String> vector = new Vector<>(arrayList);
CopyonWriteArraylist是如何实现线程安全的?
CopyOnWriteArrayList
底层也是通过一个数组保存数据,使用volatile
关键字修饰数组,保证当前线程对数组对象重新赋值后,其他线程可以及时感知到。
/**
transient表示array是一个瞬态变量,不会被序列化。
volatile表示array是一个易失变量,在多线程环境中,
该字段确保对变量的读写操作直接在主内存中进行,而不是在本地线程缓存中。
*/
private transient volatile Object[] array;
// 在写入操作时,加了一把互斥锁ReentrantLock以保证线程安全。
public boolean add(E e) {
//获取锁
final ReentrantLock lock = this.lock;
//加锁
lock.lock();
try {
//获取到当前List集合保存数据的数组
Object[] elements = getArray();
//获取该数组的长度(这是一个伏笔,同时len也是新数组的最后一个元素的索引值)
int len = elements.length;
//将当前数组拷贝一份的同时,让其长度加1
Object[] newElements = Arrays.copyOf(elements, len + 1);
//将加入的元素放在新数组最后一位,len不是旧数组长度吗,为什么现在用它当成新数组的最后一个元素的下标?
//因为旧数组长度在新数组中表示索引位置时是最后一个元素(空)的索引值
newElements[len] = e;
//替换引用,将数组的引用指向给新数组的地址
setArray(newElements);
return true;
} finally {
//释放锁
lock.unlock();
}
}
// 读是没有加锁的,所以读是一直都能读
public E get(int index) {
return get(getArray(), index);
}
在执行替换地址操作之前,读取的是老数组的数据,数据是有效数据;
执行替换地址操作之后,读取的是新数组的数据,同样也是有效数据,
而且使用该方式能比读写都加锁要更加的效率。
算法
347.前k个高频元素(堆排序-优先队列)
78.子集(回溯算法)
136.只出现一次的数字(异或运算)
1. 任何数和 0 做异或运算,结果仍然是原来的数,即 a⊕0=a
2. 任何数和其自身做异或运算,结果是 0,即 a⊕a=0
3. 异或运算满足交换律和结合律,即 a⊕b⊕a=b⊕a⊕a=b⊕(a⊕a)=b⊕0=b
优先队列(PriorityQueue)
在Java中,PriorityQueue
类就是基于优先级堆实现的。当向PriorityQueue
中添加元素时,元素会被插入到合适的位置以保持堆性质。当从PriorityQueue
中移除元素时,总是移除优先级最高的元素(对于最小堆)或优先级最低的元素(对于最大堆),并重新调整堆以保持堆性质。
- 基于优先级堆:
PriorityQueue
使用一个优先级堆来存储元素,这使得它能够高效地添加和移除元素 - 无界队列:
PriorityQueue
是一个无界队列,这意味着它没有固定的容量限制。但是,如果队列为空,PriorityQueue
的容量将动态增长。 - 常用方法:
- add(E e):将元素e添加到队列中。
- offer(E e):将元素e添加到队列中,与add方法相同。
- poll():移除并返回队列中优先级最高的元素,如果队列为空,则返回null。
- peek():返回队列中优先级最高的元素,但不移除它,如果队列为空,则返回null。
- size():返回队列中元素的数量。
// 创建一个PriorityQueue,用于存储整数数组,并按照数组中第二个元素的大小进行排序
PriorityQueue<int[]> queue = new PriorityQueue<int[]>(new Comparator<int[]>(){
public int compare(int[] m, int[] n){
// 比较两个数组中第二个元素的大小,并返回它们的差值
return m[1] - n[1];
}
} );
回溯算法(78.子集)
- 非常标准的回溯算法
class Solution {
List<List<Integer>> res = new ArrayList<List<Integer>>();
int n;
public List<List<Integer>> subsets(int[] nums) {
n = nums.length;
for (int k = 0; k <= n; k++){
backtrace(0, k, new ArrayList<Integer>(), nums);
}
return res;
}
/**
start是子集第一个数在nums中最早可以出现的位置
k是当前需要构造子集的长度
cur储存当前正在构造的子集
backtrack运行一次能够构造所有长度为k的子集
*/
public void backtrace(int start, int k, ArrayList<Integer> cur, int[] nums){
// 回溯的终止条件
if (k == 0){
res.add(new ArrayList<Integer>(cur));
return;
}
for (int i = start; i < n; i++){
cur.add(nums[i]); // 当前步骤要做的
backtrace(i + 1, k - 1, cur, nums); // 开始下一步
cur.remove(cur.size() - 1); // 回溯到上一步
}
}
}
项目
项目今天没有进展,有点累,先歇会,明天主要搞项目。