Hello, Hello~ 亲爱的朋友们👋👋,这里是E绵绵呀✍️✍️。 如果你喜欢这篇文章,请别吝啬你的点赞❤️❤️和收藏📖📖。如果你对我的内容感兴趣,记得关注我👀👀以便不错过每一篇精彩。 当然,如果在阅读中发现任何问题或疑问,我非常欢迎你在评论区留言指正🗨️🗨️。让我们共同努力,一起进步! 加油,一起CHIN UP!💪💪
进阶知识我们只需要了解即可,有个认识,因为它工作中基本不用,但面试中可能考
这是对于锁的分类。对于该知识我们只需了解即可。 因为我们不自己去实现锁,只是单纯使用锁,所以无需重点理解,只要简单知道。
乐观锁和悲观锁: 乐观锁 :场景中它不太会出现锁冲突 悲观锁 :场景中它非常容易出现锁冲突
重量级锁和轻量级锁: 重量级锁:该锁开销比较大(锁冲突比较多),一个悲观锁,通常是重量级锁(不绝对) 轻量级锁:加锁开销比较小(锁冲突比较少),一个乐观锁通常是轻量级锁(不绝对)
自旋锁和挂起等待锁: 自旋锁是轻量级锁的一种典型实现,在用户态下通过自旋方式(whlie循环)达到加锁。因为是用户态,所以开销较小 挂起等待锁,是一种重量级锁的典型实现,通过内核态借助系统提供的锁机制 当出现锁冲突的时候 会牵扯到内核对于线程的调度 是冲突线程出现挂起(阻塞等待)。因为牵涉到内核,所以开销较大。
读写锁: 把读操作和写操作分别加锁。 如果两个线程 两个线程都是读加锁 不会产生锁竞争 如果两个线程 一个写加锁 另一个线程写加锁 会产生锁竞争 如果两个线程 一个线程写加锁 另一个线程读加锁 会产生锁竞争 (因为两个读并发不会引发线程不安全,读和写并发以及两个写并发会引发线程安全,所以针对该情况就有如上加锁规则)
公平锁 和 非公平锁 公平锁:遵守"先来先到" B比C先来 当A释放锁之后B就能比C先得到锁 非公平锁:不遵守先来后到功能,在A释放锁后b和c重新竞争。 操作系统自带的锁默认都属于非公平锁 如果想实现公平锁,就需要依赖额外的数据结构, 来记录线程们的先后顺序.
可重入锁 和 不可重入锁 如果一个线程对一把锁加锁两次会出现死锁就是不可重入锁 不出现死锁就是可重入锁
结合上面的锁策略, 我们就可以总结出,Synchronized 具有以下特性(只考虑 JDK 1.8): 1. 开始时是乐观锁, 如果锁冲突频繁, 就转换为悲观锁. 2. 开始是轻量级锁实现, 如果锁被持有的时间较长, 就转换成重量级锁. 3. 实现轻量级锁的时候用到自旋锁策略,实现重量级锁的时候用到挂起等待锁 4. 是一种不公平锁 5. 是一种可重入锁 6. 不是读写锁
它的原理有三个要讲:锁升级,锁消除,锁粗化。
代码中写了一个synchronized之后 这里可能会产生一系列的"自适应的过程"锁升级: 无锁->偏向锁->轻量级锁->重量级锁 偏向锁: 不是真的锁,并没有加锁,只是做了一个标记 如果有别的锁来竞争了就会真正加锁升级为轻量级锁,如果没有别的锁竞争就不会真的加锁。 加锁本身有一定的开销,能不加就不加,有竞争才加,这是懒汉模式的一个很好体现。 对于加锁成为轻量级锁后,如果竞争这把锁的线程越来越多了(锁冲突更激烈了),就从轻量级锁升级成重量级锁
编译器,会智能的判定当前这个代码是否有必要加锁,如果你写了加锁,但是实际上没有必要加锁,编译器就会把加锁操作自动删除掉。它保证优化之后的逻辑和之前的逻辑一致。 比如,在单个线程中,使用StringBuffer,编译器就会进行优化消除掉锁,且不影响逻辑。 编译器的锁消除是非常小心的,如果没有确定十分安全它是不会优化的。
首先要学习锁粗化就要讲下关于锁的粒度 如果加锁操作里包含的实际要执行的代码越多 就认为锁的粒度越大 所以如果有很多粒度较小的锁,就可能短时间内出现频繁创建销毁锁的情况,开销就很大。编译器就会通过合并多个锁操作,减少锁的开销。这就是锁粗化。 总结一下,锁粗化是一种编译器或运行时环境的优化技术,主要用于减少多线程程序中锁操作的开销。它的核心思想是将多个连续的锁操作合并为一个更大的锁操作,从而减少锁的获取和释放次数,提高程序性能。
CAS是一种用于实现多线程同步的原子操作。它是现代并发编程中非常重要的底层机制,广泛应用于无锁算法、线程安全数据结构和并发控制中。
这是CAS伪代码
boolean compareAndSwap(V, A, B) {
if (&V == A) {
&V = B;
return true;
}
return false;
}//这是CAS伪代码,讲述逻辑,不能实现
CAS 操作包含三个操作数:
CAS 的操作逻辑是:
V
的值等于期望值 A
,则将 V
的值更新为新值 B,
返回true。
V
的值不等于 A
,则不做任何操作,返回false。
CAS 的特点
要实现原子类,标准库中提供了 java.util.concurrent.atomic 包, 里面的类都是基于内部有CAS从而实现了原子性 :这些类的方法使用时都不会被拆分为几个指令,只能当作一个整体(一个指令)来看,所以无需锁就能实现我们要的线程安全效果。
典型的就是 AtomicInteger 类. 其中的 getAndIncrement 相当于 i++ 操作.
public class Demo01_CAS {
public static void main(String[] args) throws InterruptedException {
//原子整型
AtomicInteger atomicInteger = new AtomicInteger();
Thread thread = new Thread(() -> {
//五万次自增操作
for (int i = 0; i < 50000; i++) {
atomicInteger.getAndIncrement();
}
});
Thread thread2 = new Thread(() -> {
//五万次自增操作
for (int i = 0; i < 50000; i++) {
atomicInteger.getAndIncrement();
}
});
//启动线程
thread.start();
thread2.start();
//等待两个线程执行完成
thread.join();
thread2.join();
System.out.println(atomicInteger);
}
}
最后结果为1w,就可以证明atomicinteger是个原子类,里面的方法都具有原子性,不会被拆分,不用锁就能实现一样的效果,且开销更小。
CAS 可以用于实现自旋锁,即线程在获取锁时不断重试,而不是进入阻塞状态。
public class SpinLock {
private AtomicBoolean locked = new AtomicBoolean(false);
public void lock() {
while (!locked.compareAndSet(false, true)) {
// 自旋等待
}
}
public void unlock() {
locked.set(false);
}
}
对于实现自旋锁了解一下就行,而原子类一般用的比较多,要熟记
ABA 问题是 CAS 的一个经典问题。例如:
V
的值为 A
。
V
的值从 A
改为 B
,然后又改回 A
。
V
的值仍然是 A
,误认为V没有发生变化。
通常情况下ABA问题是不会发生bug的,但是特殊情况还是会导致一些问题。 比如我的账户里面有2000块钱(状态A),我委托张三说:如果我忘给李四转1000块钱,下午帮我转一下,我在中午给李四转了1000块钱(状态B),但是随后公司发奖金1000到我的账户,此时我账户有1000块钱(状态A),张三下午检查我账户,发现我有2000块钱,于是又给李四转了1000块钱,此时就出现问题了,李四收到了两次1000元,不符合我们的需求了. 那么解决方案是什么呢? 给预期值加一个版本号.在做CAS操作时,同时要更新预期值的版本号,版本号只增不减,每次操作都加一,在进行CAS比较的时候,不仅预期值要相同,版本号也要相同,这个时候才会返回true.
concurrent有并发的意思,所以这里的类都是并发编程相关的类
Callable
接口是 Java 并发编程中的一个重要接口,用于表示一个可以返回结果的任务。它与Runnable
接口类似,但Callable
更强大,因为它可以返回结果,而runable返回不了结果。下面给一个代码示例去教你怎么使用: 代码示例: 创建线程计算 1 + 2 + 3 + ... + 1000, 使用 Callable 版本 1.创建一个匿名内部类, 实现 Callable 接口. Callable 带有泛型参数. 泛型参数表示返回值的类型. 2.重写 Callable 的 call 方法, 完成累加的过程. 直接通过返回值返回计算结果. 3.把 callable 实例使用 FutureTask 包装一下. 4.创建线程, 线程的构造方法传入 FutureTask . 此时新线程就会执行 FutureTask 内部的 Callable 的 call 方法, 完成计算. 计算结果就放到了 FutureTask 对象中. 5.在主线程中调用 futureTask.get() 能够阻塞等待新线程计算完毕. 并获取到 FutureTask 中的线程返回结果.
public class Demo {
public static void main(String[] args) throws ExecutionException, InterruptedException {
Callable<Integer> callable = new Callable<Integer>() {
@Override
public Integer call() throws Exception {
int sum = 0;
for (int i = 1; i < 101; i++) {
sum += i;
TimeUnit.SECONDS.sleep(1);
}
return sum;
}
};
//通过FutureTask来创建一个对象,这个对象持有Callable
FutureTask<Integer> futureTask = new FutureTask<>(callable);
//让线程执行好定义的任务
Thread thread = new Thread(futureTask);
thread.start();
Integer result = futureTask.get();
System.out.println("最终的结果为:" + result);
}
}
和synchronized锁类似。这个锁提供了两个方法:lock(上锁) unlock(解锁) 使用这个锁的时候要注意解锁,上锁后,在代码执行过程中,遇到return 或者异常终止了,就可能引起 unlock没有被执行,锁没有释放,其他地方想使用该锁就会死等,因此,正确使用ReentrantLock锁 是把unlock放在finall代码块中,这样无论如何都能防止锁未被释放了。
import java.util.concurrent.locks.ReentrantLock;
public class TryLockExample {
private final ReentrantLock lock = new ReentrantLock();
public void performTask() {
if (lock.tryLock()) { // 尝试获取锁
try {
System.out.println(Thread.currentThread().getName() + " 获取锁,正在执行任务");
Thread.sleep(1000); // 模拟耗时操作
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.unlock(); // 释放锁
System.out.println(Thread.currentThread().getName() + " 释放锁");
}
} else {
System.out.println(Thread.currentThread().getName() + " 未能获取锁,执行其他逻辑");
}
}
public static void main(String[] args) {
TryLockExample example = new TryLockExample();
Runnable task = example::performTask;
Thread thread1 = new Thread(task, "线程1");
Thread thread2 = new Thread(task, "线程2");
thread1.start();
thread2.start();
}
}
对于该锁还有个trylock方法。
boolean tryLock()
:
true;
否则返回 false且放弃获取锁并执行后续代码
boolean tryLock(long timeout, TimeUnit unit)
:
true
。
false
。
优势: 1.ReentrantLock,在加锁的时候,有两种方式. lock, tryLock. 给了咱们更多的可操作空间。 2.ReentrantLock,提供了公平锁 的实现.(默认情况下是非公平锁) 3. ReentrantLock 提供了更强大的等待通知机制,搭配了 Condition 类实现等待通知(类似于wait,notify) 虽然 ReentrantLock 有上述优势,但是咱们在加锁的时候,还是首选synchronized。因为ReentrantLock 使用更加复杂,尤其是容易忘记解锁,而上述优势不算刚需,另外 synchronized 背后还有一系列的优化手段~~
信号量,⽤来表⽰"可⽤资源的个数".本质上就是⼀个计数器. 就类似停车场:用N记录当前可停车位, 有车进来停车,N-1;有车开走,N+1。这个N就表示可用资源的个数。 设“可⽤资源的个数"用 N来表示: 申请一个资源,会使N-1,称为“P操作”;释放一个资源,会使N+1,称为“V操作”。如果N为0了,继续P操作,该线程则会进行阻塞。 信号量是操作系统内部提供的一种机制,操作系统对应的api被JVM封装下,就能通过java代码来调用其相关的操作了。 在java中,用 acquire方法,表示申请;release方法,表示释放。这两个操作都是具有原子性的,可以直接使用。 对于锁就是一种特殊的信号量,可以认为是计数值为1的信号量:释放锁状态,就是1;加锁状态,就是0。对于这种非0即1的信号量,称为二元信号量。
import java.util.concurrent.Semaphore;
public class SemaphoreExample {
public static void main(String[] args) {
// 创建一个信号量,初始许可数为 4
Semaphore semaphore = new Semaphore(4);
// 创建一个任务
Runnable runnable = new Runnable() {
@Override
public void run() {
try {
System.out.println(Thread.currentThread().getName() + " 申请资源");
semaphore.acquire(); // 获取许可
System.out.println(Thread.currentThread().getName() + " 获取到资源了");
Thread.sleep(1000); // 模拟资源占用
System.out.println(Thread.currentThread().getName() + " 释放资源了");
semaphore.release(); // 释放许可
} catch (InterruptedException e) {
e.printStackTrace();
}
}
};
// 创建多个线程并发执行任务
for (int i = 1; i <= 6; i++) {
new Thread(runnable, "线程" + i).start();
}
}
}
由于线程调度的不确定性,每次运行的结果可能略有不同,但整体逻辑是一致的。以下是可能的输出:
注意一个线程可以多次acquire资源
CountDownLatch
是 Java 并发包 (java.util.concurrent
) 中的一个同步工具,用于让一个或多个线程等待其他线程完成操作。它的核心思想是通过一个计数器来实现线程的等待和通知机制。计数器初始化为一个正整数,每当一个线程完成任务时,计数器减 1;当计数器减到 0 时,等待的线程会被唤醒
CountDownLatch
的核心方法:
CountDownLatch(int count)
:
void await()
:
boolean await(long timeout, TimeUnit unit)
:
void countDown()
:
long getCount()
:
import java.util.concurrent.CountDownLatch;
public class CountDownLatchExample {
public static void main(String[] args) throws InterruptedException {
int threadCount = 3;
CountDownLatch latch = new CountDownLatch(threadCount); // 初始化计数器
Runnable task = () -> {
try {
System.out.println(Thread.currentThread().getName() + " 开始执行任务");
Thread.sleep(1000); // 模拟任务执行
System.out.println(Thread.currentThread().getName() + " 完成任务");
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
latch.countDown(); // 计数器减 1
}
};
// 创建多个线程并发执行任务
for (int i = 1; i <= threadCount; i++) {
new Thread(task, "线程" + i).start();
}
System.out.println("主线程等待子线程完成任务");
latch.await(); // 主线程等待计数器减到 0
System.out.println("所有子线程完成任务,主线程继续执行");
}
}
Vector,Stack,HashTable,是线程安全的 (但是Vector和hashTable不建议⽤,效率太低,只是方法加了synchronized) , 其他的集合类不是线程安全的.
1.用synchronized加锁,保证线程安全.
2.使用Collectios.synchronizedList(new ArrayList) synchronizedList可以让ArrayList对象的关键操作都带上synchronized,这样就不用一个一个去填写synchronized,一键速成
3.使用CopyOnWriteArrayList 它是JUC包下的一个类,使用的是一种叫写时复制技术来实现的 当要修改一个集合时,先复制这个集合的复本,然后修改复本的数据,修改完成后,用复本覆盖原始集合,而读集合时,则是读取原版,所以对于多线程同时进行读取和修改时,不用加锁也不会引发bug(注意同时进行的操作中 修改最多只能有一个,不然有bug) 优点:在多读少写的情况下,无需加锁就解决了ArrayList的线程安全问题,提高了性能。 缺点:对数组的修改不能太频繁;数组不能太长,这些可能会导致复制操作成本太高。
HashMap是线程不安全的,HashTable是线程安全的 多线程环境使用哈希表可以使用: 1.HashTable 2.ConcurrentHashMap HashTable是把关键方法上都加了synchronized锁,也就是synchronized给一整个哈希表加了锁。当一个线程对数组中某条链表操作时,任何线程都不能对该数组操作,即使两个线程操作的是不同的链表,不加锁也不会有bug,但还是会进行堵塞,所以HashTable在多线程下的执行效率是很慢的。
ConcurrentHashMap: 对HashTable进行了改进和优化:
1.优化了加锁方式 缩小了锁的粒度,不再将整个数组都加锁,对每个链表都分配了一把锁(将每个链表的头节点对象设为锁),只有当多个线程访问同一个链表时,才会产生锁冲突。这样就降低了锁冲突,提高了效率。
2.充分利用CAS原子操作特性 ⽐如size属性通过CAS来更新.避免出现重量级锁的情况.
3.优化了扩容方式 HashTable通过计算负载因子,判断是否需要扩容,达到要扩容的值,就直接扩容:创建新数组,将原来的数据全复制到新数组中。当数据量非常大时,扩容操作会进行的比较慢,表现出来的就是在运行的某一时刻比较慢,不具有稳定性。 ConcurrentHashMap对此进行了优化,通过“化整为零”方式进行扩容,不是一下将全部数据进行拷贝,而是进行分批拷贝 当需要扩容时,先创建一个新的数组,每次将一部分数据拷贝到新数组中,后续每个来操作ConcurrentHashMap的线程,都会参与搬家的过程.每个操作负责搬运⼀⼩部 分元素.这个过程中新老哈希表都存在,扩容结束,删除旧表;这样就很稳定。
4.对读操作不进行加锁 ConcurrentHashMap很特殊,进行读写操作并发执行时并不会引发线程安全问题,所以就无需对读操作进行加锁,能进一步减少开销。
所以现在我们一般在多线程中都用currenthashmap去使用哈希表。