lukaliou123 / lukaliou123.github.io

lukaliou123在2022年的面试用知识点总结
Other
5 stars 0 forks source link

Java并发篇--锁 #7

Open lukaliou123 opened 2 years ago

lukaliou123 commented 2 years ago

置顶:锁的不同级别

锁主要存在四种状态,依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,他们会随着竞争的激烈而逐渐升级。注意锁可以升级不可降级,这种策略是为了提高获得锁和释放锁的效率。

1.无锁状态: 这是对象最开始的状态,它意味着该对象目前没有被任何线程锁定。在这种状态下,线程访问这个对象不需要同步操作。

2.偏向锁状态: 偏向锁的思想是,如果一个资源总是被同一个线程访问,那么为什么不让这个线程"偏好"这个锁呢?当一个线程访问某个同步块时,该线程会在对象头和栈帧中的锁记录里存储锁的偏向线程ID。之后的访问中,如果是同一个线程请求,不需要进一步的同步操作

3.轻量级锁状态: 当偏向锁的线程遇到另一个线程争用锁时,偏向锁就会升级为轻量级锁。在这种情况下,JVM首先会检查当前是不是已经有线程持有锁。如果没有,它会使用CAS操作尝试获取锁。CAS(Compare And Swap)是一个原子操作,用来确保数据同步。如果CAS失败,那么线程会自旋,等待获得锁。

4.重量级锁状态:如果轻量级锁的自旋操作失败太多次,那么锁就会升级到重量级锁。这是一个传统的互斥锁,它会导致其他请求该锁的线程进入阻塞状态。这个状态下的开销是最大的,因为它涉及到线程上下文的切换。

1.并发关键词synchronized

Synchronized关键字解决的是多个线程之间访问资源的同步性,Synchronized关键字可以保证被它修饰的方法或者代码块在任意时刻只能有一个线程执行。 Java在早期版本中,synchronized属于重量级锁,效率低下

2.说说自己是怎么使用 synchronized 关键字,在项目中用到了吗

三种主要使用方式:

  1. 修饰实例方法:作用于当前对象实例加锁,进入同步代码前要获得当前对象实例的锁 image
  2. 修饰静态方法:也就是给当前类加锁,会作用于类的所有对象实例,进入同步代码前要获得当前class的锁。因为静态成员不属于任何一个实例对象,是类成员。如果一个线程A调用一个实例对象的非静态synchronized方法,而线程B调用这个实例对象所属类的静态synchronized方法们是允许的,不会发生互斥现象。 image 3.修饰代码块:指定加锁对象,对给定对象/类枷锁。Synchronized(this|object)表示进入同步代码库前要获得给定对象锁。Synchronized(类.class)表示进入同步代码前要获得当前class的锁 image

3.说一下 synchronized 底层实现原理?

1.Synchronized的语义底层是通过一个monitor(监视器锁)的对象来完成

  1. 每个对象有一个监视器锁。每个Synchronized修饰过的代码当它的monitor被占用时就会处于锁定状态并且尝试获取monitor的所有权: 过程: 1.如果监视器锁的进入数为0,则该线程进入monitor,然后将进入数设置为1,该线程即为monitor的所有者。 2.如果线程已经占有该monitor,只是重新进入,则进入monitor的进入数加1 3.如果其他线程已经占用了monitor,则该线程进入阻塞状态,直到Monitor的进入数为0,再重新尝试获取monitor的所有权
lukaliou123 commented 2 years ago

4. synchronized 关键字在单例模式的具体使用。

面试中面试官经常会说:“单例模式了解吗?来给我手写一下!给我解释一下双重检验锁方式实现单例模式的原理呗!” 1645713366(1)

另外,需要注意 uniqueInstance 采用 volatile 关键字修饰也是很有必要。

uniqueInstance 采用 volatile 关键字修饰也是很有必要的, uniqueInstance = new Singleton(); 这段代码其实是分为三步执行: 1.为 uniqueInstance 分配内存空间 2.初始化 uniqueInstance 3.将 uniqueInstance 指向分配的内存地址

但是由于 JVM 具有指令重排的特性,执行顺序有可能变成 1->3->2。指令重排在单线程环境下不会出现问题,但是在多线程环境下会导致一个线程获得还没有初始化的实例。例如,线程 T1 执行了 1 和 3,此时 T2 调用 getUniqueInstance() 后发现 uniqueInstance 不为空,因此返回 uniqueInstance,但此时 uniqueInstance 还未被初始化。

