JAVA中的锁
- 锁的概念
- 锁机制
- 为什么要使用锁
- 锁的种类
- 乐观锁/悲观锁
- 独享锁/共享锁
- 互斥锁/读写锁
- 可重入锁/不可重入锁
- 公平锁/非公平锁
- 分段锁/自旋锁
- CAS/AQS
- synchronized
- 概念
- 应用场景
- 四种使用场景效果对比
- synchronized的特点
- Lock/ReentrantLock对比
- Volatile
- 概念
- Java内存模型
- 线程可见性
- 内存可见性解决方案
锁的概念
锁机制
通过锁机制,能够保证在多线程环境中,在某一个时间点上,只能有一个线程进入临界区代码,从而保证临界区中操作数据的一致性。
所谓的锁,可以理解为内存中的一个整型数,拥有两种状态:空闲状态和上锁状态。加锁时,判断锁是否空闲,如果空闲,修改为上锁状态,返回成功。如果已经上锁,则返回失败。解锁时,则把锁状态修改为空闲状态。
为什么要使用锁
在多线程情况下完成操作时,由于并不是原子操作,所以在完成操作的过程中可能会被打断,造成数据的一致性。
锁的种类
乐观锁/悲观锁
乐观锁/悲观锁并不是特指某两种类型的锁,而是一种锁的思想。
1、乐观锁
乐观锁总是认为不存在并发问题,每次去取数据的时候,总认为不会有其他线程对数据进行修改,因此不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据。
一般会使用“数据版本机制”或“CAS操作”来实现。乐观锁适用于多读的应用类型,这样可以提高吞吐量,因为不加锁会带来大量的性能提升。
-
CAS(下面有说明)
-
数据版本机制
实现数据版本一般有两种,第一种是使用版本号,第二种是使用时间戳,以版本号为例,
一般是在数据表中加上一个数据版本号version字段,表示数据被修改的次数,当数据被修改时,version值会加一。当线程A要更新数据值时,在读取数据的同时也会读取version值,在提交更新时,若刚才读取到的version值为当前数据库中的version值相等时才更新,否则重试更新操作,直到更新成功。
update table set xxx=#{xxx}, version=version+1 where id=#{id} and version=#{version};
2、悲观锁
每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁。比如Java里面的同步关键字synchronized关键字的实现就是悲观锁。悲观锁适合写操作非常多的场景。
如果加锁失败,说明该记录正在被修改,那么当前查询可能要等待或者抛出异常。具体响应方式由开发者根据实际需要决定。
如果成功加锁,那么就可以对记录做修改,事务完成后就会解锁了。
独享锁/共享锁
独享锁是指该锁一次只能被一个线程所持有;共享锁是指该锁可被多个线程所持有。
对于Java ReentrantLock而言,其是独享锁。但是对于Lock的另一个实现类ReadWriteLock,其读锁是共享锁,其写锁是独享锁。
读锁的共享锁可保证并发读是非常高效的,读写,写读,写写的过程是互斥的。
对于Synchronized而言,当然是独享锁。
互斥锁/读写锁
上面讲的独享锁/共享锁就是一种广义的说法,互斥锁/读写锁就是具体的实现。互斥锁在Java中的具体实现就是ReentrantLock;读写锁在Java中的具体实现就是ReadWriteLock。
可重入锁/不可重入锁
1、可重入锁
可重入锁又名递归锁,是指在同一个线程在外层方法获取锁的时候,在进入内层方法会自动获取锁。简单来说就是同一个线程可以重复加锁。对于Java ReetrantLock而言,从名字就可以看出是一个重入锁,其名字是Re entrant Lock 重新进入锁。对于Synchronized而言,也是一个可重入锁。可重入锁的一个好处是可一定程度避免死锁。
public class Test{
Lock lock = new Lock();
public void methodA(){
lock.lock();
...........;
methodB();
...........;
lock.unlock();
}
public void methodB(){
lock.lock(); // 重复获取锁,等待
...........;
lock.unlock();
}
}
2、可重入锁的实现
每次加锁的时候count值加1,每次释放锁的时候count减1,直到count为0,其他的线程才可以再次获取。
public class RSpitnLock implements Lock {
private AtomicReference<Thread> currLock = new AtomicReference<>();
private int count = 0;
@Override
public void lock() {
Thread current = Thread.currentThread();
if (current == currLock.get()) {
count++; // 一个线程多次获取锁,count++
return;
}
while (!currLock.compareAndSet(null, current)) { // 获取锁
}
}
@Override
public void unlock() {
Thread current = Thread.currentThread();
if (current == currLock.get()) {
if (count != 0) {
count--;
} else {
currLock.compareAndSet(current, null); // // 当年线程count=0才能释放锁
}
}
}
}
AtomicReference锁案例
// 1.创建一个锁对象
AtomicReference<Thread> currLock = new AtomicReference<>();
// 2.获取当前线程
Thread thread = Thread.currentThread();
// 3.当年线程获取锁
boolean b = currLock.compareAndSet(null, thread);
// 4.查看锁别那个线程获取
Thread thread1 = currLock.get();
// 5、当前线程和获取锁的线程对比
System.out.println(Thread.currentThread().getName()+"获取锁:"+b);
// System.out.println(thread == thread1);
// 6、启的一个新的线程
Thread update = new Thread(() -> {
// 在新的线程中获取锁
System.out.println(Thread.currentThread().getName()+":获取锁之前,"+currLock.get().getName());
while (!currLock.compareAndSet(null, Thread.currentThread())) {
}
System.out.println(Thread.currentThread().getName()+":获取锁之后,"+currLock.get().getName());
}, "线程2");
update.start();
// 7、休眠3s后main线程释放锁
Thread.sleep(10000);
System.out.println(Thread.currentThread().getName()+"修改3s结束开始释放锁。");
currLock.compareAndSet(thread, null); // 当前线程释放锁
2、不可重入锁
一个线程中多次获取锁,导致死锁。
public static void main(String[] args) {
Lock lock = new Lock();
lock.lock(); // 第一次获取锁成功
// ....
lock.lock(); // 同一个线程再次获取锁失败
}
class Lock {
private boolean isLocked = false;
public synchronized void lock() throws InterruptedException {
while (isLocked) {
wait();
}
isLocked = true;
}
public synchronized void unlock() {
isLocked = false;
notify();
}
}
公平锁/非公平锁
公平锁是指多个线程按照申请锁的顺序来获取锁。非公平锁是指多个线程获取锁的顺序并不是按照申请锁的顺序,有可能后申请的线程比先申请的线程优先获取锁。有可能,会造成优先级反转或者饥饿现象。
对于Java ReetrantLock而言,通过构造函数指定该锁是否是公平锁,默认是非公平锁。非公平锁的优点在于吞吐量比公平锁大。对于Synchronized而言,也是一种非公平锁。
分段锁/自旋锁
1、分段锁
分段锁其实是一种锁的设计,并不是具体的一种锁,对于ConcurrentHashMap而言,其并发的实现就是通过分段锁的形式来实现高效的并发操作,ConcurrentHashMap中的分段锁称为Segment,它即类似于HashMap(JDK7和JDK8中HashMap的实现)的结构,即内部拥有一个Entry数组,数组中的每个元素又是一个链表;同时又是一个ReentrantLock(Segment继承了ReentrantLock)。当需要put元素的时候,并不是对整个hashmap进行加锁,而是先通过hashcode来知道他要放在哪一个分段中,然后对这个分段进行加锁,所以当多线程put的时候,只要不是放在一个分段中,就实现了真正的并行的插入。但是,在统计size的时候,可就是获取hashmap全局信息的时候,就需要获取所有的分段锁才能统计。分段锁的设计目的是细化锁的粒度,当操作不需要更新整个数组的时候,就仅仅针对数组中的一项进行加锁操作。
2、自旋锁
在Java中,自旋锁是指尝试获取锁的线程不会立即阻塞,而是采用循环的方式去尝试获取锁,这样的好处是减少线程上下文切换的消耗,缺点是循环会消耗CPU。
CAS/AQS
1、AQS
AQS全名:AbstractQueuedSynchronizer,是并发容器J.U.C(java.util.concurrent)下locks包内的一个类。它实现了一个FIFO(FirstIn、FisrtOut先进先出)的队列。底层实现的数据结构是一个双向链表。
AQS核心思想是,如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并且将共享资源设置为锁定状态。如果被请求的共享资源被占用,那么就需要一套线程阻塞等待以及被唤醒时锁分配的机制,这个机制AQS是用CLH队列锁实现的,即将暂时获取不到锁的线程加入到队列中。
CLH(Craig,Landin,and Hagersten)队列是一个虚拟的双向队列,虚拟的双向队列即不存在队列实例,仅存在节点之间的关联关系。
AQS是将每一条请求共享资源的线程封装成一个CLH锁队列的一个结点(Node),来实现锁的分配。
AQS就是基于CLH队列,用volatile修饰共享变量state,线程通过CAS去改变状态符,成功则获取锁成功,失败则进入等待队列,等待被唤醒。
2、CAS
CAS(Compare And Swap),即比较并交换。是解决多线程并行情况下使用锁造成性能损耗的一种机制,CAS操作包含三个操作数——内存位置(V)、预期原值(A)和新值(B)。如果内存位置的值与预期原值相匹配,那么处理器会自动将该位置值更新为新值。否则,处理器不做任何操作。CAS也是一种乐观锁的实现。
举个例子:原本表中的数据name的值为toString, 线程将name的读出来,修改为name=Java,在写入的时候先判断name的值是否和修改之前的值(toString)一致,如果一致就提交,否则就重新读取内容再修改,所以CAS操作长时间不成功的话,会一直自旋,相当于死循环了,CPU的压力会很大。
CAS存在一个ABA的问题
线程1读取到内容A,线程也读取到内容A后把A修改为B写入进去,此时线程3读取到最新内容是B,然后把B改为A写入进去,最后线程A在写入的时候发现是A,所以依然可以写入成功。虽然写入成功但是线程A不知道这个值已经被其他线程修改过了,所以这就是典型的ABA的问题。
携带版本号可以有效的防止CAS中ABA的问题
synchronized
概念
synchronized是Java中的关键字,是一种同步锁。用来保证被它修饰的方法或者代码块在任意时刻只能有一个线程调用。
应用场景
-
修饰一个代码块,被修饰的代码块称为同步语句块,其作用的范围是大括号{}括起来的代码,作用的对象是调用这个代码块的对象;
-
修饰一个方法,被修饰的方法称为同步方法,其作用的范围是整个方法,作用的对象是调用这个方法的对象;
-
修改一个静态的方法,其作用的范围是整个静态方法,作用的对象是这个类的所有对象;
-
修改一个类,其作用的范围是synchronized后面括号括起来的部分,作用主的对象是这个类的所有对象。
public class Demo01 { private static Object object = new Object(); public static void main(String[] args) { // 注意,这里是锁的是对象不是代码块,面试题 synchronized (object) { // // 锁对象 // .... } synchronized (Object.class) { // 锁类 } } public synchronized static void test1() { //synchronized(Demo01.class) } public synchronized void test2() { // synchronized(this) } }
四种使用场景效果对比
public class ThreadDemo {
public static void main(String[] args) {
// 创建两个对象
User user1 = new User("张三");
User user2 = new User("李四");
// 创建两个任务对象
MyThread myThread1 = new MyThread(user1);
MyThread myThread2 = new MyThread(user2);
// 启动20个线程
for (int i = 0; i < 10; i++) {
new Thread(myThread1).start();
new Thread(myThread2).start();
}
}
}
class User {
public String name;
public User(String name) {
this.name = name;
}
// public synchronized void add() throws InterruptedException {
// public static synchronized void add() throws InterruptedException {
public void add() throws InterruptedException {
synchronized (User.class) { // this和User.class的区别?
Thread.sleep(1000);
System.out.println("name:" + name + ",threadNmae:" + Thread.currentThread().getName() + "---> add");
Thread.sleep(1000);
}
}
}
class MyThread implements Runnable {
private User user;
public MyThread(User user) {
this.user = user;
}
@Override
public void run() {
try {
user.add();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
内存图
synchronized的特点
1、原子性:原子性的操作执行到一半,并不会因为CPU线程调度而被打断。
2、可见性:释放锁所有的数据都会写回内存,获取锁都会从内存中读取最新数据。
3、可重入锁:它是一个可重入锁
4、重量级锁:
他是一个重量级锁,开销很大,开发过程中尽量少用。
底层是通过一个监视器对象(monitor)完成,wait () , notify ()等方法也依赖于monitor,对象监视器锁(monitor)的本质依赖于底层操作系统的互斥锁(MutexLock)实现,而操作系统实现线程切换需从用户态转换到内核态,上述切换过程较长,所以synchronized效率低&重量级。
5、自动加锁和自动释放锁
Lock/ReentrantLock对比
ReentrantLock加锁演示
// 1.获取锁对象
Lock lock = new ReentrantLock();
// 2.加锁
lock.lock(); // 获取不到锁会一直阻塞
try {
// 3.处理业务 .....
} finally {
// 5.释放锁
lock.unlock();
}
1、使用上的区别:
1)Lock是一个接口,synchronized是Java中的关键字,synchronized是内置的语言实现;
2)synchronized发生异常时,会自动释放线程占用的锁,故不会发生死锁现象。Lock发生异常,若没有主动释放,极有可能造成死锁,故需要在finally中调用unLock方法释放锁;
3)Lock可以让等待锁的线程响应中断,使用synchronized只会让等待的线程一直等待下去,不能响应中断
4)通过Lock可以知道有没有成功获取到锁,synchronized就只能等待。
5)Lock可以提高多个线程进行读操作的效率
2、在锁概念上的区别:
1)可中断锁:响应中断的锁,Lock是可中断锁(体现在lockInterruptibly()方法),synchronized不是。如果线程A正在执行锁中代码,线程B正在等待获取该锁。时间太长,线程B不想等了,可以让它中断自己。
2)公平锁和非公平锁:synchronized是非公平锁,ReentrantLock默认是非平锁,可以设置为公平锁。
3)读写锁:读写锁将对一个资源(如文件)的访问分为2个锁,一个读锁,一个写锁;读写锁使得多个线程的读操作可以并发进行,不需同步。而写操作就得需要同步,提高了效率
ReadWriteLock就是读写锁,是一个接口,ReentrantReadWriteLock实现了这个接口。可通过readLock()获取读锁,writeLock()获取写锁
3、性能比较:
synchronized是一个重量级锁,性能要低于ReentrantLock。
但是synchronized存在也有它的道理,它是因多线程应运而生,它的存在也大幅度简化了Java多线程的开发。它的优势就是使用简单,你不需要显示去加减锁,相比之下ReentrantLock的使用就繁琐的多了,你加完锁之后还得考虑到各种情况下的锁释放,而synchronized就不用关心这些。
Volatile
概念
volatile是Java中的关键字,提供了一种稍弱的同步机制,即volatile变量,用来确保将变量的更新操作通知到其他线程。当把变量声明为volatile类型后,编译器与运行时都会注意到这个变量是共享的,因此不会将该变量上的操作与其他内存操作一起重排序。volatile变量不会被缓存在寄存器或者对其他处理器不可见的地方,因此在读取volatile类型的变量时总会返回最新写入的值。
Java内存模型
Java虚拟机规范中定义了一种Java内存 模型(Java Memory Model,即JMM)来屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的并发效果。Java内存模型的主要目标就是定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量这样的细节。
JMM中规定所有的变量都存储在主内存(Main Memory)中,每条线程都有自己的工作内存(Work Memory),线程的工作内存中保存了该线程所使用的变量的从主内存中拷贝的副本。线程对于变量的读、写都必须在工作内存中进行,而不能直接读、写主内存中的变量。同时,本线程的工作内存的变量也无法被其他线程直接访问,必须通过主内存完成。
线程可见性
多个线程共享一个数据,其中一个线程修改了数据,另一个线程维持的还是旧数据。
public class Demo02 {
public static void main(String[] args) {
MyThread myThread = new MyThread();
myThread.start();
while (true) {
if (myThread.getFlag()) {
System.out.println(Thread.currentThread().getId() + ":flag为true:" + System.currentTimeMillis());
}
}
}
}
class MyThread extends Thread {
private boolean flag = false;
@Override
public void run() {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
flag = true;
System.out.println("线程1修改为true");
}
public boolean getFlag() {
return this.flag;
}
}
对于普通的共享变量来讲,线程A将其修改为某个值发生在线程A的本地内存中,此时还未同步到主内存中去;而线程B已经缓存了该变量的旧值,所以就导致了共享变量值的不一致。
解决这种共享变量在多线程模型中的不可见性问题,较粗暴的方式自然就是加锁,但是此处使用synchronized或者Lock这些方式太重量级了,比较合理的方式其实就是volatile。
内存可见性解决方案
1、加锁
while (true) {
synchronized (myThread) {
if (myThread.getFlag()) {
System.out.println(Thread.currentThread().getId() + ":flag为true:" + System.currentTimeMillis());
}
}
}
实现原理
2、volatile
class MyThread extends Thread {
private volatile boolean flag = false;
@Override
public void run() {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
flag = true;
System.out.println("线程1修改为true");
}
public boolean getFlag() {
return this.flag;
}
}
实现原理
当对volatile变量执行写操作后,JMM会把工作内存中的最新变量值强制刷新到主内存
写操作会导致其他线程中的缓存无效。
这样,其他线程使用缓存时,发现本地工作内存中此变量无效,便从主内存中获取,这样获取到的变量便是最新的值,实现了线程的可见性。
后记
👉👉💕💕美好的一天,到此结束,下次继续努力!欲知后续,请看下回分解,写作不易,感谢大家的支持!! 🌹🌹🌹