目录
一、多线程
优点:提高程序的响应处理速度,提高cpu的利用率,压榨硬件的剩余价值.
存在的问题:多线程访问同一资源.
产生的原因:现在的cpu是多内核的,在理论上是可以同时执行多个线程的.
二、并发执行与并行执行
✅并行执行:在同一个时间节点上,多个线程同时执行.
例如,一个计算机同时执行多个程序、多个线程或者多个进程时,就是采用并行的方式来处理任务,这样能够提高计算机的处理效率。
✅并发执行:在一个时间段内,多个线程交替执行(即一个任务执行一段时间后,再执行另外一个任务)
所以并发执行宏观上感觉是同时执行的,微观上其实是一个一个执行的。它是通过操作系统的协作调度来实现各个任务的切换,达到看上去同时进行的效果。
例如,一个多线程程序中的多个线程就是同时运行的,但是因为 CPU 只能处理一个线程,所以在任意时刻只有一个线程在执行,线程之间会通过竞争的方式来获取 CPU 的时间片。
三、并发编程的核心问题
并发编程的核心问题就是多个线程访问共享数据时,出现问题的根本原因
3.1不可见性
并发编程的不可见性与Java的内存模型有关,也就是JMM( Java Memory Model )
• Java内存模型是变量数据都存在主内存中,每个线程还有自己的工作内存。
• 规定线程不能直接对主内存中的数据操作,只能把主内存数据加载到自己的工作内存中操作,
操作完成后,再写回主内存。
• 这样的设计就会引发不可见性问题,即一个线程A在自己的工作内存中操作了数据后,此时另一个线程B也正在操作,但是线程B不知道数据已经被另一个线程A修改了。
3.2 乱序性
• 为了优化指令执行,在执行一些等待时间长的执行时,可以把其他的一些指令提前执行,提高速度。
• 但是在多线程场景下,由于cpu时间片轮换,不同的线程可能会交替执行不同的代码,就可能会出现问题。
3.3 非原子性
线程的切换执行带来非原子性问题,cpu保证的原子性执行是cpu指令级别的,但是对于高级语言的一条代码,有时是要拆分成多条指令来执行。
线程A在执行到某条指令时,操作系统会切换到其他线程去执行,这样这条高级语言指令执行就是非原子性的。
例如:++操作,可以分成3条指令
- 加载主内存数据到工作内存
- 在工作内存操作数据
- 写回主内存
3.4 并发问题总结
在多线程环境中,JMM (也可以理解为缓存) 导致的不可见性问题,编译优化带来了乱序性问题,线程切换带来了非原子性问题。
其实缓存、线程、编译优化的目的和我们写并发程序的目的是相同的,都是提高程序安全性和性能,但是技术在解决一个问题的同时,必然会带来另外一个问题,所以在采用一项技术的同时,一定要清楚它带来的问题是什么, 以及如何规避。
在了解并发编程存在的问题后,接下来该如何解决这些问题呢?
四、volatile关键字
• volatile 修饰的变量,在一个线程中被修改后,对其它线程立即可见。底层会将工作内存中的数据同步到其他线程的工作内存,使其立即可见。
• volatile 关键字修饰后的变量,在执行时禁止指令重排来保证顺序性,也解决了乱序性。
但是volatile无法解决非原子性问题
总的来说,volatile可以确保多个线程对共享变量的操作一致,避免了数据不一致的问题。但是它不能保证原子性,因此对于需要保证原子性的操作,还需要使用其他同步机制,如synchronized关键字或java.util.concurrent.atomic包中的原子类。
▼ 代码测试
五、如何保证原子性
• 解决非原子性问题,可以通过加锁的方式实现,synchronized和ReentrantLock都可以实现。
• Java中还提供了一种方案,在不加锁的情况下,实现++操作的原子性。就是原子类,在java.util.concurrent 包下,定义了许多与并发编程相关的处理类,简称JUC.
5.1 锁实现
synchronized用来保证代码的原子性,主要由三种用法:
• 修饰实例方法:作用于当前对象实例加锁,进入同步代码前要获得当前对象实例的锁。
不同的对象实例之间并不会互相影响,它们的锁是相互独立的。因此,如果不同的线程在不同的对象实例上执行同一个同步方法,它们之间并不会因为共享变量而产生互斥的效果。
• 修饰静态方法:给当前类加锁,在同一时间内,只能有一个线程持有该类对应的 Class 对象的锁,其他线程需要等待锁的释放才能继续执行该静态方法。
应该尽量避免持有锁的时间过长,否则可能会导致其他线程长时间等待,从而影响系统性能。同时,也要注意避免死锁的情况。
• 修饰代码块:指定一个同步锁对象,这个对象可以是具体的Object或者是类.class。在同一时间内,只能有一个线程持有该同步锁对象的锁,其他线程需要等待锁的释放才能继续执行该代码块。
同步锁并不是对整个代码块进行加锁,而是对同步锁对象进行加锁。因此,如果在不同的代码块中使用了相同的同步锁对象,它们之间也会产生互斥的效果。而如果使用不同的同步锁对象,则它们之间并不会产生互斥的效果。
5.2 原子类
在原子类中,最常被问到AtomicInteger的原理是什么?
一句话概括:使用CAS实现。
在AtomicInteger中,CAS操作的流程如下:
1. 调用 incrementAndGet()方法,该方法会通过调用unsafe.getAndAddInt()方法,获取当前 AtomicInteger对象的值val
2. 将 val + 1 得到新的值 next
3. 使用 unsafe.compareAndSwapInt() 方法进行 CAS 操作,将对象中当前值与预期值(步骤1中获取的val)进行比较,如果相等,则将 next赋值给val;否则返回 false
4. 如果CAS操作返回false,说明有其他线程已经修改了AtomicInteger对象的值,需要重新执行步骤 1
▼ 测试代码
import java.util.concurrent.atomic.AtomicInteger;
public class Atomic {
private static AtomicInteger atomicInteger = new AtomicInteger(0);
public static void main(String[] args) {
for (int i = 0; i < 10; i++) {
new Thread() {
@Override
public void run() {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println(atomicInteger.incrementAndGet());
}
}.start();
}
}
}
在 CAS 操作中,由于只有一个线程可以成功修改共享变量的值,因此可以保证操作的原子性,即多线程同时修改AtomicInteger变量时也不会出现竞态条件。这样就可以在多线程环境下安全地对AtomicInteger进行整型变量操作。其它的原子操作类基本都是大同小异。
六、CAS
6.1 什么是CAS?
CAS叫做CompareAndSwap,比较并交换,主要是通过处理器的指令来保证操作的原子性的。
CAS 操作包含三个参数:共享变量的内存地址(V)、预期原值(A)和新值(B),当且仅当内存地址 V 的值等于 A 时,才将 V 的值修改为 B;否则,不会执行任何操作。
在多线程场景下,使用 CAS 操作可以确保多个线程同时修改某个变量时,只有一个线程能够成功修改。其他线程需要重试或者等待。这样就避免了传统锁机制中的锁竞争和死锁等问题,提高了系统的并发性能。
6.1 CAS三大问题
ABA问题
ABA 问题是指一个变量从A变成B,再从B变成A,这样的操作序列可能会被CAS操作误判为未被其他线程修改过。
例如线程A读取了某个变量的值为 A,然后被挂起,线程B修改了这个变量的值为B,然后又修改回了A,此时线程A恢复执行,进行CAS操作,此时仍然可以成功,因为此时变量的值还是A。
怎么解决ABA问题?
加版本号
每次修改变量,都在这个变量的版本号上加1,这样,刚刚A->B->A,虽然A的值没变,但是它的版本号已经变了,再判断版本号就会发现此时的A已经被改过了。
比如使用JDK5中的AtomicStampedReference类或JDK8中的LongAdder类。这些原子类型不仅包含数据本身,还包含一个版本号,每次进行操作时都会更新版本号,只有当版本号和期望值都相等时才能执行更新,这样可以避免 ABA 问题的影响。
循环性能开销
自旋CAS,如果一直循环执行,一直不成功,会给CPU带来非常大的执行开销。
怎么解决循环性能开销问题?
可以使用自适应自旋锁,即在多次操作失败后逐渐加长自旋时间或者放弃自旋锁转为阻塞锁;
只能保证一个变量的原子操作
CAS 保证的是对一个变量执行操作的原子性,如果需要对多个变量进行复合操作,CAS 操作就无法保证整个操作的原子性。
怎么解决只能保证一个变量的原子操作问题?
可以使用锁机制来保证整个复合操作的原子性。
例如,使用 synchronized 关键字或 ReentrantLock 类来保证多个变量的复合操作的原子性。在锁的作用下,只有一个线程可以执行复合操作,其他线程需要等待该线程释放锁后才能继续执行。这样就可以保证整个操作的原子性了。
七、锁分类
锁分类,不全是指Java中的锁,有的指所得特征,有的指锁的实现,有的指锁的状态。
7.1 乐观锁与悲观锁
乐观锁:是一种不加锁的实现,例如原子类,认为不加锁,采用自旋方式尝试修改共享数据,是不会有问题的(无锁实现)
悲观锁:是一种加锁实现,例如synchronized和ReentrantLock,认为不加锁修改共享数据会出现问题。
7.2 可重入锁
可重入锁又称递归锁,当同一个线程,获取锁进入到外层方法后,可以在内层进入到另一个方法(内层方法与外层方法使用的是同一把锁)
synchronized和ReentrantLock都是可重入锁
7.3 读写锁
ReentrantReadWriteLock 读写锁实现
读读不互斥、读写互斥、写写互斥;只要有一个线程写操作,其他线程就不能写,保证读不到脏数据,且最大的保证读的效率。
7.4 分段锁
将锁的粒度进一步细化,提高并发效率
Hashtable是线程安全的,方法上都加了锁,假如有两个线程同时读,也只能一个一个 读,并发效率低。
ConcurrentHashMap没有给方法上加锁,使用hash表中的每一个位置上的第一个对象作为锁对象,这样就可以多个线程对不同位置进行操作,相互不影响,只有对同一个位置进行操作时才互斥。
有多把锁,提高并发操作的效率。
7.5 自旋锁
线程尝试不断的获取锁,当第一次获取不到时,线程不阻塞,尝试继续获取锁,有可能后面几次尝试后,有其他线程释放了锁,此时就可以获取锁,当尝试获取到一定次数后(默认10次),任然获取不到锁,那么可以进入阻塞状态.
synchronized 就是一种自旋锁.
并发量的低的情况下适合自旋.
7.6 共享锁和独享锁
共享锁:锁可以被多个线程共享,读写锁中读锁就是共享锁。
独占锁:一把锁只能有一个线程使用,如读写锁的写锁,synchronized 和 ReentrantLock都是独占锁。
7.7 公平锁和非公平锁
公平锁:可以按照请求获得锁的顺序来得到锁.
非公平锁:不按照请求获得锁的顺序来得到锁.
synchronized是非公平的;ReentrantLock默认是非公平的,但可以通过构造方法参数设置选择公平实现或非公平实现。
八、锁状态
Java中为了synchronized 进行优化,提供了3种锁状态:
偏向锁:一段同步代码块一直由一个线程执行,那么会在锁对象中记录下了线程信息,可以直接获得锁。
轻量级锁:当锁状态为偏向锁时,此时又有其他线程访问,锁状态升级为轻量级锁,线程不阻塞,采用自旋方式获取锁。
重量级锁:当锁状态为轻量级锁时,如果有大量的线程到来,大量的线程自旋,锁状态升级为重量级锁,自旋的线程会进入到阻塞状态,由操作系统去调度管理。
九、synchronized锁实现
9.1 修饰同步代码块
我们使用synchronized的时候,发现不用自己去lock和unlock,是因为JVM帮我们把这个事情做了。
✅synchronized修饰同步代码块,JVM采用monitorenter、monitorexit两个指令来实现同步,在进入到同步代码块时,会执行monitorenter指令,离开同步代码块时或者出现异常时,执行monitorexit指令。
▼ 源代码
public class SyncDemo {
public void main(String[] args) {
synchronized (this) {
int a = 1;
}
}
}
▼ 字节码
public void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC
Code:
stack=2, locals=5, args_size=2
0: aload_0
1: dup
2: astore_2
3: monitorenter
4: iconst_1
5: istore_3
6: aload_2
7: monitorexit
8: goto 18
11: astore 4
13: aload_2
14: monitorexit
15: aload 4
17: athrow
18: return
9.2 修饰同步方法
✅synchronized修饰同步方法时,在指令中会为方法添加ACC_SYNCHRONIZED标志
源代码
public class SyncDemo {
public synchronized void main(String[] args) {
int a = 1;
}
}
字节码
synchronized锁住的是什么呢?
实例对象结构里有对象头,对象头里面有一块结构叫Mark Word,Mark Word指针指向了monitor。
所谓的Monitor其实是一种同步机制,我们可以称为内部锁或者Monitor锁。
monitorenter、monitorexit或者ACC_SYNCHRONIZED都是基于Monitor实现的。
反编译class文件方法:
反编译一段synchronized修饰代码块代码,javap -c -s -v -l ***.class,可以看到相应的字节码指令。
十、Reentrantlock锁实现
10.1 实现原理
ReentrantLock是一种可重入的排它锁,主要用来解决多线程对共享资源竞争的问题;它提供了比synchronized关键字更加灵活的锁机制,其实现原理主要涉及以下三个方面:
✅基本结构
ReentrantLock内部维护了一个Sync对象(AbstractQueuedSynchronizer类的子类),Sync持有锁、等待队列等状态信息,实际上 ReentrantLock的大部分功能都是由Sync来实现的。
* 关于AQS
AQS(抽象同步队列)是一个实现线程同步的框架,并发包中很多类的底层都用到了AQS,在AQS中维护了一个状态state,表示有没有线程访问共享数据,默认0表示没有线程访问。当需要修改状态时会调用compareAndSetState方法。
✅加锁过程
当一个线程调用ReentrantLock的lock()方法时,会先尝试CAS操作获取锁,如果成功则返回;否则,线程会被放入等待队列中,等待唤醒重新尝试获取锁。
如果一个线程已经持有了锁,那么它可以重入这个锁,即继续获取该锁而不会被阻塞。
ReentrantLock通过维护一个计数器来实现重入锁功能,每次重入计数器加1,每次释放锁计数器减1,当计数器为0时,锁被释放。
✅解锁过程
当一个线程调用ReentrantLock的unlock()方法时,会将计数器减1,如果计数器变为了0,则锁被完全释放。如果计数器还大于0,则表示有其他线程正在等待该锁,此时会唤醒等待队列中的一个线程来获取锁。
总结:
ReentrantLock的实现原理主要是基于CAS操作和等待队列来实现。它通过Sync对象来维护锁的状态,支持重入锁和公平锁等特性,提供了比synchronized更加灵活的锁机制,是Java并发编程中常用的同步工具之一。
10.2 ReentrantLock怎么实现公平锁的?
ReentrantLock可以通过构造函数的参数来控制锁的公平性,如果传入 true,就表示该锁是公平的;如果传入 false,就表示该锁是不公平的。
new ReentrantLock()构造函数默认创建的是非公平锁 NonfairSync
同时也可以在创建锁构造函数中传入具体参数创建公平锁 FairSync
10.3 非公平锁和公平锁的区别
● 非公平锁在调用 lock 后,首先就会调用 CAS 进行一次抢锁,如果这个时候恰巧锁没有被占用,那么直接就获取到锁返回了。
● 非公平锁在 CAS 失败后,和公平锁一样都会进入到 tryAcquire 方法,在 tryAcquire 方法中,如果发现锁这个时候被释放了(state == 0),非公平锁会直接 CAS 抢锁,但是公平锁会判断等待队列是否有线程处于等待状态,如果有则不去抢锁,乖乖排到后面。