使用 volatile 可以禁止 JVM 的指令重排,保证在多线程环境下也能正常运行。

补充:为什么要检查两次Null?

为什么要进行两次检测呢?这是为了解决多线程环境下的线程安全性问题。在单例模式中,当第一个线程进入临界区时,判断实例是否为空,如果为空,则创建一个实例。然而,在多线程环境下,可能会有多个线程同时通过了第一次检测,然后一个线程获得了锁,创建了实例,而其他线程在等待锁的过程中继续执行,如果没有第二次检测,那么这些线程在获取到锁后也会创建实例,导致多个实例被创建。

通过第二次检测,可以避免多个线程同时创建实例的问题。当第一个线程创建完实例释放锁后,后续的线程再次进入临界区时会发现实例已经不为空,就不会再创建新的实例,而是直接返回已创建的实例。这样就确保了只有一个实例被创建。

这种双重检索的方式能够在保证线程安全性的同时,减少了同步开销,提高了性能

lukaliou123 commented 2 years ago

5.什么是自旋

1.很多synchronized里面的代码只是一些很简单的代码,执行时间非常快,此时等待的线程都解锁可能是一种不太值得的操作,因为线程阻塞涉及到用户态的内核态切换的问题。既然synchronized里面执行的代码执行得非常快,不妨让等待锁的线程不要被阻塞,而是在synchronized的边界做忙循环,这就是自旋。如果做了多次循环发现还没有获得锁,再阻塞,这样可能是一种更好的策略。

2.忙循环:就是程序员用循环让一个线程等待,不像传统方法wait(),sleep()或yield(),它们都放弃了CPU控制,而忙循环不会放弃CPU,它就是在运行一个空循环。这样做的目的是为了保留CPU缓存,在多核系统中,一个等待线程醒来的时候可能会在另一个内核运行,这样会重建缓存,为了避免重建缓存和减少等待重建的时间就可以使用它了。

lukaliou123 commented 2 years ago

6.volatile 关键字的作用

1.对于可见性,Java提供了volatile关键词来保证可见性和禁止指令重排。Volatile还提供happens-before得保证,确保一个线程的修改能对其他线程是可见的。当一个共享变量被volatile修饰时,它会保证修改的值会立即被更新到主内存中。当有其他线程需要读取时,它会去内存中读取新值。 2.从实践角度,volatile的一个重要作用就是和CAS结合,保证了原子性,详细的可以参见java.util.concurrent.atomic 包下的类,比如 AtomicInteger 3.Volatile变量常用于多线程环境下的单次操作(单次读或者单次写)

7.Java 中能创建 volatile 数组吗?

能,Java中可以创建volatile类型数组,不过只是一个指向数组的引用,而不是整个数组。意思是,如果改变引用指向的数组,将会受到volatile的保护,但是如果多个线程同时改变数组的元素,volatile标识符就不能起到之前的保护作用了。

8.volatile 变量和 atomic 变量有什么不同吗?

  1. volatile变量可以确保线性关系,即写操作会发生在后续的读操作之前,但它并不能保证原子性
  2. 而 AtomicInteger 类提供的 atomic 方法可以让这种操作具有原子性,getAndIncrement()方法会原子性的进行增量操作把当前值加一

9.volatile 能使得一个非原子操作变成原子操作吗?

1.关键字volatile的主要作用是使变量在多个线程间可见,但无法保证原子性,对于多个线程访问同一个实例变量需要加锁进行同步 2.虽然volatile只能保证可见性不能保证原子性,但用volatile修饰long和double可以保证其操作原子性

9.1.Volatilie是如何保证可见性和有序性的?

可见性:当一个共享变量被 volatile 修饰时,它会保证修改的值会立即被更新到主存(也就是堆内存),当有其他线程需要读取时,它会去主存中读取新值。而普通的共享变量不能保证可见性,因为线程对共享变量的操作(读取、赋值)都是在工作内存中进行,而不是直接在主存中进行的。所以, volatile 可以保证多线程操作共享变量时的可见性。

有序性:在Java内存模型中,允许编译器和处理器对指令进行重排序,但是重排序过程不会影响到单线程程序的执行,却可能会影响到多线程并发执行的正确性。在Java语言中,volatile 也能够阻止指令重排序。Java内存模型的规定,所有使用 volatile 变量的前后都会插入内存屏障,来禁止特定类型的处理器重排序。

