前言
在Java中,并发编程是一个经久不衰的话题,涉及到并发,必然要进行加锁,其实不仅是Java语言,在如今支持高并发特性的语言,都要支持,下面对于并发常见中常见锁在不同维度的划分做简单的概念介绍。
并发与并行
说到并发,首先要区分并发与并行的概念。
- 并发:同一时间应对(dealing with)多件事情的能力。
- 并行:同一时间动手做(doing)多件事的能力。
案例:
我妻子是一位教师。与众多教师一样,她极其善于处理多个任务。她虽然每次只能做一件事,但可以同时处理多个任务。比如,在听一位学生朗读的时候,她可以暂停学生的朗读,以维持课堂秩序,或者回答学生的问题。这是并发,但并不并行(因为仅有她一个人,某一时刻只能进行一件事)。
但如果还有一位助教,则她们中一位可以聆听朗读,而同时另一位可以回答问题。这种方式既是并发,也是并行。假设班级设计了自己的贸卡并要批量制作。一种方法是让每位学生制作五枚贺卡。这种方法是并行,而(从整体看)不是并发,因为这个过程整体来说只有一个任务。
通常的从宏观上来看,并发可以通过单核CPU的调度来完成,并行至少需要多核CPU来完成,并行往往伴随着并发,并发通常不存在并行。
单核CPU并发编程
一般程序存在IO密集型或者CPU密集型两种任务类型。
- CPU密集型也叫计算密集型,指的是系统的硬盘、内存性能相对CPU要好很多,此时,系统运作大部分的状况是CPU Loading100%,CPU要读/写I/O(硬盘/内存),I/O在很短的时间就可以完成,而CPU还有许多运算要处理,CPU Loading很高。
- IO密集型任务,涉及到网络、磁盘IO的任务都是,这类任务的特点是CPU消耗很少,任务的大部分时间都在等待IO操作完成(因为IO的速度远远低于CPU和内存的速度)。对于IO密集型任务,任务越多,CPU效率越高,但也有一个限度。常见的大部分任务都是IO密集型任务,比如Web应用。
详细描述:https://www.cnblogs.com/starsray/p/12650238.html
并发编程意义
并发编程的概念出现很早,有的语言天生支持并发,比如ErLang、GO,比如在GO里面可以轻松的通过go
关键字通过goroutin来实现并发,而Java需要Thread来开辟线程,二者区别在于前者为协程,用户级(轻量)线程,后者为系统级(重量)线程,如今的Web应用中大部分时候都会用到并发编程,尤其是面对如今强劲的多核计算机,并发才能显得物尽其用。
当前并发的目的不是为了单纯发挥现代计算机多核的优势,正确的使用并发可以让程序更加健壮,响应更及时,提高程序的效率,容错率等。学习并发编程也显得更为重要。
锁分类
在计算机的世界中,并发和锁是一个永恒的话题,并发意味着效率(混乱),锁维持着有序性,在不同的场景按照不同的维度来划分,锁的种类也是五花八门。这些所往往也都是成对出现。
乐观锁/悲观锁
- 乐观锁和悲观锁从字面意思来看是一组反义词,实际上指的是他们对数据操作的一种容忍度,悲观锁每次总是考虑最坏的场景,每次拿数据总会认为别人会修改数据,因此每次拿数据都会对数据上锁,其他线程就阻塞等待;乐观锁处理策略就更为宽松一些,每次拿数据认为别人都不会修改,只有在更新的时候才会判断在此期间是否有人更新数据,乐观锁一般通过版本号来实现,每次拿数据的时候版本号一同读取,每次更新数据的时候会比对当前版本号是否和期望的一致。
- 通常悲观锁多运用在数据库、操作系统等层面,乐观锁更多由上层开发者使用,JUC中atomic包的工具类就是基于CAS,而CAS就是一种基于操作系统底层的一组原语以及版本号的乐观锁实现。
Java中的乐观锁具体实现可以看作是CAS,悲观锁实现synchronized。
公平锁/非公平锁
公平锁指的是多线程并发场景下按照线程申请锁的顺序来执行,简单说就是先到先得,非公平锁就存在有可能先申请锁的线程后执行,后申请的先执行,具体的执行策略由CPU调度确定。
Java中的老牌锁实现synchronize就是非公平锁,非公平锁的实现有点在于在高并发场景下可以有更高的吞吐量,JUC中ReentrantLock默认是是一种非公平锁实现,可以通过参数指定其为公平锁,由于其基于AQS同步队列实现,因此可以确定线程的调度顺序,JUC中同样基于AQS的同步辅助类Semaphore也可以实现公平锁。
可重入锁/不可重入锁
可重入锁也叫递归锁,Java中synchronized和ReentrantLock都是可重入锁,一个线程可以重复的获得锁,可以在同步代码快中调用另一段同步代码快,目的是防止死锁。
- synchronized底层实现,每个锁关联一个线程持有者和一个计数器。当计数器为0时表示该锁没有被任何线程持有,那么任何线程都都可能获得该锁而调用相应方法。当一个线程请求成功后,JVM会记下持有锁的线程,并将计数器计为1。此时其他线程请求该锁,则必须等待。而该持有锁的线程如果再次请求这个锁,就可以再次拿到这个锁,同时计数器会递增。当线程退出一个synchronized方法/块时,计数器会递减,如果计数器为0则释放该锁。
- 不可重入锁,同一个线程不能再次获得锁,通过一个示例来演示。 ```java package nonreentrant;
/**
- 非可重入锁 *
- @author starsray
- @date 2021/12/18
/
public class NonReentrantLock {
/*
- owner */ private Thread owner;
public synchronized void lock() throws InterruptedException {
Thread thread = Thread.currentThread();
while (owner != null) {
System.out.printf("%s 等待 %s 释放锁%n",thread.getName(), owner.getName());
wait();
}
System.out.println(thread.getName() + " 获得了锁");
owner = thread;
}
public synchronized void unlock() {
if (Thread.currentThread() != owner) {
throw new IllegalMonitorStateException();
}
System.out.println(owner.getName() + " 释放了锁");
owner = null;
notify();
}
public static void main(String[] args) throws InterruptedException {
NonReentrantLock lock = new NonReentrantLock();
lock.lock();
lock.lock();
}
}
输出结果:
main 获得了锁 main 等待 main 释放锁
通过代码简单测试了不可重入锁,main线程造成了死锁,那不可重入锁的用途是什么,有什么应用场景吗?看一下ThreadPoolExecutor中的一段源码:
```java
private final class Worker extends AbstractQueuedSynchronizer implements Runnable {
/**
* Creates with given first task and thread from ThreadFactory.
*
* @param firstTask the first task (null if none)
*/
Worker(Runnable firstTask) {
setState(-1); // inhibit interrupts until runWorker
this.firstTask = firstTask;
this.thread = getThreadFactory().newThread(this);
}
protected boolean tryAcquire(int unused) {
if (compareAndSetState(0, 1)) {
setExclusiveOwnerThread(Thread.currentThread());
return true;
}
return false;
}
}
私有内部类Worker继承了AQS,并且在构造方法中初始化了setState()方法的值为-1,设置-1的目的是在创建Worker的过程中不希望此操作被中断掉。
tryAcquire方法通过Worker类继承AQS来使用排他锁,没有直接使用ReentrantLock来实现排他锁功能,因为ReentrantLock是可重入的,而tryAcquire()方法这里是不可重入的,state的默认值为0,在创建Worker工作类时被初始化为-1,因此在空闲遍历工作线程时刚创建的类是不应该被打断的,但是这个线程如果正在执行任务那就应该是被独占不可重入的。
可重入和不可重入两者关系是根据不同场景相对而言,没有绝对的好坏,只有适合与不适合。
读写锁/互斥锁
- 互斥锁是一种对立的状态,要么加锁要么不加锁,Java中的synchronized就是一个典型的例子,但是如果仅仅是读取而不修改数据就使用互斥量来加锁,显得过于粗暴,而读写锁就是另一种更加细节的实现方式,提供了比互斥量跟好的并行性,在读模式加锁后又有多个线程试图再以读模式加锁时,并不会造成这些线程阻塞。读写锁提高了线程的并行性,允许多个线程同一时间读取一个共享变量,而互斥锁不行。
在一些写操作比较多或是本身需要同步的地方并不多的程序中应该使用互斥锁,在读操作远大于写操作的一些程序中应该使用读写锁来进行同步。
共享锁/排他锁
共享锁指的是同一时刻可以有多个线程来占有锁,对数据进行操作,排他锁在同一时间只能有单个线程占有,Java中synchronized和ReentrantLock都是排他锁。
- 基于AQS实现的ReadWriteLock读写锁,在的时候是共享锁,在写的时候是排他锁,读锁的共享锁可保证并发读是非常高效的,读写,写读 ,写写的过程是互斥的。
- MySQL中也有共享锁(lock in share mode)和排他锁(for update)。
- 当时用select…lock in share mode语句时,MySQL 会对查询结果中的每行加共享锁,当没有其他线程对查询使用排他锁时,可以成功申请共享锁,否则会被阻塞。其他线程也可以读取使用了共享锁的表,这些线程读取的数据是同一个快照版本。
- 当时用select…for update语句时,MySQL 就会对查询结果中的每行都加排他锁,当没有其他线程对查询结果使用排他锁时,可以成功申请排他锁,否则会被阻塞。
此外需要注意MySQL的行锁是对索引加锁,不是针对记录加锁,虽然是访问不同行的记录,但是如果是使用相同的索引键,是会出现锁冲突的。
偏向锁/轻量级锁/自旋锁/重量级锁
这个在Java中特指synchronized锁升级的过程,在JDK1.5之前,synchronized是直接通过操作操作系统级别的信号量互斥来实现锁,是重量级锁,在JDK后续版本通过利用现代CPU特性,对其做了一系列的优化,进行了锁升级的过程。
关于synchronized的进一步分析可以参考:https://www.yuque.com/starsray/java/lozufa
分布式锁
上面所介绍的锁往往是应对单机情况下,单个JVM进程中的场景,现在服务基于容器化、集群等场景,应用往往都是多进程部署,因此分布式锁就出现了,分布式锁有多重实现方式,比如借助于数据库来实现一个简易分布式锁,借助于redis来实现一个性能高一点的分布式锁,借助于基于redis现有方案redlock来实现,借助于zookeeper节点来实现分布式锁等方案。
任何一种技术的出现,都不是偶然,是时代发展的必然性,但是熟悉底层原理,会更有利于对知识的延展。