JUC_ 锁_ 信号量_并发集合
一、Synchronized同步锁回顾
1. 锁介绍
在Java中每个对象或类都可以当做锁使用,这些锁称为内置锁。
Java中内置锁都是互斥锁。锁在同一时刻,只能被一个线程持有。
如果锁是作用于对象,称对象锁。如果锁作用整个类称为类锁。
-
synchronized是Java中的关键字。使用synchronized关键字是锁的一种实现。
-
synchronized的加锁和解锁过程不需要程序员手动控制,只要执行到synchronized作用范围会自动加锁(获取锁/持有锁),执行完成后会自动解锁(释放锁)。
-
synchronized可以保证可见性,因为每次执行到synchronized代码块时会清空线程区。
-
synchronized 不会禁用指令重排,但可以保证有序性。因为同一个时刻只有一个线程能操作。
-
synchronized 可以保证原子性,一个线程的操作一旦开始,就不会被其他线程干扰,只能当前线程执行完,其他线程才可以执行。
-
synchronized 在Java老版本中属于重量级锁(耗费系统资源比较多的锁),随着Java的不停的更新、优化,在Java8中使用起来和轻量级锁(耗费系统资源比较少的锁)已经几乎无差别了。
-
主要分为下面几种情况:
-
修饰实例方法,非静态方法(对象锁) 需要在类实例化后,再进行调用。
-
修饰静态方法(类锁)静态方法属于类级别的方法,静态方法可以类不实例化就使用。
-
修饰代码块(对象锁、类锁)。
3. 修饰实例方法
锁类型:使用synchronized修饰实例方法时为
锁范围:锁的范围是加锁的方法。
锁生效:必须为同一个对象调用该方法该锁才有作用。
4. 修饰静态方法
锁类型:使用synchronized修饰静态方法时为类锁。
锁范围:锁的范围是加锁的方法。
锁生效:该类所有的对象调用加锁方法,锁都生效 。
5. 修饰代码块
语法:
synchronized(锁){
// 内容
}
锁代码块是非常重要的地方。添加锁的类型是Object类型。
运行过程:
多线程执行时,每个线程执行到这个代码块时首先会判断是否有其他线程持有这个锁,如果没有,执行synchronized代码块。如果已经有其他线程持有锁,必须等待线程释放锁。当一个线程执行完成synchronized代码块时会自动释放所持有的锁。
5.1 锁为固定值
当锁为固定值时,每个线程执行到synchronized代码块时都会判断这个锁是否被其他线程持有,哪个线程抢到先执行哪个线程。当抢到的线程执行完synchronized代码块后,会释放锁,其他线程竞争,抢锁,抢到的持有锁,其他没抢到的继续等待。
由于值固定不变,所有的对象调用加锁的代码块,都会争夺锁资源,属于类锁。
5.2 锁为不同内容
每个线程中的synchronized锁不相同时,相当于没有加锁。
因为没有需要竞争锁的线程,线程执行到synchronized时,直接获取锁,进入到代码块。
5.3 锁为this
5.3.1 同一个对象调用加锁方法时:
如果是同一个对象调用synchronized所在方法时,this代表的都是一个对象。this就相当于固定值。所以可以保证结果正确性,属于对象锁。
public void test(){ //当锁为this时,每个线程执行到synchronized代码块时都会判断这个锁是否被其他线程持有,this就相当于调用加锁代码块的对象,同一个对象调用,锁生效 synchronized (this){ for (int i = 0; i < 10000; i++) { a++; } } }
5.3.2 不同对象调用加锁方法时:
如果不是同一个对象调用synchronized所在方法时,this所代表的对象就不同。相当于锁为不同内容时,锁失效。
5.4 锁为class
public void test(){ //当锁为类时,所有对象执行加锁代码块,锁都生效 synchronized (Demo20.class){ for (int i = 0; i < 10000; i++) { a++; } } }
6. 对象锁和类锁(面试题)
当synchronized修饰静态方法或代码块参数为Class时或代码块参数为固定值,锁为类锁,作用整个类。同一个类使用,锁生效。
当synchronized修饰实例方法或代码块参数为this时,为对象锁,只对当前对象有效。
体现在:
多个对象使用时,锁生效,使用类锁。
同一对象使用时,锁生效,使用对象锁。
7. 什么是可重入锁(面试题)
某个线程已经获得了某个锁,允许再次获得锁,就是可重入锁。如果不允许再次获得锁就称为不可重入锁。
synchronized为可重入锁。但可重入锁不仅仅只有synchronized。后面还会学习ReentrantLock也是可重入锁。
7.1 代码演示
test1()、test2()中都使用的同一把锁。在执行test1()时没有人持有”锁”,所以进入到test1()中的synchronized,输出test1,调用test2()方法,因为是同一个线程,且synchronized是可重入锁,所以允许继续执行,就会进入到test2(),执行synchronized,输出test2。
public class Demo22 { public static void main(String[] args) { Demo22 demo = new Demo22(); new Thread(new Runnable() { @Override public void run() { demo.test1(); } }).start(); } public void test1(){ synchronized (this){ System.out.println("test1执行"); test2(); } } public void test2(){ synchronized (this){ System.out.println("test2执行"); } } }
可重入锁底层原理,就是计数器
当一个线程第一次持有某个锁时会由monitor(监控器)对持有锁的数量加1,当这个线程再次需要碰到这个锁时,如果是可重入锁就对持有锁数量再次加1(如果是不可重入锁,发现持有锁为1了,就不允许多次持有这个锁了,阻塞),当释放锁时对持有锁数量减1,直到减为0,表示完全释放了这个锁。
线程生命周期从新建到死亡共包含五种状态:
2.1 新建状态
当实例化Thread对象后,线程就处于新建状态. 这时线程并没有执行。
public static void main(String[] args) { Thread thread = new Thread(new Runnable() { @Override public void run() { System.out.println("run()执行开始"); System.out.println("run()执行结束"); } }); }
2.2 就绪状态
thread.start();//调用start(),然后自动调用start0,start0一执行,开启线程,开启的线程自动调用run方法
通过时间轮片算法拿CPU执行权
2.3 运行状态
运行状态就是开始执行线程的功能。具体就是执行run()方法
Thread thread = new Thread(new Runnable() { @Override public void run() { System.out.println("run()执行开始"); try { Thread.sleep(2000); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println("run()执行结束"); } }); thread.start();
1. 如果碰到sleep() / wait() / join()等方法会让线程切换为阻塞状态。
2. 如果调用yield()方法或失去CPU执行权限会切换为就绪状态。
3. 如果run()方法成功执行完成,或出现问题或被停止(中断)会切换为死亡状态。
2.4 阻塞状态
阻塞状态时,线程停止执行。让出CPU资源。
处于阻塞状态的线程需要根据情况进行判断是否转换为就绪状态:
1. 如果是因为sleep()变为阻塞,则休眠时间结束自动切换为就绪状态。
2. 如果是因为wait()变为阻塞状态,需要调用notify()或notifyAll()手动切换为就绪状态。
3. 如果因为join()变为阻塞状态,等到join线程执行完成,自动切换为就绪状态。
4. (已过时)如果是因为suspend()暂停的线程,需要通过resume()激活线程。
2.5 死亡状态
死亡状态即线程执行结束。
1. stop()介绍(已过时)
1.1 stop()介绍(已过时)
stop()可以停止一个线程。让线程处于死亡状态,stop()已经过时
1.2 stop()弃用的原因
stop()本身就是不安全的,强制停止一个线程,可能导致破坏线程内容。导致错误的结果。同时程序还没有任务异常。
stop()太绝对了,什么情况下都能停,并没有任何的提示信息,可能导致混乱结果。
interrupt()作为stop()的替代方法。可以实现中断线程,并结束该线程。
interrupt()只能中断当前线程状态带有InterruptedException异常的线程,当程序执行过程中,如果被强制中断会出现Interrupted异常。
public static void main(String[] args) { Thread thread = new Thread(new Runnable() { @Override public void run() { System.out.println("run()开始执行"); try { Thread.sleep(2000); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println("run()执行结束"); } }); //启动线程 thread.start(); try { Thread.sleep(1000);//主线程阻塞状态 } catch (InterruptedException e) { e.printStackTrace(); } //结束处于阻塞状态的线程 thread.interrupt(); }
2.2 停止并结束运行状态的线程
运行状态没有抛出InterruptException 所以不能中断。
-
Thread类中的run()方法
@Override public void run() { if (target != null) { target.run(); } }
public static void main(String[] args) { Thread thread = new Thread(new Runnable() { @Override public void run() { while (true){ System.out.println("run()开始执行"); System.out.println("run()执行结束"); } } }); //启动线程 thread.start(); try { Thread.sleep(1000);//主线程阻塞状态 } catch (InterruptedException e) { e.printStackTrace(); } //结束处于运行状态的线程 thread.interrupt(); }
3.1 suspend()介绍(已过时)
suspend()可以挂起、暂停线程,让线程处于阻塞状态,是一个实例方法,已过时。
挂起时和释放时,不会释放锁,所以没有锁的要求
3.2 resume()介绍(已过时)
resume()可以让suspend()的线程唤醒,变成就绪状态,已过时。
四、线程通信回顾
需要多个线程配合完成一件事情,如何让多个线程能够合理的切换就是线程通信需要考虑的问题,重点在于配合。
2. 生产者消费者模式
生产者和消费者模式为最经典的线程通信案例:
但是该方式有缺点:
-
因为两个线程一直处于运行状态和就绪状态。
-
两个线程一直在运行和就绪状态之间切换。 抢到锁的就可以执行,而没有抢到锁的线程一直在抢锁,所以对系统性能损耗较大,不推荐使用这种方式。
-
wait()和notify() | notifyAll() 方式
-
join()方式
-
Condition 方式
4. wait()和notify() | notifyAll()
注意:一个线程唤醒其他线程时,要求当前线程必须持有锁
总结:
1. 使用wait()和notify() | notifyAll()要求必须有锁。
3. wait()和notify() | notifyAll() 配合使用。
4.2 wait()和sleep()区别(常见面试题)
-
所属类不同
wait(long) 是Object中方法
sleep(long)是Thread的方法
-
唤醒机制不同
wait() 没有设置最大时间情况下,必须等待notify() | notifyAll()
sleep()是到指定时间自动唤醒
-
锁机制不同
wait(long)释放锁
sleep(long)只是让线程休眠,不会释放锁
-
使用位置不同
wait()必须持有对象锁
sleep()可以使用在任意地方
-
方法类型不同
wait()是实例方法
sleep()是静态方法
5. join()
join() 把线程加入到另一个线程中。在哪个线程内调用join(),就会把对应的线程加入到当前线程中。
五、 JUC中的locks包
1. locks包介绍
六、JUC的锁机制
1. AQS
1.1 介绍
AQS全名AbstractQueuedSynchronizer(就是抽象队列),是并发容器JUC(java.util.concurrent)下locks包内的一个类。
1.2 工作原理
AQS的核心思想为如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并且将共享资源设置为锁定状态。如果被请求的共享资源被占用,那么就需要一套线程阻塞等待以及被唤醒时锁分配的机制,这个机制AQS是使用队列实现的锁,即将暂时获取不到锁的线程加入到队列中。synchronized是等执行完线程去抢,谁抢到谁执行,而AQS是按队列从头开始执行。
AQS使用一个int state成员变量来表示同步状态,通过内置的FIFO队列来完成获取资源线程的排队工作。
2. 锁机制介绍
JUC中提供的锁
-
ReentrantLock:Lock接口的实现类,叫可重入锁。作用相当于synchronized同步锁。
-
ReentrantReadWriteLock:ReadWriteLock接口的实现类。类中包含两个静态内部类,ReadLock读锁、WriteLock写锁。
-
Condition:是一个接口,都是通过lock.newCondition()实例化。属于wait和notify的替代品。提供了await()、signal()、singnalAll()与之对应。
-
LockSupport:和Thread中suspend()和resume()相似。
3. 锁机制详解
3.1 ReentrantLock重入锁
ReentrantLock是JUC中对重入锁的标准实现。作用相当于synchronized。
加锁和解锁过程都需要由程序员手动控制,使用很灵活。
提供了2种类型的构造方法。
1. ReentrantLock():创建非公平锁的重入锁。
2. ReentrantLock(boolean):创建创建锁。取值为true表示公平锁,取值为false表示非公平锁。
公平锁
非公平锁:多线程在等待时,可以竞争,谁竞争成功,谁获取锁。
非公平锁的效率要高于公平锁。ReentrantLock默认就是非公平锁。
创建:
ReentrantLock rk = new ReentrantLock();
//无返回值 阻塞代码 rk.lock(); //有返回值 不会阻塞代码 boolean b = rk.tryLock()
注意:
1. ReentrantLock出现异常时,不会自动解锁
2. 多线程的情况下,一个线程出现异常,并没有释放锁,其他线程也获取不到锁,容易出现死锁
3. 建议把解锁方法finally{}代码块中
4. synchronized加锁与释放锁不需要手动的设置,遇到异常时,会自动的解锁
注意:
避免死锁,需要将解锁放到finally{}中
3.2 Condition等待 | 唤醒
语法:
创建:
ReentrantLock rk = new ReentrantLock(); Condition condition = rk.newCondition();
线程等待:
condition.await();
唤醒一个线程 | 唤醒所有线程:
condition.signal(); //唤醒一个线程 condition.signalAll(); //唤醒所有线程
3.3 ReadWriteLock读写锁
ReadWriteLock为接口,实现类为ReentrantReadWriteLock
ReadLock 读锁,又称为共享锁。
WriteLock 写锁,又称为独占锁。只有一个线程能获取,其他写的线程等待,避免死锁。
语法:
创建:
ReentrantReadWriteLock rk = new ReentrantReadWriteLock();
//获取读锁 ReentrantReadWriteLock.ReadLock readLock = rrw.readLock(); //加锁 readLock.lock(); boolean b = readLock.tryLock(); //解锁 readLock.unlock();
写锁:
//获取写锁 ReentrantReadWriteLock.WriteLock writeLock = rrw.writeLock(); //加锁 writeLock.lock(); boolean b = writeLock.tryLock(); //解锁 writeLock.unlock();
3.4 LockSupport 暂停 | 恢复
注意:暂停恢复不会释放锁,避免死锁问题
暂停:
LockSupport.park();//写在哪个线程范围哪个线程暂停
LockSupport.unpark(t1);
3.5 synchronized和lock的区别(面试题)
synchronized是关键字。修饰方法,修饰代码块
Lock是接口
-
加锁和解锁机制不同
synchronized是自动加锁和解锁,程序员不需要控制。
Lock必须由程序员手动控制加锁和解锁过程,解锁时,需要注意出现异常不会自动解锁
-
异常机制
synchronized碰到没有处理的异常,会自动解锁,不会出现死锁。
Lock碰到异常不会自动解锁,可能出现死锁。所以写Lock锁时都是把解锁放入到finally{}中。
-
Lock功能更强大
Lock里面提供了tryLock()/isLocked()方法,进行判断是否上锁成功。synchronized因为是关键字,所以无法判断。
-
Lock性能更优
如果多线程竞争锁特别激烈时,Lock的性能更优。如果竞争不激烈,性能相差不大。
-
线程通信方式不同
synchronized 使用wait()和notify()线程通信。
Lock使用Condition的await()和signal()通信。
-
暂停和恢复方式不同
synchronized 使用suspend()和resume()暂停和恢复,这俩方法过时了。
Lock使用LockSupport中park()和unpark()暂停和恢复,这俩方法没有过时。
七、JUC中的Tools
1. Tools介绍
Tools也是JUC中的工具类,其中包含了CountDownLatch、CyclicBarrier、Semaphore
在开发中经常遇到在主线程中开启多个线程去并行执行任务,并且主线程需要等待所有子线程执行完毕后再进行汇总的场景。之前是使用join() | 主线程休眠实现的,但是不够灵活,某些场合和还无法实现,所以JUC提供了CountDownLatch这个类。底层基于AQS。
CountDown是计数递减的意思,Latch是门闩的意思。内部维持一个递减的计数器。可以理解为初始有n个Latch,等Latch数量递减到0的时候,结束阻塞,执行后续操作。
创建:
CountDownLatch cdl= new CountDownLatch(数字);
线程等待:
//当前线程等待,直到到Latch计数到零,或者被interrupt cdl.await():
计数器递减:
//减少Latch的计数,如果计数达到零,释放等待的线程 cdl.countDown( ):
3. CyclicBarrier回环屏障
CountDownLatch优化了join()在解决多个线程同步时的能力,但CountDownLatch的计数器是一次性的。计数递减为0之后,再调用countDown()、await()将不起作用。为了满足计数器可以重置的目的,JDK推出了CyclicBarrier(回环屏障)类。
await()方法表示当前线程执行时计数器值不为0则等待和计数器相似。如果计数器为0则继续执行。每次await()之后计算器会减少一次,而计数器减少只能调用countDown。当减少到0下次await从初始值重新递减。
4. Semaphore 信号量
CountDownLatch和CyclicBarrier(回环屏障)的计数器递减的,而Semaphore的计数器是可加可减的。
创建:
Semaphore sp= new Semaphore(数字);
获取信号量的值:
int i = sp.availablePermits();
//信号量+1 sp.release(); //信号量+n sp.release(n);
sp.acquire(); //信号量-1,无返回值 sp.tryAcquire(); //信号量-1,有返回值 sp.acquire(n); //信号量-n,无返回值 sp.tryAcquire(n); //信号量-n,有返回值
八、并发集合类
1. 介绍
并发集合类:主要是提供线程安全的集合。
比如:
1. ArrayList对应的并发类是CopyOnWriteArrayList
2. HashSet对应的并发类是 CopyOnWriteArraySet
3. HashMap对应的并发类是ConcurrentHashMap
2.1 ArrayList
ArrayList是最常用的集合之一,大小不固定,可以随着元素的增多可以自动扩容。
储存的数据为有序,可重复. 底层实现是基于数组,线程不安全。
2.2. CopyOnWriteArrayList
使用方式和ArrayList相同, 当时CopyOnWriteArrayList线程为安全的。
写时复制
通俗的理解是当我们往一个容器添加元素的时候,不直接往当前容器添加,而是先将当前容器进行Copy,复制出一个新的容器,然后往新的容器里添加元素,添加完元素之后,再将原容器的引用指向新的容器。
这样做的好处是我们可以对CopyOnWrite容器进行并发的读,而不需要加锁,因为当前容器不会添加任何元素。所以CopyOnWrite容器也是一种读写分离的思想,读和写不同的容器。
对于读操作远远多于写操作的应用非常适合,特别在并发情况下,可以提供高性能的并发读取。
CopyOnWrite容器只能保证数据的最终一致性,不能保证数据实时一致性。
3. CopyOnWriteArraySet
3.1 HashSet
HashSet无序,无下标,元素不可重复的集合,线程不安. 底层实现为(HashMap)
3.2 CopyOnWriteArraySet
它是线程安全的HashSet,CopyOnWriteArraySet则是通过"动态数组(CopyOnWriteArrayList)"实现的,并不是散列表
4.1 HashMap
HashMap也是使用非常多的集合,线程不安全,以key-value的形式存在。
在HashMap中,底层实现为哈希表,系统会根据hash算法来计算key的存储位置,我们可以通过key快速地存、取value,允许一个key-value为null
1. HashMap JDk1.7以及1.7之前
HashMap 底层是基于数组+链表
组成的
头插
2. HashMap JDk1.8以及1.8之后
HashMap 底层是基于 数组+链表+红黑树
组成的,
尾插
4.2 HashTable
HashTable和HashMap的实现原理几乎一样,差别无非是
1. HashTable不允许key和value为null
2. HashTable是线程安全的,但是HashTable线程安全的策略实现代价却太大了,简单粗暴
4.3 ConcurrentHashMap1.7及之前
ConcurrentHashMap做了优化采用了"分段锁"策略。
4.4 ConcurrentHashMap1.8及之后
ConcurrentHashMap在JDK8中进行了巨大改动。它摒弃了Segment(锁段)的概念,而是启用了一种全新的方式实现,利用synchronized + CAS,如果没有出现hash冲突,使用CAS直接添加数据,只有出现hash冲突的时候才会使用同步锁添加数据,并且增加了许多辅助类。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· TypeScript + Deepseek 打造卜卦网站:技术与玄学的结合
· 阿里巴巴 QwQ-32B真的超越了 DeepSeek R-1吗?
· 【译】Visual Studio 中新的强大生产力特性
· 10年+ .NET Coder 心语 ── 封装的思维:从隐藏、稳定开始理解其本质意义
· 【设计模式】告别冗长if-else语句:使用策略模式优化代码结构