对于volatile变量的写操作,会在写操作后添加一个写屏障(Store Memory Barrier,简称Store Barrier或者STB),这个屏障可以让缓存中的写操作同步回主存,也就保证了其他线程可以看到这个写操作

对于volatile变量的读操作,会在读操作前添加一个读屏障(Load Memory Barrier,简称Load Barrier或者LDB),这个屏障可以让这个读操作之后的所有操作,都能看到这个读操作的结果

lukaliou123 commented 2 years ago

10.synchronized 和 volatile 的区别是什么

概念: Synchronized表示只有一个线程可以获取作用对象的锁,执行代码,阻塞其他线程

Volatile表示变量在CPU的寄存器中是不确定的,必须从主存中读取。保证多线程环境下变量的可见性;禁止指令重排序

区别: 1.volatile是变量修饰符;synchronized可以修饰类,方法,变量 2.volatile仅能实现变量的修改可见性,不能保证原子性;而synchronized则可以保证变量的修改可见性和原子性 3.volatile不会造成线程的阻塞;synchronized可能会造成线程的堵塞 4.volatile标记的变量不会被编译器优化;synchronized标记的变量可以被编译器优化 5.volatile关键字是线程同步的轻量级实现,所以volatile性能肯定比synchronized关键字要好。但是volatile关键字只能用于变量而synchronized关键字可以修饰方法以及代码块。实际开发中使用synchronized关键字的场景还是更多一些

补充:Lock和synchronized

首先,这两者都是用于处理多线程并发控制的,也就是说他们都能帮助我们保证在多线程环境下的数据安全。

但是synchronized和Lock还是有一些区别的:

1.首先,synchronized是Java语言内置的一种锁机制,当我们声明一个代码块或者方法为synchronized时,JVM会自动帮我们实现加锁和解锁。但是,Lock是一个接口,它需要我们手动进行锁的获取和释放。如果忘记了手动释放锁,可能就会导致其他线程无法获取到锁,从而陷入阻塞状态。

2.其次,synchronized在发生异常时,会自动释放锁,而Lock不会。所以当我们使用Lock时,通常会在finally块中进行锁的释放,以保证锁一定会被释放

3.然后,Lock还提供了更多的功能,例如可以被中断的锁获取操作、超时获取锁等,这些都是synchronized所不具备的。

4.synchronized 不可中断,除非锁释放,否则一直等待。而 ReentrantLock 可以中断,它提供了两个方法,lockInterruptibly() 和 tryLock(long timeout, TimeUnit unit),可以实现等待的中断。

5.synchronized 固定非公平锁,而 ReentrantLock 两者都可以,创建 ReentrantLock 的时候可以传一个 boolean 值来决定公平性,公平锁意味着每次都是等待时间最长的线程获得锁。

6.synchronized 不提供锁绑定多个条件,而 ReentrantLock 提供了一个 Condition 类,可以绑定多个条件

两者的不同底层:

synchronized 的实现主要是通过 monitor 对象来完成的。每个在Java中的Object都可以作为一个 monitor(监视器)。synchronized 块就是通过这种方式来达到同步的效果的。

ReentrantLock 是通过 AQS (AbstractQueuedSynchronizer) 来实现锁的。AQS 使用一个 int 成员变量来表示同步状态,并使用一个 FIFO 队列来管理那些获取不到资源的线程。当一个线程获取到资源时,AQS 会尝试为其设置同步状态,并将成功的线程作为独占模式的拥有者。其他线程获取资源时将会失败,被加入到队列的尾部等待。

synchronized和ReentrantLock都可能会发生死锁,关键在于如何使用。例如,如果两个线程分别持有资源A和资源B,并且分别等待对方释放资源B和资源A,那么这两个线程就会因为互相等待对方释放资源而进入死锁状态,无论是用synchronized还是ReentrantLock都会发生这样的情况。

Lock使用例: 1689676223200 然后我们就可以创建多个线程,代表多个售票窗口,然后调用sellTicket()方法开始售票了。

这个例子中,lock.lock()就是上锁,确保了同一时间只有一个线程可以执行sellTicket()方法。然后在finally中,我们调用lock.unlock()来释放锁,确保了即使出现了异常,锁也能被正确地释放。

补充:可重入锁

可重入锁,英文称作 Reentrant Lock,或者递归锁。"Reentrant" 一词在计算机科学中的意思是 "可以在同一线程中多次获取同一个资源"。 image

