目录
Synchronized 加到对象上面,加到类上,加到这个 this 上面有什么区别?
当一个线程进入一个对象的synchronized方法A之后,其它线程是否可进入此对象的synchronized方法B?为什么?
Synchronized、volatile、CAS、Lock 比较
独占锁 synchronized 共享锁ReentrantReadWriteLock.readLock()
公平锁ReentrantLock和非公平锁synchronized
可重入锁ReentrantLock/synchronized和非可重入锁
Java并发编程1
并发编程:三个特征原子性,可见性,有序性。
原子性
一个线程在CPU操作不可暂停、不可中断,要不执行完成,要不不执行。
保证原子性:synchronized方法、Lock接口
面试官拿笔写了段代码,下面这几句代码能保证原子性吗?
int i = 2;
int j = i;
i++;
i = i + 1;
第一句是基本类型赋值操作,必定是原子性操作。
第二句先读取i的值,再赋值到j,两步操作,不能保证原子性。
第三和第四句其实是等效的,先读取i的值,再+1,最后赋值到i,
三步操作了,不能保证原子性。
JMM只能保证基本的原子性,如果要保证一个代码块的原子性,
提供了monitorenter 和 moniterexit 两个字节码指令,也就是 synchronized 关键字。因此在 synchronized 块之间的操作都是原子性的。
并发的三大特性 -- java面试_下面哪个关键字同时满足java并发的三种特性-CSDN博客
可见性
一个线程修改共享变量的值,对另一个线程可见。
保证可见性:volatile、final、 不建议加锁synchronized、。
Java是利用volatile关键字来提供可见性的。
当变量被volatile修饰时,这个变量被修改后会立刻刷新到主内存,当其它线程需要读取该变量时,会去主内存中读取新值。而普通变量则不能保证这一点。
final和synchronized也能实现可见性。
synchronized的原理是,在执行完,进入unlock之前,必须将共享变量同步到主内存中。
final修饰的字段,一旦初始化完成,如果没有对象逸出(指对象为初始化完成就可以被别的线程使用),那么对于其他线程都是可见的。
有序性
即程序执行的顺序按照代码的先后顺序执行。
volatile 禁止指令重排,但不满足原子性;
计算机为什么重排序代码?
指令重排序在单线程是没有问题的,不会影响执行结果,而且还提高了性能。
但是在多线程的环境下就不能保证一定不会影响执行结果了。所以在多线程环境下,就需要禁止指令重排序。
重排序遵循原则:as-if-serial【顺序一致原则】与happens-before原则 【发生之前】
Java语言规范
单线程语法、语意树有依赖就不重排序,多线程复杂。
重排序的种类分为三种,分别是:编译器重排序,指令级并行的重排序,内存系统重排序。整个过程如下所示:
在Java中,可以使用synchronized或者volatile保证多线程之间操作的有序性。
实现原理有些区别:
volatile关键字是使用内存屏障达到禁止指令重排序,以保证有序性。
synchronized的原理是,一个线程lock之后,必须unlock后,其他线程才可以重新lock,使得被synchronized包住的代码块在多线程之间是串行执行的。
导致并发程序的根本原因是什么
根本原因主要是对共享资源的并发访问以及执行顺序的不确定性。
共享资源访问冲突
多个线程同时访问和操作同一份共享资源(如内存中的变量、文件、数据库记录等),如果没有正确的同步机制,就可能导致数据不一致。
例如,两个线程同时对一个共享变量进行读写操作。一个线程读取变量的值,在它还没来得及更新这个值之前,另一个线程也读取了相同的值,然后两个线程分别进行更新操作,后一个线程的更新会覆盖前一个线程的更新,导致数据丢失或错误。
执行顺序不确定性
由于线程调度是由操作系统决定的,线程执行的顺序是不确定的。
在并发程序中,不同的执行顺序可能会导致不同的结果。
例如,有线程 A 和线程 B,它们都对共享变量 x 和 y 进行操作。
如果线程 A 先读取 x,然后线程 B 读取 x 和 y 并进行计算,
之后线程 A 再读取 y 并进行计算,与线程 A 先读取 x 和 y 进行计算,
然后线程 B 再进行操作,这两种不同的执行顺序可能会产生不同的计算结果。
并发中变量不安全,可以声明常量:final
有序性 volatile
内存可见性 volatile
原子性 synchronized、lock解决
原子性和锁区别,加锁是为了什么
原子性:
- 原子性是指一个操作是不可中断的,要么全部执行成功,要么全部不执行。
就像一个不可分割的基本单元,在执行过程中不会被其他操作干扰。
例如,在 Java 中,对long和double类型之外的基本数据类型的简单赋值操作是原子性的,如int a = 5;。这些操作在执行时不会被其他线程中断,能保证数据的完整性。
- 它是一种操作性质,关注的是操作本身是否能完整执行,不涉及多个操作之间的协调。
锁:
- 锁是一种同步机制,用于控制多个线程对共享资源的访问。它通过限制线程对共享资源的访问权限来实现线程间的协调。
例如,synchronized关键字和ReentrantLock等都是 Java 中的锁机制。
- 当一个线程获取了锁,其他线程就不能访问被这个锁保护的共享资源,直到锁被释放。锁的目的是为了保证共享资源在多个线程访问时的正确性和一致性,涉及到多个线程之间的互斥和同步关系。
为什么要加锁
保证数据一致性:
当多个线程访问和修改共享资源时,如果没有锁机制,就可能出现数据不一致的情况。 例如,在一个银行账户系统中,有多个线程代表不同的用户进行取款操作。如果没有锁, 两个线程可能同时读取账户余额,然后分别进行取款计算,最后更新余额,这可能导致 余额计算错误。加锁可以确保在一个线程操作账户余额时,其他线程不能同时进行操作, 从而保证余额数据的一致性。
实现线程间的同步和协调:
有些情况下,需要多个线程按照一定的顺序或规则来访问共享资源。
锁可以用于实现这种同步关系。
例如,在生产者 - 消费者模型中,生产者线程和消费者线程共享一个缓冲区。
通过加锁,可以保证生产者在向缓冲区添加产品时,消费者不能同时从缓冲区获取产品,并且可以通过锁机制实现当缓冲区为空时消费者等待,当缓冲区满时生产者等待的同步规则。
原子操作即一条或者一系列不可以被中断的指令。
加锁就是当多线程并发访问同一个资源时,用于保证数据一致性的一种机制,
当给资源加锁时,只有一个线程能够访问资源,其他线程将阻塞保证
原文链接:操作系统-原子性与锁机制_原子指令 锁 关系-CSDN博客
线程安全:共享资源安全 , 可以分为无锁 和有锁
锁问题
锁机制和无锁机制
死锁(Dead Lock)
定义:多个进程由于竞争资源而造成阻塞的现象,如果无外力作用,这种局面就会一直持续下去。
两个或两个以上的进程在执行过程中,由于竞争资源或者由于彼此通信而造成的一种阻塞的现象,若无外力作用,它们都将无法推进下去。
诊断死锁方案: jps和jstack
Jps java进程ID。
Jstack-l |2336 查看线程堆栈信息
可视化:jconsole、VisualVM(jdk/bin/)
指的是两个或两个以上的运算单元(进程、线程或协程),
互相持有对方所需的资源,导致它们都无法向前推进,从而导致永久阻塞的问题就是死锁。
比如:线程 1 拥有了锁 A 的情况下试图获取锁 B,
而线程 2 又在拥有了锁 B 的情况下试图获取锁 A,
这样双方就进入相互阻塞等待的情况,如下图所示:
造成死锁的原因有四个,破坏其中一个即可破坏死锁
互斥条件:指进程对所分配到的资源进行排它性使用,
即在一段时间内某资源只由一个进程占用。
如果此时还有其它进程请求资源,则请求者只能等待,
直至占有资源的进程释放。
请求和保持条件:指进程已经保持至少一个资源,但又提出了新的资源请求,
而该资源已被其它进程占有,此时请求进程阻塞,
但又对自己已获得的其它资源保持占有。
不剥夺条件:指进程已获得的资源,在未使用完之前,不能被剥夺,
只能在使用完时由自己释放。
循环等待: 指在发生死锁时,必然存在一个进程对应的环形链。
竞争与协作
由于多线程执行操作共享变量的这段代码可能会导致竞争状态,
因此我们将此段代码称为临界区(critical section),
它是访问共享资源的代码片段,一定不能给多线程同时执行。
互斥(mutualexclusion):也就说保证一个线程在临界区执行时,其他线程应该被阻止进入临界区。说白了,就是这段代码执行过程中,最多只能出现一个线程。
同步:就是并发进程/线程在一些关键点上可能需要互相等待与互通消息,
这种相互制约的等待与互通信息称为进程/线程同步
同步与互斥是两种不同的概念:
同步就好比:「操作 A 应在操作 B 之前执行」,
「操作 C 必须在操作 A 和操作 B 都完成之后才能执行」等;
互斥就好比:「操作 A 和操作 B 不能在同一时刻执行」;
互斥与同步的实现和使用
在进程/线程并发执行的过程中,进程/线程之间存在协作的关系,
例如有互斥、同步的关系。
为了实现进程/线程间正确的协作,操作系统必须提供实现进程协作的措施和方法,主要的方法有两种:
锁:加锁、解锁操作
信号量:P、V 操作;【红绿灯】
生产者-消费者模型
锁
任何想进入临界区的线程,必须先执行加锁操作。
若加锁操作顺利通过,则线程可进入临界区;
在完成对临界资源的访问后再执行解锁操作,以释放该临界资源。
信号量实现临界区的互斥访问。
对于两个并发线程,互斥信号量的值仅取 1、0 和 -1 三个值,分别表示:
如果互斥信号量为 1,表示没有线程进入临界区;
如果互斥信号量为 0,表示有一个线程进入临界区;
如果互斥信号量为 -1,表示一个线程进入临界区,另一个线程等待进入。
通过互斥信号量的方式,就能保证临界区任何时刻只有一个线程在执行,就达到了互斥的效果。
生产者-消费者模型
生产者在生成数据后,放在一个缓冲区中;
消费者从缓冲区取出数据处理;
任何时刻,只能有一个生产者或消费者可以访问缓冲区;
我们对问题分析可以得出:
任何时刻只能有一个线程操作缓冲区,说明操作缓冲区是临界代码,需要互斥;
缓冲区空时,消费者必须等待生产者生成数据;
缓冲区满时,生产者必须等待消费者取出数据。说明生产者和消费者需要同步。
读者-写者的问题描述:
读者只会读取数据,不会修改数据,而写者即可以读也可以修改数据。
「读-读」允许:同一时刻,允许多个读者同时读
「读-写」互斥:没有写者时读者才能读,没有读者时写者才能写
「写-写」互斥:没有其他写者时,写者才能写
无锁
资源不共享
实现:1.声明常量,
2.局部变量放在自己方法里面 栈里面,
3.ThreadLocal
4.cas
- 局部变量:仅仅存在于每个线程的工作内存中,不存在共享的情况,
自然就没有并发安全问题。
- 常量:不可变对象一旦创建就不会改变,无论多个线程对他操作,
他都是不可变的,自然也没有并发问题
- ThreadLocal的本质是每个线程都有自己的副本,每个线程的副本是互不影响的,
自然就没有并发问题
- cas 在Java中的实现则通常是指以英文Atomic为前缀的一系列类,
它们都采用了CAS的思想。Atomic使用的是Unsafe类提供硬件级别的原子操作。来看看Unsafe的方法通过v获取一个旧的值,接着CAS操作来对数据进行比较并置换,操作失败就进入while循环,直到成功为止。
有锁
单体应用中 synchronized、lock
分布式应用中 分布式锁 Redis/zookeeper
Synchronized lock
共享锁 读锁
排它锁 写锁
加锁存在性能/安全性问题?
- 锁得粒度优化-锁的范围缩小
- 无锁编程-乐观锁 cas
- 偏向锁/轻量级锁/重量级锁(减少锁的竞争)-锁升级
- 锁消除/锁膨胀-编译器层面的优化
- 读写锁(读多写少的情况下) -
读写锁ReentrantReadWriteLock中的
读锁ReadLock是共享锁,
写锁WriteLock是独享锁。
- 公平锁、非公平锁-减少线程的阻塞唤醒
锁的特性
重入锁(防止死锁)- 可重入锁/非可重入锁 ReentrantLock/Synchronized
Synchronized
对象锁,采用互斥方式让同一时刻至多只有一个线程能持有对象锁,
其他线程再想获取这个对象锁时就会阻塞住。
- 普通方法/实例方法,锁是当前实例对象
- 同步方法块, 锁是Synchronized括号里匹配的对象/当前实例对象
- 静态方法, 锁是当前类的Class对象
对象在内存中的实现
在hotspot虚拟机中,对象在内存中的布局分为三块区域:对象头、实例数据和对齐填充
对象头(Object Header):
- 标记字段(Mark Word):存储对象的哈希码、分代年龄、锁状态标志等信息。在 64 位 JVM 中,Mark Word 一般占 8 字节。例如,当对象处于无锁状态时,部分位用于存储对象的哈希码等;当对象被偏向锁锁定时,这些位会用于存储偏向线程 ID 等相关信息。【分代年龄】
- 类型指针(如果对象是数组,还有数组长度):类型指针指向对象的类元数据,JVM 通过这个指针来确定对象是哪个类的实例。这部分大小在开启指针压缩的情况下是 4 字节,未开启时是 8 字节。如果是数组对象,还有额外的 4 字节(32 位)或 8 字节(64 位)来记录数组长度。
实例数据(Instance Data):
- 这是对象真正存储有效数据的部分,包括从父类继承下来的和子类中定义的各种类型的字段内容。例如,对于一个简单的Person类,里面有name(String类型)和age(int类型)两个字段,那么实例数据部分就会存储对应的姓名和年龄的值。这些字段在内存中的排列顺序会受到虚拟机分配策略和字段类型的影响。
对齐填充(Padding):
- 由于 JVM 要求对象起始地址必须是 8 字节的整数倍(在 64 位虚拟机下),所以如果对象头和实例数据加起来没有对齐,就需要通过对齐填充来保证对象占用内存大小是 8 字节的倍数。例如,对象头和实例数据部分一共占用了 20 字节,那么就需要填充 4 字节来达到 24 字节(是 8 的倍数)。
偏向锁/轻量级锁/重量级锁
JDK 1.6 为了减少获得锁和释放锁所带来的性能消耗,在JDK 1.6里引入了4种锁的状态:
无锁、偏向锁、轻量级锁和重量级锁,它会随着多线程的竞争情况逐渐升级,但不能降级。
偏向锁: 无实际竞争,且将来只有第一个申请锁的线程贴个标签(线程ID)。 1
重量级锁:1.6后竞争比较激烈jvm自己判断,自适应。(自旋超过10次或者超过cpu2分之一自动升级;) 10
用户态 /内核态
一般程序先调用用户态。
轻量级锁是在用户态(自旋 while)
重量级:不消耗cpu,轮到你了才能给你解冻。
https://zhuanlan.zhihu.com/p/112649693
锁升级
jdk1.6对锁的实现引入了大量的优化,如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销。
锁主要存在四中状态,依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,他们会随着竞争的激烈而逐渐升级。
其中锁可以升级不可降级,这种策略是为了提高获得锁和释放锁的效率。
(HotSpot JVM锁是可以降级的,但是锁升降级效率较低,如果频繁升降级的话对JVM性能会造成影响)
Java 中 Synchronized 的锁升级过程如下:无锁——>偏向锁——>轻量级锁——>重量级互斥锁
链接:https://www.jianshu.com/p/b516a981a1a7
无锁-> 偏向锁(线程ID)->轻量级(cas线程有竞争)>-重量级锁(自旋10次)
锁争夺也就是对象头指向的Monitor对象的争夺,一旦有线程持有了这个对象,
标志位修改为1,就进入偏向模式,
同时会把这个线程的ID记录在对象的Mark Word中。
这个过程是采用了CAS乐观锁操作的,每次同一线程进入,
虚拟机就不进行任何同步的操作了,对标志位+1就好了,不同线程过来,
CAS会失败,也就意味着获取锁失败。
如何实现
Monitor 监视器 jvm 提供的,C++实现的。
Synchronized 在汇编语言中,会在同步块的前后生成 monitorenter 和monitorexit这两个字节码指令。/ˈentə(r)/
Monitor内部有三个属性,分别是owner、entrylist、waitset
Owner是关联获得锁的线程,并且只能关联一个;
entrylist关联处于阻塞状态的线程;
Waitset关联处于waiting状态的线程
synchronized锁对象的时候有个计数器,他会记录下线程获取锁的次数,
在执行完对应的代码块之后,计数器就会-1,直到计数器清零,就释放锁了。
那可重入有什么好处呢?
可以避免一些死锁的情况,也可以让我们更好封装我们的代码。
不可中断性
不可中断就是指,一个线程获取锁之后,另外一个线程处于阻塞或者等待状态,
前一个不释放,后一个也一直会阻塞或者等待,不可以被中断。
值得一提的是,Lock的tryLock方法是可以被中断的。
同步方法
不知道大家注意到方法那的一个特殊标志位没,ACC_SYNCHRONIZED。
同步方法的时候,一旦执行到这个方法,就会先判断是否有标志位,
然后, ACC_SYNCHRONIZED会去隐式调用刚才的两个指令:
monitorenter和monitorexit。
所以归根究底,还是monitor对象的争夺。
monitor监视器源码是C++写的
同步代码
对象头,他会关联到一个monitor对象。
当我们进入一个人方法的时候,执行monitorenter,就会获取当前对象的一个所有权,这个时候monitor进入数为1,当前的这个线程就是这个monitor的owner。
如果你已经是这个monitor的owner了,你再次进入,就会把进入数+1.
同理,当他执行完monitorexit,对应的进入数就-1,直到为0,
才可以被其他线程持有。
所有的互斥,其实在这里,就是看你能否获得monitor的所有权,
一旦你成为owner就是获得者。
缺点
锁的不能手动释放,只在程序正常执行完成和抛出异常时释放锁;
锁是不能设置超时;
不能中断一个正在试图获得锁的线程;
无法知道是否成功获取到锁;
原文链接:java 关于锁常见面试题_锁面试题-CSDN博客
Synchronized 加到对象上面,加到类上,加到这个 this 上面有什么区别?
类 、静态方法、非静态方法(普通方法)
静态变量、静态方法都是类本身,
非静态的调用需要 new A()[对象],才能访问
Class A{
// 非静态方法(普通方法)
public void method() {}
// 静态方法
public static void method1() {}
A a =new A();
//a是实例对象,method是实例方法;method1静态方法就是非实例方法a.method();
}
静态的属于类,所以也叫类变量,在声明类的时候,就堆中分配内存了。
- 修饰静态方法,对当前类的Class对象加锁
含义:
当synchronized关键字修饰一个静态方法时,锁是加在类对象上的。因为静态方法是属于类的,而不是属于某个具体的对象实例。
示例和效果:
class MyClass {
public static synchronized void staticMethod() {
// 执行的代码
}
}
不管创建了多少个MyClass的对象实例,当一个线程访问MyClass.staticMethod()时,
它获取的是类级别的锁。这样一来,其他任何线程在访问这个静态的synchronized方法或者其他被synchronized修饰的静态方法时,都需要等待这个锁释放。
- 加在对象方法上(实例方法),对当前实例对象this加锁。
含义:
当synchronized关键字修饰一个非静态的实例方法时,它锁定的是当前对象实例。
例如,有一个class MyClass,其中有一个synchronized实例方法method(),当一个线程访问某个MyClass对象的method()方法时,就获取了这个对象的锁。
示例和效果:
class MyClass {
public synchronized void method() {
// 执行的代码
}
}
如果有两个线程分别访问同一个MyClass对象的method()方法,那么这两个线程会竞争该对象的锁。一个线程获取锁进入方法执行后,另一个线程必须等待锁释放才能进入。
但如果是两个线程访问不同的MyClass对象的method()方法,它们不会相互阻塞,因为它们获取的是不同对象的锁。
- 修饰代码块,指定一个加锁的对象,给对象加锁
含义:
在代码块中使用synchronized (this),这里的this指的是当前对象。这意味着进入这个同步代码块的线程获取的是当前对象的锁,和在实例方法上添加synchronized关键字的效果类似,都是对当前对象加锁。
示例和效果:
class MyClass {
public void method() {
synchronized (this) {
// 执行的代码
}
}
}
当一个线程进入这个同步代码块时,它获取了当前MyClass对象的锁。如果还有其他线程也想进入这个对象的这个同步代码块或者这个对象的其他被synchronized修饰的实例方法,就需要等待锁释放。这种方式可以更灵活地控制同步代码的范围,只对需要同步的部分代码进行加锁,而不是整个方法。
其实就是锁方法、锁代码块和锁类对象,
锁方法和代码块是加锁当前实例对象
锁静态方法是加锁当前类的Class对象
当一个线程进入一个对象的synchronized方法A之后,其它线程是否可进入此对象的synchronized方法B?为什么?
如果方法a和方法b都是实例方法,并且都被synchronized修饰,那么其他线程不可以进入方法b。因为在 Java 中,对于实例方法的synchronized关键字,是通过给对象加锁来实现同步的。当一个线程进入对象的一个synchronized实例方法时,它获取了这个对象的锁。在这个线程释放锁之前,其他线程无法获取该对象的锁,也就不能进入这个对象的其他synchronized实例方法。
但是,如果方法b是静态方法,并且被synchronized修饰,
或者方法b没有被synchronized修饰,那么其他线程可以进入方法b。
因为静态方法的synchronized锁是加在类对象上的,和实例方法的对象锁不同;
而没有被synchronized修饰的方法本身就不受这种对象锁的限制。
实例方法是什么?
在 Java 中,实例方法是属于类的对象(实例)的方法。
当你创建一个类的多个对象时,每个对象都有自己独立的实例方法。
实例方法可以访问和操作所属对象的实例变量(非静态变量)。
例如:
class Car {
private String color;
// 这是一个实例方法
public void setColor(String c) {
color = c;
}
public String getColor() {
return color;
}}
在这个例子中,setColor和getColor就是实例方法。你可以通过Car类的对象来调用它们,
比如:
Car myCar = new Car();
myCar.setColor("red");
System.out.println(myCar.getColor());
每个Car对象都有自己的color属性,
实例方法setColor和getColor用于操作和获取这个属性的值。
它们的操作是基于具体的对象实例的,不
同的Car对象之间的这些实例方法的操作结果不会相互影响(除非通过一些特殊的方式,如静态变量来共享状态)。
Voliatile [ˈvɒlətaɪl]
保证不了线程安全,保证变量数据的可见性和有序性
可见性-线程之间可见 jmm内存地址[栈]和堆主内存刷新过程-嗅探机制
有序性-数据读写内存屏障,写之前写之后会加loadstore和loadload进行保证。
- volatile修饰符适用于以下场景:某个属性被多个线程共享,
其中有一个线程修改了此属性,其他线程可以立即得到修改后的值,
比如boolean flag;或者作为触发器,实现轻量级同步。
- volatile属性的读写操作都是无锁的,它不能替代synchronized,
因为它没有提供原子性和互斥性。因为无锁,
不需要花费时间在获取锁和释放锁上,所以说它是低成本的。
- volatile可以在单例双重检查中实现可见性和禁止指令重排序,从而保证安全性。
相比synchronized的加锁方式来解决共享变量的内存可见性问题,
volatile就是更轻量的选择,他没有上下文切换的额外开销成本。
使用volatile声明的变量,可以确保值被更新的时候对其他线程立刻可见。
volatile使用内存屏障来保证不会发生指令重排,解决了内存可见性的问题。
- 保证线程间变量的可见性。
对变量执行写操作会立马刷到主内存中。底层 汇编语言LOCK前缀指令,嗅探机制
- 有序性,
禁止CPU进行指令重排序, volatile 赋值后多执行了一个“load addl $0x0, (%esp)”操作,这个操作相当于一个内存屏障(指令重排序时不能把后面的指令重排序到内存屏障之前的位置)。锁总线 ->cpu缓存一致性 -> 读屏障和写屏障。
可见性
volatile修饰的变量,当一个线程改变了该变量的值,其他线程是立即可见的。普通变量则需要重新读取才能获得最新值。
volatile保证可见性的流程大概就是这个一个过程:
volatile一定能保证线程安全吗?
volatile不能一定能保证线程安全。不满足原子性,前面说过了count++不是原子性操作,会当做三步,先读取count的值,然后+1,最后赋值回去count变量。需要保证线程安全的话,需要使用synchronized关键字或者lock锁,给count++这段代码上锁:
private static synchronized void add() {
count++;
}
有序性
即程序执行的顺序按照代码的先后顺序执行。
as-if-serial语义,不管怎么重排序,(单线程)程序的执行结果不能被改变。
为了使指令更加符合CPU的执行特性,最大限度的发挥机器的性能,提高程序的执行效率,只要程序的最终结果与它顺序化情况的结果相等,那么指令的执行顺序可以与代码逻辑顺序不一致,这个过程就叫做指令的重排序。
重排序的种类分为三种,分别是:编译器重排序,指令级并行的重排序,内存系统重排序。整个过程如下所示:
所以在多线程环境下,就需要禁止指令重排序。
重点是Java内存模型(JMM)的工作方式,三大特征,还有volatile关键字。为什么喜欢问volatile关键字呢,因为volatile关键字可以扯出很多东西,比如可见性,有序性,还有内存屏障等等。
cpu 乱序执行
简单说就是程序里面的代码的执行顺序,有可能会被编译器、CPU 根据某种策略调整顺序(俗称,“打乱”)——虽然从单线程的角度看,乱序执行不影响执行结果。
用在哪里,单例会用吗?
1、中断服务程序中修改的供其它程序检测的变量需要加volatile;
private static volatile Singleton singleton;
private Singleton() {
}
public static Singleton getInstance() {
if (singleton == null) {
synchronized (Singleton.class) { //1
if (singleton == null) { //2
singleton = new Singleton(); //3
}
}
}
return singleton;
}
}
双重检查锁定失败的问题并不归咎于 JVM 中的实现 bug,而是归咎于 Java 平台内存模型。内存模型允许所谓的“无序写入”,这也是这些习语失败的一个主要原因。
执行命令时虚拟机可能会对以上3个步骤交换位置 最后可能是132这种 分配内存并修改指针后未初始化 多线程获取时可能会出现问题。
当线程A进入同步方法执行singleton = new Singleton();代码时,恰好这三个步骤重排序后为1 3 2,
如果一个字段被声明成volatile,Java线程内存模型确保所有线程看到这个变量的值是一致的,同时还会禁止指令重排序
所以使用volatile关键字会禁止指令重排序,可以避免这种问题。使用volatile关键字后使得 singleton = new Singleton();语句一定会按照上面拆分的步骤123来执行。
原文链接:volatile关键字在单例模式(双重校验锁)中的作用_双重校验锁volatile关键字的作用-CSDN博客
CPU三级缓存
CPU Cache缓存有3级 L1、L2 、L3 (L3是多个核心共享的)
程序执行时,会先将内存中的数据加载到共享的 L3 Cache 中,再加载到每个核心独有的 L2 Cache,最后 进入到最快的 L1 Cache,之后才会被 CPU 读取,越靠近 CPU 核心的缓存其访问速度越快。
缓存行
一行8字节
系统底层如何实现数据一致性?
1.硬件层面
- 缓存一致性协议:在多核处理器系统中,每个核心都有自己的缓存。像 MESI 协议(修改 Modified、独占 Exclusive、共享 Shared、无效 Invalid)用于维护多个缓存中数据的一致性。当一个核心修改了缓存中的数据,该协议会通过一系列状态转换操作,如将其他核心中对应的缓存行设为无效状态,来确保数据的一致性。
2.操作系统层面
- 文件系统事务:文件系统使用事务制保证数据一致性。以数据库的事务为例,当执行一系列文件操作(如写入多个文件块)时,会将这些操作看作一个事务。如果事务中的某个操作失败,系统会回滚所有已完成的操作,使得文件系统的数据状态恢复到事务开始之前,避免数据不一致。
- 虚拟内存管理:操作系统通过页表和内存映射来管理虚拟内存。当多个进程共享内存区域时,系统会协调对这些共享区域的访问。例如,在写时复制(Copy - on - Write)机制中,当一个进程试图修改共享的内存页时,系统会为该进程复制一个新的页面来进行修改,保证其他进程所看到的数据不受影响,从而维护数据一致性。
3.数据库管理系统层面
- ACID 特性:数据库系统遵循原子性(Atomicity)、一致性(Consistency)、隔离性(Isolation)和持久性(Durability)原则。例如在关系型数据库中,通过日志文件和事务回滚机制来实现原子性和一致性。当事务执行过程中出现故障,系统可以根据日志恢复到事务开始前的状态或者完成未完成的事务,以此确保数据一致性。
内存屏障是什么?
内存屏障(Memory Barrier)是一种 CPU 指令,用于控制多个 CPU 核心或者硬件设备对内存访问的顺序。
作用
它可以防止指令重排序和保证数据的可见性。
在现代计算机系统中,为了提高性能,CPU 和编译器可能会对指令进行重新排序,
只要不改变单线程程序的语义。
但在多线程或多处理器环境下,这种重排序可能会导致数据不一致。
指令重排序
- 例如,在没有内存屏障的情况下,线程 A 中对变量 x 的写操作和对变量 y 的写操作可能会被编译器或 CPU 重新排序。而在另一个线程 B 中,可能会以错误的顺序读取到 x 和 y 的值。内存屏障可以阻止这种跨线程影响的指令重排序。
数据可见性
- 当一个 CPU 核心修改了共享变量的值后,其他核心可能由于缓存等原因不能马上看到这个更新。内存屏障可以保证在屏障之后的内存操作,能够看到屏障之前的内存操作的结果,使共享变量的修改能及时被其他核心感知。
类型
- 写内存屏障(Store Memory Barrier):保证在屏障之前的写操作,一定先于屏障之后的写操作执行。
- 读内存屏障(Load Memory Barrier):保证在屏障之后的读操作,一定能看到屏障之前的写操作的结果。
- 全内存屏障(Full Memory Barrier):兼具写内存屏障和读内存屏障的功能,是最严格的一种内存屏障。
八种内存交互操作
画张图给你看吧:
- lock(锁定),作用于主内存中的变量,把变量标识为线程独占的状态。
- read(读取),作用于主内存的变量,把变量的值从主内存传输到线程的工作内存中,以便下一步的load操作使用。
- load(加载),作用于工作内存的变量,把read操作主存的变量放入到工作内存的变量副本中。
- use(使用),作用于工作内存的变量,把工作内存中的变量传输到执行引擎,每当虚拟机遇到一个需要使用到变量的值的字节码指令时将会执行这个操作。
- assign(赋值),作用于工作内存的变量,它把一个从执行引擎中接受到的值赋值给工作内存的变量副本中,每当虚拟机遇到一个给变量赋值的字节码指令时将会执行这个操作。/əˈsaɪn/
- store(存储),作用于工作内存的变量,它把一个从工作内存中一个变量的值传送到主内存中,以便后续的write使用。
- write(写入):作用于主内存中的变量,它把store操作从工作内存中得到的变量的值放入主内存的变量中。
- unlock(解锁):作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定
CAS(Compare and Swap)
CAS(Compare and Swap)即比较并替换,[kəmˈpeə(r)] [swɒp]
(期望值=内存的值),更新值
它体现的是一种乐观锁的思想,在无锁的情况下保证线程操作共享数据的原子性。
思想:三个参数,一个当前内存值V、旧的预期值A、即将更新的值B,当且仅当预期值A和内存值V相同时,将内存值修改为B并返回true,否则什么都不做,并返回false
乐观锁/自旋锁
在JUC包下实现的很多类都用到了CAS操作。
AQS(AbstractQueuedSynchronizer)、ReentrantLock、AtomicXXX类。
在操作共享变量时候使用自旋锁,效率高一些。
底层实现 lock cmpxchg 指令
【native 方法,调用c++ unsafe方法 ->汇编指令 cmpxchg 】
缺点:①循环时间长开销大。
②ABA 【一个值原来是A, 变成B,又改成了A,中间被别改过】。
解决方案:版本号;jdk atomic包下一个类AtomicStampedReference
使用场景:AQS(AbstractQueuedSynchronizer)、ReentrantLock、AtomicXXX、ConcurrentHashMap类都用到。
经典ABA问题
解决:版本号(比较的时候也比较版本号)或者flag
从Java 1.5开始,JDK的Atomic包里提供了一个类AtomicStampedReference来解决ABA问题。 [əˈtɒmɪk] [stæmpt]
ThreadLocal
ThreadLocal可以理解为线程本地变量,他会在每个线程都创建一个副本,那么在线程之间访问内部副本变量就行了,做到了线程之间互相隔离,相比于synchronized的做法是用空间来换时间。
ThreadLocal有一个静态内部类ThreadLocalMap,
ThreadLocalMap又包含了一个Entry数组,
Entry本身是一个弱引用,他的key是指向ThreadLocal的弱引用,Entry具备了保存key value键值对的能力。
弱引用的目的是为了防止内存泄露,
如果是强引用那么ThreadLocal对象除非线程结束否则始终无法被回收,
弱引用则会在下一次GC的时候被回收。
但是这样还是会存在内存泄露的问题,假如key和ThreadLocal对象被回收之后,entry中就存在key为null,但是value有值的entry对象,但是永远没办法被访问到,同样除非线程结束运行。
但是只要ThreadLocal使用恰当,在使用完之后调用remove方法删除Entry对象,实际上是不会出现这个问题的。
Synchronized/cas/ Voliatile
Synchronized | 原子性 | 可见性 | 有序性 | 悲观锁/排它锁-> lock 公平锁/非公平锁 |
cas | 原子性 | 可见性 | 乐观锁 | |
Voliatile | 可见性 | 有序性 |
Synchronized、volatile、CAS、Lock 比较
- synchronized(同步关键字)
- 概念和原理:
它是 Java 中的关键字,用于实现互斥同步。
当一个线程访问被 synchronized 修饰的代码块或方法时,
会获取对象的锁。其他线程如果也想访问这个代码块或方法,就必须等待锁的释放。例如,在一个类中有一个被 synchronized 修饰的方法,多个线程调用这个方法时,同一时刻只有一个线程能够执行该方法内的代码。
-
- 作用:
- 保证原子性。在被 synchronized 修饰的代码块内,代码会以原子方式执行,不会被其他线程干扰。例如,对一个共享变量的读写操作在 synchronized 块内可以保证操作的完整性。
- 保证可见性。当一个线程释放锁时,会将该线程对共享变量的修改刷新到主内存,其他线程获取锁后能看到最新的值。
- 缺点:
- 性能开销。获取和释放锁会带来一定的性能损耗,尤其是在高并发场景下,如果锁的竞争激烈,会导致程序性能下降。
- 作用:
- volatile(易变关键字)
- 概念和原理:
用于修饰变量,它保证了变量的可见性和禁止指令重排序。当一个线程修改了 volatile 变量的值,这个新值会立即被刷新到主内存,其他线程读取这个变量时会从主内存获取最新的值。例如,在多线程环境下,如果一个线程对 volatile 变量进行了写操作,其他线程能马上感知到这个变化。
-
- 作用:
- 可见性。主要用于解决多个线程之间共享变量的可见性问题,确保每个线程都能看到变量的最新状态。
- 禁止指令重排序。编译器和处理器为了提高性能可能会对指令进行重排序,但是 volatile 关键字可以保证在它修饰的变量的读写操作前后的指令顺序不被随意改变。
- 缺点:
- 作用:
不能保证原子性。对于复杂的操作(如自增操作,包含读取、修改、写入多个步骤),volatile 不能保证操作的原子性,可能会导致数据不一致。
- CAS(Compare - And - Swap)
- 概念和原理:
是一种乐观锁机制。它包含三个操作数:内存位置(V)、预期原值(A)和新值(B)。在操作时,先比较内存位置的值是否等于预期原值,如果是,则将内存位置的值更新为新值;如果不是,则说明其他线程已经修改了这个值,操作失败。例如,在 Java 的AtomicInteger类中,其自增操作就是通过 CAS 实现的。
-
- 作用:
- 高效的并发操作。在低冲突的情况下,CAS 可以提供高效的并发访问,因为它不需要像 synchronized 那样进行阻塞等待锁。它通过不断重试来更新变量的值,减少了线程的阻塞和唤醒开销。
- 原子性。能够以原子方式更新变量的值,保证了数据的一致性。
- 缺点:
- ABA 问题。如果一个值从 A 变为 B,然后又变回 A,在 CAS 操作时可能会认为值没有变化而进行更新,但实际上这个值已经被其他线程修改过了。
- 循环开销。在高冲突的情况下,CAS 操作可能会因为不断重试而产生较大的循环开销,导致性能下降。
- 作用:
场景
CAS:单个变量支持比较替换操作,如果实际值与期望值一致时才进行修改
volatile:单个变量并发操作,直接修改为我们的目标值
synchronized:一般性代码级别的并发
Lock:代码级别的并发,需要使用锁实现提供的独特机制,
例如:读写分离、中断、公平等 synchronized 不支持的机制。
原子性
CAS:保证原子性
volatile:单个操作保证原子性,组合操作(例如:++)不保证原子性
synchronized:保证原子性
Lock:保证原子性
并发粒度
CAS:单个变量值
volatile:单个变量值
synchronized:静态、非静态方法、代码块
Lock:代码块
编码操作性
CAS:调用 JDK 方法
volatile:使用关键字,系统通过屏障指令保证并发性
synchronized:使用关键字,加锁解锁操作系统默认通过指令控制
Lock:手动加锁解锁
线程阻塞
CAS:不会
volatile:不会
synchronized:可能会
Lock:可能会
性能(在合理使用情况下比较,比如我们可以用 volatile 实现的需求即不用 Lock)
CAS:主要表现在 CPU 资源占用
volatile:性能较好
synchronized:性能一般(JDK 1.6 优化后增加了偏向锁、轻量级锁机制)
Lock:性能较差
原文链接:Java 并发同步、锁定机制比较(CAS、volatile、synchronized、Lock)_并发机制对比-CSDN博客
锁比较
volatile 不是锁实现机制,因此锁相关比较不参与
- 锁中断操作
synchronized:不支持中断操作
Lock:支持中断,支持超时中断
- 锁功能性
synchronized:独占锁、可重入锁
Lock:独占锁、共享锁、可重入锁、读写锁、分段锁 …
ConcurrentHashMap每个segment使用单独的ReentrantLock(分段锁)
如ReentrantLock、ReentranReadWriteLock读写锁中的读锁ReadLock是共享锁,
写锁WriteLock是独享锁。
- 锁状态感知
synchronized:无法判断是否拿到锁
Lock:可以判断是否拿到锁
- 死锁
synchronized:可能出现死锁
Lock:需合理编码,可能出现死锁
原文链接:https://blog.csdn.net/xiaohulunb/article/details/103590989
Synchronized和Lock比较
- 实现方式
- synchronized:
是 Java 的关键字,由 JVM 实现。在字节码层面,通过 monitorenter 和 monitorexit 指令来实现锁的获取和释放。当一个线程进入被 synchronized 修饰的方法或代码块时,会执行 monitorenter 指令获取对象的锁;退出时执行 monitorexit 指令释放锁。这种实现方式是隐式的,开发者不需要手动操作锁的获取和释放过程。
-
- Lock:
是一个接口(如java.util.concurrent.locks.Lock),常见的实现类有ReentrantLock。它是通过代码来显式地实现锁的获取和释放,例如,使用ReentrantLock时,需要通过lock()方法获取锁,unlock()方法释放锁。这种显式的方式让开发者对锁的操作有更多的控制。
- 功能特性
- 锁的获取方式:
- synchronized:如果一个线程已经获取了锁,其他线程访问同一把锁时会被阻塞,直到锁被释放。对于同一个对象的锁,它是互斥的。例如,多个线程访问同一个对象的 synchronized 方法时,只有一个线程能够执行方法内的代码。
- Lock:Lock接口提供了更灵活的获取方式。比如tryLock()方法,它尝试获取锁,如果锁当前不可用,不会一直等待,而是立即返回一个结果(获取成功返回true,失败返回false)。还有lockInterruptibly()方法,允许在等待锁的过程中响应中断。
- 锁的释放条件:
- synchronized:当执行完 synchronized 修饰的方法或代码块时,锁会自动释放。这是一种隐式的释放机制,保证了锁的正确使用,但也缺乏灵活性。
- Lock:需要在代码中显式地调用unlock()方法来释放锁。这种方式增加了灵活性,但如果忘记释放锁,会导致死锁等问题。
- 公平性:
- synchronized:是非公平锁,不保证等待时间最长的线程最先获取锁。在高并发场景下,可能会导致某些线程长时间等待。
- Lock:可以通过构造函数设置为公平锁(如ReentrantLock(boolean fair))。公平锁会保证等待时间最长的线程最先获取锁,但是公平锁的实现会带来一定的性能开销。
- 锁的获取方式:
- 性能开销
- synchronized:
在低并发场景下,性能开销相对较小。因为 JVM 对其进行了优化,如锁粗化、锁消除等操作。但在高并发且锁竞争激烈的场景下,由于其实现机制,线程的阻塞和唤醒会带来一定的性能损失。
-
- Lock:
性能开销取决于具体的使用方式。在一些场景下,如使用tryLock()避免长时间等待,可以提高性能。但由于它是通过代码显式控制的,如果使用不当(如忘记释放锁),可能会导致严重的性能问题和死锁情况。
语法层面:
synchronized是java的一个关键字,源码在jvm中,用C++实现;
lock是接口,源码有JDK提供,java语言实现,使用synchronized时,退出同步代码块锁会自动释放。而lock需要手动调用unLock方法释放。
功能层面:
都属于悲观锁、都具备基本的互斥、同步、锁重入功能。
lock提供了许多synchronized不具备的功能,例如:公平、可打断、可超时、多条件变量;
Lock有适合不同场景的实现:如ReentrantLock、ReentranReadWriteLock读写锁
性能层面:
在么有竞争时,synchronized做了很多优化,如偏向锁、轻量级锁、性能不赖。
当竞争资源激烈时,Lock的实现通常会提供更好的性能。
synchronized使用Object对象本身的wait 、notify、notifyAll调度机制,
而Lock可以使用Condition进行线程之间的调度。await、singnal、singnalAll。
底层实现
synchronized: 底层使用指令码方式来控制锁的,映射成字节码指令就是增加来两个指令:monitorenter和monitorexit。当线程执行遇到monitorenter指令时会尝试获取内置锁,如果获取锁则锁计数器+1,如果没有获取锁则阻塞;当遇到monitorexit指令时锁计数器-1,如果计数器为0则释放锁。
Lock: 底层是CAS乐观锁,依赖AbstractQueuedSynchronizer类,把所有的请求线程构成一个CLH队列。而对该队列的操作均通过Lock-Free(CAS)操作。
https://zhuanlan.zhihu.com/p/379684721
原文链接:Lock与Synchronized区别_lock和synchronized区别-CSDN博客
原文链接:java-----synchronized和Lock的区别_java 和lock的区别-CSDN博客
Synchronized VS Lock
- synchronized是关键字,是JVM层面的底层啥都帮我们做了,
而Lock是一个接口,是JDK层面的有丰富的API。
- synchronized会自动释放锁,而Lock必须手动释放锁。
- synchronized是不可中断的,Lock可以中断也可以不中断。
- 通过Lock可以知道线程有没有拿到锁,而synchronized不能。
- synchronized能锁住方法和代码块,而Lock只能锁住代码块。
- Lock可以使用读锁提高多线程读效率。
- synchronized是非公平锁,
ReentrantLock可以控制是否是公平锁。(默认非公平 ,传入TRUE 变成公平锁)
Synchronized VS Volatile
- volatile只能修饰实例变量和类变量,而synchronized可以修饰方法,以及代码块。
- volatile保证数据的可见性,
但是不保证原子性(多线程进行写操作,不保证线程安全);
而synchronized是一种排他(互斥)的机制。
- volatile用于禁止指令重排序:
可以解决单例双重检查对象初始化代码执行乱序问题。
- volatile可以看做是轻量版的synchronized,volatile不保证原子性,
但是如果是对一个共享变量进行多个线程的赋值,而没有其他的操作,
那么就可以用volatile来代替synchronized,因为赋值本身是有原子性的,而volatile又保证了可见性,所以就可以保证线程安全了。
单例有8种写法,涉及Volatile的。好奇为啥要双重检查?如果不用Volatile会怎么样
对象实际上创建对象要进过如下几个步骤:
- 分配内存空间。
- 调用构造器,初始化实例。
- 返回地址给引用
可能发生指令重排序的,那有可能构造函数在初始化完成前就赋值完成了,在内存里面开辟了一片存储区域后直接返回内存的引用,这个时候还没真正的初始化完对象。
但是别的线程去判断instance!=null,直接拿去用了,其实这个对象是个半成品,那就有空指针异常了。
可见性怎么保证的?
因为可见性,线程A在自己的内存初始化了对象,还没来得及写回主内存,B线程也这么做了,那就创建了多个对象,不是真正意义上的单例了。
Synchronized VS ReentrantLock
灵活性
- ReentrantLock 功能更灵活。它可以实现公平锁(按照线程请求锁的顺序来分配锁),通过构造函数传入参数true来实现。例如在多个线程排队获取资源的场景下很有用。
- synchronized 是隐式的非公平锁,不能手动设置为公平锁。
(ReentrantLock 默认非公平 ,传入TRUE 变成公平锁。)
可操作性
- ReentrantLock 提供了更丰富的方法,如tryLock()可以尝试获取锁,获取不到不会阻塞线程,而是返回false;lockInterruptibly()方法可以让线程在等待锁的过程中响应中断。
- synchronized 只有隐式的获取锁和释放锁的操作,在获取锁的过程中如果不能获取就会一直阻塞,并且无法响应中断。
锁的释放
- ReentrantLock 需要在finally块中手动释放锁,以保证锁一定被释放,避免死锁。
- synchronized 是由 Java 虚拟机自动释放锁,当线程执行完同步代码块或同步方法后自动释放。
Lock 接口的实现类有哪些?
- ReentrantLock(可重入锁)
这是 Lock 接口最常用的实现类之一。它与 synchronized 关键字类似,
具有可重入性。例如,一个线程可以多次获取同一把 ReentrantLock 锁,不会造成死锁。
每次获取锁时,锁的持有计数会增加,每次释放锁时,计数会减少,当计数为 0 时,
锁才真正被释放。它可以通过构造函数设置为公平锁或者非公平锁。
在公平锁模式下,等待时间最长的线程会优先获取锁,保证了一定的公平性;非公平锁则允许插队获取锁,性能上通常优于公平锁。
- ReentrantReadWriteLock(可重入读写锁)
它实现了读写分离的锁机制。包括读锁(ReentrantReadWriteLock.ReadLock)和写锁(ReentrantReadWriteLock.WriteLock)。
多个线程可以同时获取读锁,因为读操作通常是不会相互冲突的,这样可以提高并发读取的效率。
但是,写锁是排他的,当一个线程获取写锁时,其他线程不能获取读锁或写锁。
只有当写锁被释放后,其他线程才能获取相应的锁。
这种机制在有大量读操作和少量写操作的场景下非常有用,例如缓存系统。
- StampedLock(戳记锁)
这是 Java 8 引入的一种新型锁。它在功能上是对 ReentrantReadWriteLock 的增强。除了提供了乐观读(tryOptimisticRead)的功能外,还支持读写锁和转换锁。乐观读模式下,线程可以在不加锁的情况下读取数据,读取完成后通过验证戳记(stamp)来判断在读取过程中数据是否被修改。如果没有被修改,就可以继续正常处理;如果被修改了,就需要获取读锁或者写锁重新读取或更新数据。这种方式在高并发读取且写操作较少的场景下,可以极大地提高性能。
分布式用锁用的需要注意?
1. 锁的互斥性
确保在分布式环境下,同一时刻只有一个客户端能够获取到锁。
这是分布式锁最基本的功能,要注意锁实现的算法和机制是否真正能保证这一点。
例如,基于 Redis 的 SETNX 命令实现分布式锁时,需要考虑网络延迟、命令执行顺序等因素对互斥性的影响。
2. 锁的过期时间
合理设置锁的过期时间很关键。如果时间过短,业务逻辑可能没完成锁就失效了;
如果过长,会导致资源被长时间占用,降低系统效率。
比如一个需要执行 5 秒的任务,锁的过期时间可以设置为 10 - 15 秒左右,并且要考虑网络抖动等额外因素。
3. 死锁问题
避免死锁情况。在分布式环境中,如果多个客户端相互等待对方释放锁,就会形成死锁。比如客户端 A 获取了资源 X 的锁,客户端 B 获取了资源 Y 的锁,然后 A 尝试获取 Y 的锁,B 尝试获取 X 的锁,就可能出现死锁。
可以通过设置获取锁的超时时间或者规定统一的锁获取顺序来避免。
4. 性能开销
分布式锁的获取和释放操作会带来一定的性能开销,包括网络通信成本、存储系统(如 Redis 或 Zookeeper)的读写成本等。
在高并发场景下,这些开销可能会影响系统的整体性能。
要考虑是否真的需要使用分布式锁,或者能否通过优化业务逻辑来减少锁的使用频率。
5. 锁的可重入性
根据业务需求确定锁是否需要可重入。可重入锁允许同一个线程在已经持有锁的情况下,再次获取该锁而不会被阻塞。如果业务逻辑中有递归调用或者嵌套获取锁的情况,就需要使用可重入锁。
6. 异常处理
锁的获取和释放过程可能会出现各种异常情况,如网络故障、存储系统故障等。要确保在这些异常情况下,锁不会出现异常状态,
比如没有释放锁或者错误地释放了其他客户端的锁。
在代码中需要有完善的异常处理机制,保证锁的状态始终可控。
7. 一致性
确保在分布式系统的各个节点上,锁的状态是一致的。
这涉及到分布式存储系统(如 Redis 集群或 Zookeeper 集群)的一致性协议,
不同的协议(如 Redis 的主从复制和哨兵模式、Zookeeper 的 ZAB 协议)
对锁的一致性有不同的影响。
在选择分布式锁的实现方式时,要考虑存储系统的一致性级别是否满足业务需求。
Java 中的锁
在 Java 中有多种类型的锁,用于控制多个线程对共享资源的访问。
- 乐观锁和悲观锁
- 独占锁和共享锁
- 互斥锁和读写锁
- 公平锁和非公平锁
- 可重入锁
- 自旋锁
- 分段锁
- 锁升级(无锁|偏向锁|轻量级锁|重量级锁)
- 锁优化技术(锁粗化、锁消除)
悲观锁synchronized/乐观锁CAS
悲观锁
- 原理:它是一种比较保守的并发控制策略。在操作数据时,悲观地认为一定会有其他线程来修改数据,所以在操作之前就先获取锁,把数据锁住,确保在自己操作期间,其他线程无法访问和修改这些数据。
- 实现方式:在 Java 中,synchronized关键字在某种程度上可以看作是一种悲观锁。当一个线程进入一个被synchronized修饰的方法或代码块时,就相当于获取了锁,其他线程必须等待这个线程释放锁才能访问被保护的资源。另外,在数据库操作中,像SELECT... FOR UPDATE语句也是悲观锁的应用,它会对查询出来的记录进行加锁,防止其他事务修改。
- 适用场景:适用于写操作比较频繁,竞争激烈的场景。比如在金融系统的转账业务中,对账户余额的操作需要保证数据的准确性,使用悲观锁可以有效避免并发修改导致的问题。
乐观锁
- 原理:乐观地认为在自己操作数据期间,其他线程不会修改数据。所以不会提前加锁,而是在更新数据时检查数据是否被其他线程修改过。如果没有修改,就正常更新;如果被修改了,就根据业务逻辑进行处理,如重试或者抛出异常。
- 实现方式:常见的实现方式是通过版本号或者时间戳。例如,在数据库中有一个表,有一个version字段用于记录版本号。每次读取数据时,同时读取版本号。更新数据时,会在 SQL 语句中加入条件判断版本号是否与读取时一致,如UPDATE table SET... WHERE version =?,如果更新成功,说明数据没有被其他线程修改,同时版本号加 1;如果更新的行数为 0,说明数据已经被修改,需要采取相应措施。
- 适用场景:适用于读操作远多于写操作的场景,因为它减少了加锁解锁的开销。像在一些内容管理系统中,文章的浏览次数统计,使用乐观锁可以在高并发读取的情况下,偶尔进行的写操作也能保证数据相对准确。
乐观锁:乐观锁认为一个线程去拿数据的时候不会有其他线程对数据进行更改,所以不会锁。
实现方式:CAS机制、版本号机制,java中JUC.atomic下的类, 底层CAS就是乐观锁。
悲观锁:悲观锁认为一个线程去拿数据时一定会有其他线程对数据进行更改。
所以一个线程在拿数据的时候都会顺便加锁,这样别的线程此时想拿这个数据就会阻塞。
比如Java里面的synchronized关键字的实现就是悲观锁。实现方式:就是加锁。
还有一些使用了 synchronized 关键字的容器类如 HashTable 等也是悲观锁的应用。
两种锁的使用场景
乐观锁适用于写比较少(冲突比较小)的场景,
因为不用上锁、释放锁,省去了锁的开销,从而提升了吞吐量。
如果是写多读少的场景,即冲突比较严重,线程间竞争激励,使用乐观锁就是导致线程不断进行重试,这样可能还降低了性能,这种场景下使用悲观锁就比较合适。
独占锁 synchronized 共享锁ReentrantReadWriteLock.readLock()
独享锁(互斥锁)共享锁(读写锁)
共享锁:该锁可以被多个线程所持有
独占锁:JDK中的synchronized和
java.util.concurrent(JUC)包中Lock的实现类
ReentrantReadWriteLock .WriteLock()就是独占锁。
共享锁:在 JDK 中 ReentrantReadWriteLock.ReadLock() 就是一种共享锁。
ReentrantLock [riːˈɛntrənt] ReentrantReadWriteLock
独占锁与共享锁通过AQS(AbstractQueuedSynchronizer)来实现的,
通过实现不同的方法,来实现独享或者共享。
共享锁(读锁)
- 原理:允许多个线程同时获取锁来访问共享资源,主要用于读取操作。多个线程获取共享锁后可以同时读取共享资源,不会相互干扰。
- 实现方式:在 Java 中,ReadWriteLock接口提供了读锁(共享锁)和写锁(排他锁)的实现。例如,ReentrantReadWriteLock类,它的readLock()方法返回的是共享读锁,多个线程可以通过获取这个读锁来同时读取共享资源。
- 适用场景:适用于读操作远远多于写操作的场景,如在一个信息查询系统中,数据的读取非常频繁,使用共享锁可以提高系统的并发读取性能。
排他锁(写锁)
- 原理:在同一时刻,只有一个线程可以获取排他锁来访问和修改共享资源。当一个线程获取了排他锁后,其他线程无论是获取读锁还是写锁都需要等待,直到排他锁被释放。
- 实现方式:在 Java 中,synchronized关键字修饰的方法或代码块、ReentrantLock等在本质上都是排他锁。在ReadWriteLock机制中,writeLock()方法返回的是排他写锁。
- 适用场景:在需要对共享资源进行独占式修改的场景中使用,如在更新数据库中的重要记录或者修改配置文件等操作时。
公平锁ReentrantLock和非公平锁synchronized
公平锁
- 原理:公平锁遵循 “先来先得” 的原则,多个线程按照请求锁的先后顺序来获取锁。当一个线程请求锁时,如果锁已经被其他线程持有,这个线程就会进入等待队列。一旦锁被释放,等待队列中的第一个线程就会获得锁。
- 实现方式:在 Java 中,ReentrantLock可以通过构造函数设置为公平锁,如ReentrantLock fairLock = new ReentrantLock(true);。
- 适用场景:在对公平性要求较高的场景下使用,比如在资源分配系统中,希望每个请求资源的线程都能按照请求顺序公平地获取资源。
非公平锁
- 原理:非公平锁不保证线程获取锁的顺序。当一个线程请求锁时,如果锁恰好可用,那么这个线程可以直接获取锁,而不管等待队列中是否有其他线程在等待。
- 实现方式:Java 中的synchronized关键字默认是非公平锁,ReentrantLock默认也是非公平锁。ReentrantLock可以通过构造函数设置为公平锁或非公平锁,默认构造函数ReentrantLock()创建的是非公平锁。
- 适用场景:在对性能要求较高,对公平性要求不是特别严格的场景下使用。例如在高并发的缓存系统中,多个线程频繁地获取和更新缓存数据,使用非公平锁可以减少线程等待时间,提高系统性能。
公平锁:多个线程相互竞争时要排队,多个线程按照申请锁的顺序来获取锁。(上来排队)
New ReentrantLock(TRUE)
非公平锁:多个线程相互竞争时,先尝试插队,插队失败再排队。(插队)
在 java 中 synchronized 关键字是非公平锁;
/**
* 创建一个可重入锁,true 表示公平锁,false 表示非公平锁。默认非公平锁。
*/
Lock lock = new ReentrantLock(true );--(底层cas)
区别在于获取锁的顺序和CPU唤醒线程的开销。公平锁会让所有线程都能得到资源,但吞吐量会下降,CPU唤醒阻塞线程的开销也会增加。非公平锁则可以减少CPU唤醒线程的开销,整体的吞吐效率会高点,但可能导致队列中间的线程一直获取不到锁或者长时间获取不到锁,导致饿死。
可重入锁ReentrantLock/synchronized和非可重入锁
可重入锁
- 原理:可重入锁允许一个线程多次获取同一个锁而不会被自己阻塞。比如一个线程已经获取了一个锁,在它还没有释放这个锁的情况下,又遇到了需要获取同一个锁的代码块,可重入锁会允许这个线程再次获取锁。
- 实现方式:Java 中的synchronized关键字和ReentrantLock都是可重入锁。以ReentrantLock为例,它内部会记录持有锁的线程以及获取锁的次数,当一个线程再次获取锁时,获取锁的次数会增加,释放锁时次数会减少,当次数为 0 时,锁才真正被释放。
- 适用场景:当一个方法调用另一个同样被这个锁保护的方法时很有用,比如在递归方法中,如果方法内部操作需要加锁保护共享资源,可重入锁可以保证方法在递归过程中顺利执行。
非可重入锁:
- 原理:如果一个线程已经获取了锁,在没有释放锁之前,它不能再次获取同一个锁,否则就会被阻塞。
- 实现方式:在 Java 标准库中,一般较少使用非可重入锁,不过可以自己实现这种锁机制,通常需要记录锁的状态和持有锁的线程,当锁被占用时,其他线程包括已经持有锁的线程再次请求时就会被拒绝。
- 适用场景:这种锁的应用场景相对较少,因为在很多复杂的业务逻辑中,可重入性是比较重要的需求。不过在一些简单的、对锁的嵌套使用要求不高的场景下可以考虑使用。
ReentrantLock [riːˈɛntrənt]
Synchronized (对于线程自身可重入,对于其他线程仍是不可重入阻塞)
含义 :所谓可重入锁,指的是以线程为单位,当一个线程获取对象锁之后,这个线程可以再次获取本对象上的锁,而其他的线程是不可以的。(同一个加锁线程自己调用自己不会发生死锁情况)
优点:避免死锁
实现原理: 通过为每个锁关联一个请求计数和一个占有它的线程。当计数为 0 时,认为锁是未被占有的。线程请求一个未被占有的锁时,jvm 将记录锁的占有者,并且将请求计数器置为 1 。如果同一个线程再次请求这个锁,计数将递增;每次占用线程退出同步块,计数器值将递减。直到计数器为0,锁被释放。
synchronized(修饰方法和代码块) - 希希里之海 - 博客园
原文链接:synchronized作用于实例方法、静态方法、代码块的三种作用方式_synchronized作用于方法-CSDN博客
Java中的重入锁
Java中的锁都来自与Lock接口,如下图中红框内的,就是重入锁。
重入锁提供的最重要的方法就是lock()
- void lock():加锁,如果锁已经被别人占用了,就无限等待。
这个lock()方法,提供了锁最基本的功能,拿到锁就返回,拿不到就等待。
因此,大规模得在复杂场景中使用,是有可能因此死锁的。
因此,使用这个方法得非常小心。
如果要预防可能发生的死锁,可以尝试使用下面这个方法:
boolean tryLock(long timeout, TimeUnit unit) throws InterruptedException:
尝试获取锁,等待timeout时间。同时,可以响应中断。
tryLock()在JDK内部被大量的使用。与lock()相比,tryLock()至少有下面一个好处:
- 可以不用进行无限等待。直接打破形成死锁的条件。
如果一段时间等不到锁,可以直接放弃,同时释放自己已经得到的资源。
这样就可以在很大程度上,避免死锁的产生。因为线程之间出现了一种谦让机制
- 可以在应用程序这层进行进行自旋,你可以自己决定尝试几次,或者是放弃。
- 等待锁的过程中可以响应中断,如果此时,程序正好收到关机信号,中断就会触发,进入中断异常后,线程就可以做一些清理工作,从而防止在终止程序时出现数据写坏,数据丢失等悲催的情况。
当然了,当锁使用完后,千万不要忘记把它释放了。不然,程序可能就会崩溃啦~
- void unlock() :释放锁
此外, 重入锁还有一个不带任何参数的tryLock()。
- public boolean tryLock()
这个不带任何参数的tryLock()不会进行任何等待,如果能够获得锁,直接返回true,
如果获取失败,就返回false,特别适合在应用层自己对锁进行管理,
在应用层进行自旋等待。
重入锁的实现原理
重入锁的核心功能委托给内部类Sync实现,并且根据是否是公平锁有FairSync和NonfairSync两种实现。这是一种典型的策略模式。
实现重入锁的方法很简单,就是基于一个状态变量state。
这个变量保存在AbstractQueuedSynchronizer对象中private volatile int state;
当这个state==0时,表示锁是空闲的,大于零表示锁已经被占用,
它的数值表示当前线程重复占用这个锁的次数。因此,lock()的最简单的实现是:
公平的重入锁
默认情况下,重入锁是不公平的。
什么叫不公平呢。也就是说,如果有1,2,3,4 这四个线程,按顺序,依次请求锁。那等锁可用的时候,谁会先拿到锁呢?
在非公平情况下,答案是随机的。
如果你是一个公平主义者,强烈坚持先到先得的话,那么你就需要在构造重入锁的时候,指定这是一个公平锁:ReentrantLock fairLock = new ReentrantLock(true);
这样一来,每一个请求锁的线程,都会乖乖的把自己放入请求队列,而不是上来就进行争抢。但一定要注意,公平锁是有代价的。维持公平竞争是以牺牲系统性能为代价的。
如果你愿意承担这个损失,公平锁至少提供了一种普世价值观的实现吧!
那公平锁和非公平锁实现的核心区别在哪里呢?来看一下这段lock()的代码:
//非公平锁
final void lock() {
//上来不管三七二十一,直接抢了再说
if (compareAndSetState(0, 1))
setExclusiveOwnerThread(Thread.currentThread());
else
//抢不到,就进队列慢慢等着
acquire(1);
}
//公平锁
final void lock() {
//直接进队列等着
acquire(1);
}
从上面的代码中也不难看到,非公平锁如果第一次争抢失败,后面的处理和公平锁是一样的,都是进入等待队列慢慢等。
Condition
Condition可以理解为重入锁的伴生对象。它提供了在重入锁的基础上,
进行等待和通知的机制。可以使用 newCondition()方法生成一个Condition对象,如下所示。
private final Lock lock = new ReentrantLock();
private final Condition condition = lock.newCondition();
那Condition对象怎么用呢。在JDK内部就有一个很好的例子。
让我们来看一下ArrayBlockingQueue吧。
ArrayBlockingQueue是一个队列,你可以把元素塞入队列(enqueue),也可以拿出来take()。但是有一个小小的条件,就是如果队列是空的,那么take()就需要等待,一直等到有元素了,再返回。那这个功能,怎么实现呢?
这就可以使用Condition对象了。
实现在ArrayBlockingQueue中,就维护一个Condition对象
lock = new ReentrantLock(fair);
notEmpty = lock.newCondition();
这个notEmpty 就是一个Condition对象。它用来通知其他线程,
ArrayBlockingQueue是不是空着的。当我们需要拿出一个元素时:
public E take() throws InterruptedException {
final ReentrantLock lock = this.lock;
lock.lockInterruptibly();
try {
while (count == 0)
// 如果队列长度为0,那么就在notEmpty condition上等待了,一直等到有元素进来为止
// 注意,await()方法,一定是要先获得condition伴生的那个lock,才能用的哦
notEmpty.await();
//一旦有人通知我队列里有东西了,我就弹出一个返回
return dequeue();
} finally {
lock.unlock();
}
}
当有元素入队时:
public boolean offer(E e) {
checkNotNull(e);
final ReentrantLock lock = this.lock;
//先拿到锁,拿到锁才能操作对应的Condition对象
lock.lock();
try {
if (count == items.length)
return false;
else {
//入队了, 在这个函数里,就会进行notEmpty的通知,通知相关线程,有数据准备好了
enqueue(e);
return true;
}
} finally {
//释放锁了,等着的那个线程,现在可以去弹出一个元素试试了
lock.unlock();
}
}
private void enqueue(E x) {
final Object[] items = this.items;
items[putIndex] = x;
if (++putIndex == items.length)
putIndex = 0;
count++;
//元素已经放好了,通知那个等着拿东西的人吧
notEmpty.signal();
}
因此,整个流程如图所示
总结
可重入锁算是多线程的入门级别知识点,所以我把他当做多线程系列的第一章节,对于重入锁,我们需要特别知道几点:
- 对于同一个线程,重入锁允许你反复获得通一把锁,
但是,申请和释放锁的次数必须一致。
- 默认情况下,重入锁是非公平的,公平的重入锁性能差于非公平锁
- 重入锁的内部实现是基于CAS操作的。
- 重入锁的伴生对象Condition提供了await()和singal()的功能,可以用于线程间消息通信。
https://mp.weixin.qq.com/s/GDno-X1N8zc98h9MZ8_KoA
自旋锁 AtomicInteger/AtomicXXX
分段锁
是一种锁的设计,并不是具体的一种锁。
分段锁设计目的是将锁的粒度进一步细化,当操作不需要更新整个数组的时候,就仅仅针对数组中的一项进行加锁操作。
在 Java 语言中 CurrentHashMap 底层就用了分段锁,
使用Segment,就可以进行并发使用了。
锁升级
JDK1.6 为了提升性能减少获得锁和释放锁所带来的消耗,
引入了4种锁的状态:无锁、偏向锁、轻量级锁和重量级锁,
它会随着多线程的竞争情况逐渐升级,但不能降级。
锁优化(锁粗化和锁消除)
锁粗化:就是将多个同步块的数量减少,并将单个同步块的作用范围扩大,
本质上就是将多次上锁、解锁的请求合并为一次同步请求。
锁消除:是指虚拟机编译器在运行时检测到了共享数据没有竞争的锁,
从而将这些锁进行消除。
超时释放
synchronized直接PASS,这玩意请求不到就阻塞。
LOCK可以手动释放锁,使用tryLock中的超时用来释放锁。
synchronized VS ReentrantLock
ReentrantLock 表现为 API 层面的互斥锁(lock() 和 unlock() 方法配合 try/finally 语句块来完成),
1.内置锁(synchronized 关键字)
- 对象锁:
- 当在方法或者代码块上使用synchronized关键字时,实际上是对对象加锁。例如,对于一个非静态的synchronized方法,锁是加在当前对象实例上的。
- 如public synchronized void method() {... },
当一个线程进入这个方法,就获取了该对象的锁,其他线程想要访问这个对象的其他synchronized方法或者代码块时,就需要等待锁的释放。
- 类锁:
- 对于静态的synchronized方法,锁是加在类对象上的。
例如public static synchronized void staticMethod() {... }。
-
- 这意味着当一个线程访问这个静态方法时,获取的是类级别的锁,
其他线程访问该类的其他静态synchronized方法也需要等待这个锁释放。
2. ReentrantLock(可重入锁)
- 特点:
- 可重入性,即一个线程可以多次获取同一个锁。例如,一个方法调用另一个使用相同ReentrantLock锁的方法时,线程可以成功获取锁,不会被自己阻塞。
- 提供了更灵活的锁操作,如可以手动控制锁的获取和释放,通过lock()方法获取锁,unlock()方法释放锁。
- 可以创建公平锁和非公平锁。公平锁是指按照线程请求锁的顺序来分配锁,非公平锁则不保证请求顺序,在性能上非公平锁通常更优,但可能会导致某些线程长时间等待。
- 示例用法:
import java.util.concurrent.locks.ReentrantLock;
class MyClass {
private ReentrantLock lock = new ReentrantLock();
public void doSomething() {
lock.lock();
try {
// 访问共享资源的代码
} finally {
lock.unlock();
}
}
}
3. ReadWriteLock(读写锁)
- 特点和原理:
- 读写分离,适用于读操作远远多于写操作的场景。它包含读锁和写锁,多个线程可以同时获取读锁来读取共享资源,但写锁是排他的。
- 当一个线程获取写锁时,其他线程无论是读还是写都需要等待;当一个或多个线程获取读锁时,其他线程获取读锁可以继续进行,但获取写锁的线程需要等待所有读锁释放。
- 示例用法(以 ReentrantReadWriteLock 为例):
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
class MyData {
private int data;
private ReadWriteLock rwLock = new ReentrantReadWriteLock();
public int readData() {
rwLock.readLock().lock();
try {
return data;
} finally {
rwLock.readLock().unlock();
}
}
public void writeData(int newData) {
rwLock.writeLock().lock();
try {
data = newData;
} finally {
rwLock.writeLock().unlock();
}
}
}
可重入锁与内置锁的区别是什么?
锁的获取和释放方式
内置锁(synchronized):
它是 Java 语言内置的一种锁机制。对于同步方法,当线程访问一个被synchronized修饰的方法时,会自动获取对象锁;方法执行结束后,自动释放锁。例如:
class MyClass {
public synchronized void method1() {
// 自动获取锁,方法结束自动释放锁
}
}
对于同步代码块,通过synchronized (this) {... }(this可以是其他对象)的形式,在进入代码块时自动获取指定对象的锁,代码块执行结束后自动释放锁。
可重入锁(ReentrantLock):
需要手动获取和释放锁。通过lock()方法获取锁,例如lock.lock(),
并且必须在finally块中使用unlock()方法释放锁,
以确保锁在任何情况下都能被正确释放。如:
import java.util.concurrent.locks.ReentrantLock;
class MyClass {
private ReentrantLock lock = new ReentrantLock();
public void method() {
lock.lock();
try {
// 访问共享资源的代码
} finally {
lock.unlock();
}
}
}
可重入性的灵活性
- 内置锁(synchronized):
- 它本身是可重入的。例如,一个类中有两个被synchronized修饰的方法methodA和methodB,如果一个线程在执行methodA的过程中调用了methodB(都是实例方法且属于同一个对象),因为它们的锁是基于对象的,所以线程可以成功进入methodB,不会被自己阻塞。
- 可重入锁(ReentrantLock):
- 同样具有可重入性,并且提供了更灵活的重入控制。可以通过lock的getHoldCount()方法来获取当前线程持有该锁的次数,用于更复杂的逻辑,比如在多层嵌套的同步代码块中精确地控制锁的状态。
锁的功能扩展
- 内置锁(synchronized):
功能相对比较基础,主要用于简单的线程同步。不过在 Java 5 之后,它在性能上得到了优化,和可重入锁的性能差距在很多场景下已经不大。
- 可重入锁(ReentrantLock):
提供了更多高级功能。例如,可以创建公平锁和非公平锁。公平锁会按照线程请求锁的顺序来分配锁,非公平锁则不保证请求顺序,在高并发场景下,非公平锁通常性能更好,但可能会导致某些线程长时间等待。另外,可重入锁可以关联多个条件(Condition对象),用于实现更复杂的线程间通信,比如生产者 - 消费者模型中更精细的通知机制。
标签:Java,synchronized,获取,对象,编程,并发,线程,内存,操作 From: https://blog.csdn.net/qq_24426227/article/details/143465734