可重入锁有一个特性,就是在同一个线程在外层方法获取锁的时候,再进入该线程的内层方法会自动获取锁,这种特性叫做可重入性。也就是说,一个线程可以进入任何一个它已经拥有的锁所同步着的代码块。 O}1S 6VH_4{V_7ENG4E5D(B

举个例子,假设有个类中有两个同步方法,methodA和methodB,methodA中调用了methodB。那么当一个线程已经拿到了调用methodA的锁,接着在methodA中调用methodB的时候,就不需要再去申请锁了,因为这个锁已经在该线程中了,这就是所谓的可重入。

如果在面试中被问到,你可以回答:

“可重入锁,也叫做递归锁,是指在同一线程在外层方法获取锁的时候,在进入该线程的内层方法会自动获取锁。 换句话说,线程可以进入任何一个它已经拥有的锁所同步着的代码块。在Java中,synchronized和ReentrantLock都是可重入锁。这种机制避免了死锁,并增加了编程的便捷性。”

可重入锁的更多功能

相比synchronized,ReentrantLock增加了一些高级功能。主要来说主要有三点:

1.等待可中断 : ReentrantLock提供了一种能够中断等待锁的线程的机制,通过 lock.lockInterruptibly() 来实现这个机制。也就是说正在等待的线程可以选择放弃等待,改为处理其他事情。

2.可实现公平锁 : ReentrantLock可以指定是公平锁还是非公平锁。而synchronized只能是非公平锁。所谓的公平锁就是先等待的线程先获得锁。ReentrantLock默认情况是非公平的,可以通过 ReentrantLock类的ReentrantLock(boolean fair)构造方法来制定是否是公平的。

3.可实现选择性通知(锁可以绑定多个条件): synchronized关键字与wait()和notify()/notifyAll()方法相结合可以实现等待/通知机制。ReentrantLock类当然也可以实现,但是需要借助于Condition接口与newCondition()方法。

可中断锁和不可中断锁有什么区别?

1.可中断锁:获取锁的过程中可以被中断,不需要一直等到获取锁之后 才能进行其他逻辑处理。ReentrantLock 就属于是可中断锁。 2.不可中断锁:一旦线程申请了锁,就只能等到拿到锁以后才能进行其他的逻辑处理。 synchronized 就属于是不可中断锁

lukaliou123 commented 2 years ago

11.乐观锁和悲观锁的理解及如何实现,有哪些实现方式?

1.悲观锁:总是假设最坏的情况,每次去拿数据的时候都认为别人会修该,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它解锁。Synchronized就是悲观锁

2.乐观锁:很乐观,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,可以使用版本号等机制。Java的atomic包下面的原子变量类就是使用了乐观锁的一种实现方式CAS实现的。

补充:悲观锁和乐观锁的使用场景

悲观锁,顾名思义,就是对数据的修改持有悲观态度。它假设在数据处理的过程中一定会有其他线程来进行修改,所以在每次读取的时候都会上锁,这样可以确保在读取修改数据的过程中不会被其他线程干扰。这样的处理方式适合在并发操作很高,读写操作非常频繁的场景。在这种场景下,数据冲突的可能性很大,为了保证数据的一致性,需要使用悲观锁。比如,银行转账操作。

乐观锁,相反,对数据的修改持有乐观态度。它假设在数据处理的过程中不会有其他线程进行修改,所以在读取的时候不会上锁,而是在更新时进行判断此期间有没有别的线程对数据进行修改。如果没有,那就进行更新。如果有,那就进行重试或者回滚。这样的处理方式适合在并发操作不高,读取操作非常频繁的场景。在这种场景下,数据冲突的可能性很小,使用乐观锁可以减少锁的竞争。比如,库存数量的查询。

12.什么是 CAS

1.CAS是compare and swap的缩写,即比较交换。CAS是乐观锁 2.CAS操作包含三个操作数:内存位置(V),预期原值(A)和新值(B)。如果内存地址里面的值和A的值是一样的,那么就将内存里面的值更新成B。CAS是通过无限循环来获取数据的,如果在第一轮循环中,a线程获取地址里面的值被b线程修改了,那么a线程需要自旋,到下次循环才有可能机会执行

12.1.CAS的缺点

1. ABA 问题

如果一个变量 V 初次读取的时候是 A 值,并且在准备赋值的时候检查到它仍然是 A 值,那我们就能说明它的值没有被其他线程修改过了吗?很明显是不能的,因为在这段时间它的值可能被改为其他值,然后又改回 A,那 CAS 操作就会误认为它从来没有被修改过。这个问题被称为 CAS 操作的 "ABA"问题。

ABA 问题的解决思路是在变量前面追加上版本号或者时间戳。JDK 1.5 以后的 AtomicStampedReference 类就是用来解决 ABA 问题的,其中的 compareAndSet() 方法就是首先检查当前引用是否等于预期引用,并且当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值。 1691305927817

2.循环时间长开销大

CAS 经常会用到自旋操作来进行重试,也就是不成功就一直循环执行直到成功。如果长时间不成功,会给 CPU 带来非常大的执行开销。 如果 JVM 能支持处理器提供的 pause 指令那么效率会有一定的提升,pause 指令有两个作用: 1.可以延迟流水线执行指令,使 CPU 不会消耗过多的执行资源,延迟的时间取决于具体实现的版本,在一些处理器上延迟时间是零。 2.可以避免在退出循环的时候因内存顺序冲而引起 CPU 流水线被清空,从而提高 CPU 的执行效率。

3.只能保证一个共享变量的原子操作

CAS 只对单个共享变量有效,当操作涉及跨多个共享变量时 CAS 无效。但是从 JDK 1.5 开始,提供了AtomicReference类来保证引用对象之间的原子性,你可以把多个变量放在一个对象里来进行 CAS 操作.所以我们可以使用锁或者利用AtomicReference类把多个共享变量合并成一个共享变量来操作。

lukaliou123 commented 2 years ago

13.什么是原子类

1.java.util.concurrent.atomic包:是原子类的小工具包,支持在单个变量上解除锁的线程安全编程 原子变量类相当于一种泛化的 volatile 变量,能够支持原子的和有条件的读-改-写操作。

2.原理:Atomic包中的类基本的特性就是在多线程环境下,当有多个线程同时对单个(包括基本类型及引用类型)变量进行操作时,具有排他性,即当多个线程同时对该变量的值进行更新时,仅有一个线程能成功,而未成功的线程可以向自旋锁一样,继续尝试,一直等到执行成功。

原子类的补充

关于Java中的原子类(Atomic classes),它们是一组提供原子操作的线程安全类。原子类的设计目的是为了在多线程环境下保证对共享变量的操作是原子性的,即不会被其他线程中断。

原子类提供了一些常见的原子操作,如原子更新、原子赋值、原子增减等,确保这些操作在多线程环境下是安全的,不会引发竞态条件等并发问题。

下面是一个使用原子类的例子,假设有一个计数器类:

1689583414098 在这个例子中,AtomicInteger是一个原子类,用于实现线程安全的计数器。通过使用incrementAndGet()和decrementAndGet()方法,对计数器进行原子的增加和减少操作,避免了多线程环境下可能出现的并发问题。

原子类适用于需要保证对共享变量的操作是原子性的场景,特别是在高并发环境下。例如计数器、ID生成器、多线程累加等都是常见的使用原子类的场景。

总结一下,原子类提供了一组线程安全的原子操作,保证了对共享变量的操作的原子性和线程安全性。在多线程环境下,使用原子类可以简化并发编程的复杂性,并减少竞态条件等并发问题的发生

lukaliou123 commented 1 year ago

14.如何保证线程安全?

1.同步原语(Synchronization Primitives): 这是最基本的处理线程安全的方式,例如 synchronized 关键字和 ReentrantLock。它们可以帮助你在对共享资源进行操作的时候,实现对资源的互斥访问。

2.原子类(Atomic Classes): 这些类,例如 AtomicInteger,内部通过CAS(Compare And Swap)等机制,提供了线程安全的基本操作,比如 incrementAndGet。

3.避免共享状态: 共享状态是多线程安全问题的根源,如果我们能够避免共享状态,就能避免线程安全问题。比如,我们可以使用 ThreadLocal 将需要的状态保存在每个线程中,避免多线程间的状态共享。另一个例子是使用函数式编程或不可变对象,避免状态修改。

4.使用并发集合(Concurrent Collections): Java 提供了很多线程安全的集合类,比如 ConcurrentHashMap、CopyOnWriteArrayList等,它们内部已经处理了线程安全的问题。

5.使用更高级的并发控制工具:Java 并发库提供了更高级的工具,比如 Semaphore、CountDownLatch、CyclicBarrier等,它们可以帮助你更好地控制并发。

6.理解并避免死锁:理解死锁的产生条件,并通过合理的设计和编码习惯避免死锁。