【JAVA】【面试】【基础篇】- 线程、锁

756 阅读29分钟
再快不能快基础,再烂不能烂语言!

【基础篇】- 线程

线程:一个程序同时执行多个任务,通常,每一个任务称为一个线程。
串行: 对于单条线程执行多个任务,例如下载多个任务,需要下载完一个再下载另一个。
并行:下载多个文件,开启多条线程,多个文件同时下载。


Java程序天生就是多线程的,运行Java程序首先要运行main方法主线程

  • 创建线程的方式及实现

    1. 继承Thread类创建线程

      (1) 定义Thread类的子类,并重写run方法,该run方法的方法体就代表了线程要完成的任务。因此把run()方法称为执行体。
      (2) 创建Thread子类的实例,即创建了线程对象。
      (3) 调用线程对象那个的start()方法来启动该线程。
    
      知识点:
      Thread.currentThread()方法返回当前正在执行的线程对象。
      GetName()方法返回调用该方法的线程的名字。
    

    2. 实现Runnable接口创建线程

      (1) 定义runnable接口的实现类,并重写接口的run()方法,该run()方法的方法体同样是该线程的线程执行体。
      (2) 创建Runnable实现类的实例,并以此实例作为Thread的target来创建Thread对象,该Thread对象才是真正的线程对象。
      (3) 调用线程对象的start()方法来启动该线程。
    

    3. 通过Callable和Future创建线程

      (1) 创建Callable接口的实现类,并实现call()方法,该call()方法作为线程执行体,并且有返回值。
      (2) 创建Callable实现类的实例,使用FutureTask类来包装Callable对象,该FutureTask对象封装了该Callable对象的call()方法的返回值。(FutureTask是一个包装器,它通过接收Callable来创建,它同时实现了Future和Runable接口。)
      (3) 使用FutureTask对象作为Thread对象的Target创建并启动新线程。
      (4) 调用FutureTask对象的get()方法来获得子线程执行结束后的返回值。
    
  • 三种创建线程方法的对比:

      (1)采用Runnable、Callable接口的方法创建多线程
      优势:线程类只是实现了Runnable接口或Callable接口,还可以实现其他类。在这种方式下,
      多个线程可以共享同一个target对象,所以非常适合多个相同线程来处理同一份资源的情况,从而可以将CPU,代码,数据分开,形成清晰的模型,
      较好地体现了面对对象的思想。
      劣势:编程稍微复杂,如果要访问当前线程,则必须使用Thread.currentThread()方法。
      
      (2)使用继承Thread类的方式创建多线程
      优势:编写简单,如果需要访问当前线程,则无需使用Thread.currentThread()方法,直接使用this即可获得当前线程。
      劣势: 线程类已经继承了Thread类,所以不能再继承其他父类。
      
      (3) Runable和Callable的区别
      Callable规定(重新)的方法是call(),Runnale规定(重写)的方法是run()。
      Callable的任务执行后可返回值,而Runnable的任务是不能返回值的。
      call方法可以抛出异常,run方法不可以。
      运行Callable任务可以拿到一个Future对象,表示异步计算的结果。它提供了检查计算是否完成的方法,以等待计算的完成,并检索计算的结果。
      通过Future对象可以了解任务执行情况,可取消任务的执行,还可获取执行结果。
    
  • sleep() 、join()、yield()有什么区别

    • sleep():

      sleep()方法需要指定等待时间,它可以让当前正在执行的线程在指定的时间内暂停执行,进入阻塞状态,该方法既可以让其他同优先级或高优先级的线程得到执行的机会,也可以让低优先级的线程得到执行的机会。但是sleep()方法不会释放"锁标志",也就是说如果有synchronized同步块,其他线程仍然不能访问共享数据。

    • wait():

      wait()方法需要跟notify()以及notifyAll()两个方法一起介绍,这三个方法用于协调多个线程对共享数据的存取,所以必须在synchronized语句块内使用,也就是说,notify()和notifyAll()的任务在调用这些方法前必须拥有对象的锁。注意,它们都是Object类的方法,而不是Thread类的方法。

      wait()方法与sleep()方法的不同之处在于,wait()方法会释放对象的"锁属性"。当调用某一对象的wait()方法后,会使当前线程暂停执行,并将当前线程放入对象等待池中,直到调用notify()方法后,将从对象等待池中移出任意一个线程并放入锁标志等待池中,只有锁标志等待池中的线程可以获取锁标志,它们随时准备争夺锁的拥有权。当调用了某个对象的notifyAll()方法,会将对象等待池中的所有线程都移动到该对象的锁等待池。

      除了使用notify()和notifyAll()方法,还可以使用带毫秒参数的wait(longtimeout)方法,效果是在延迟timeout毫秒后,被暂停的线程将恢复到锁标志等待池。

      此外,wait(),notify()及notifyAll()只能在synchronized语句中使用,但是如果使用的是ReenTrantLock实现同步,解决方法是使用ReenTrantLock.newCondition()获取一个Condition类对象,然后Condition的await(),signal()以及signalAll()分别对应上面的三个方法。

    • yield():

      yield()方法和sleep()方法类似,也不会释放“锁标志”,区别在于,它没有参数,即yield()方法只是使当前线程重新回到可执行状态,所以执行yield()的线程有可能在进入到可执行状态后马上又被执行,另外yield()方法只能使用同优先级或者高优先级的线程得到执行机会,这也和sleep()方法不同。

    • join():

      join()方法会使当前线程等待join()方法的线程结束后才能继续执行。

  • 常用的线程并发工具类

    CountDownLatch、CyclicBarrier、Semaphore和Exchanger

  • 说说 CountDownLatch 原理

    CountDownLatch简介:

    闭锁是一种同步工具类,可以延迟线程的进度直到其到达终止状态。闭锁的作用相当于一扇门,在闭锁到达结束状态之前,这扇门一直是关闭的,并且没有任何线程能通过,当到达技术状态时,这扇门会打开并允许所有的线程通过。当闭锁到达结束状态后,将不会再改变状态,因此这扇门将永远保持打开状态。闭锁可以用来确保某些活动直到其他活动都完成后才继续执行。

    CountDownLatch是一种灵活的闭锁实现,它是一个同步辅助类,在完成一组正在其他线程中执行的操作之前,它允许一个或多个线程一直等待。

    CountDownLatch实现原理:

    原文链接:blog.csdn.net/qq_39241239…

CountDownLatch是通过“共享锁”实现的。在创建CountDownLatch中时,会传递一个int类型参数count,该参数是“锁计数器”的初始状态,
表示该“共享锁”最多能被count个线程同时获取。当某线程调用该CountDownLatch对象的await()方法时,该线程会等待“共享锁”可用时,
才能获取“共享锁”进而继续运行,而“共享锁”可用的条件,就是“锁计数器”的值为0!而“锁计数器”的初始值为count,每当一个线程调用
该CountDownLatch对象的CountDown()方法时,才将“锁计数器”-1;通过这种方式,必须有count个线程调用countDown()之后,
“锁计数器”才为0,而前面提到的等待线程才能继续运行!
  • 说说 CyclicBarrier 原理

    CyclicBarrier简介:

    栅栏类似于闭锁,它能阻塞一线程直到某个事件发送。栅栏与闭锁的关键区别在于,所有线程必须同时到达栅栏位置,才能继续执行。闭锁用于等待时间,而栅栏用于等待其他线程。

    所有线程相互等待,直到所有的线程到达某一点时才打开栅栏,然后线程可以继续执行

    CyclicBarrier实现原理:

    原文链接:blog.csdn.net/qq_39241239…

CyclicBarrier的源码实现和CountDownLatch大相庭径,CyclicBarrier基于Condition来实现的。CyclicBarrier类的内部有一个计数器,
每个线程在到达屏障点的时候都会调用await方法将自己阻塞,此事计数器会减1,当计数器为0的时候所有因调用await方法而被阻塞的线程将被唤醒。
  • 说说 CountDownLatch 与 CyclicBarrier 区别

    1. 这两个类都可以实现一组线程在到达某个条件之前进行等待,它们内部都有一个计数器,当计数器的值不断的减为0的时候所有阻塞的线程将会被唤醒。
    2. CountDownLatch的计数器是有使用者来控制的,调用await方法只是将自己阻塞而不会减少计数器的值。
      CyclicBarrier的计数器是由自己控制,调用await方法不仅会将自己阻塞还会将减少计数器的值。
    3. CountDownLatch只能拦截一轮 CyclicBarrier可以实现循环拦截(CyclicBarrier可以实现CountDownLatch的功能,反之则不能)
    4. CountDownLatch的作用是允许1或N个线程等待其他线程完成执行;
      CyclicBarrier则是允许N个线程相互等待。
    5. CountDownLactch的计数器无法被重置;
      CyclicBarrier的计数器可以被重置后使用,因此它被称为是循环的。
  • 说说 Semaphore 原理

    在一个停车场中,车位是公共资源,每辆车就好比一个线程,看门人起的就是信号量的作用。

    信号量是一个非负整数,表示了当前公共资源的可用数目,当一个线程要使用公共资源时,首先要查看信号量,如果信号量的值大于1,则将其减1,然后去占有公共资源。如果信号量的值为0,则线程会将自己阻塞,直到有其他线程释放公共资源。

    在信号量上我们定义两种操作:acquire(获取)和release(释放)。当一个线程调用acquire操作时,它要么通过成功获取信号量(信号量-1),要么一直等下去,直到有线程释放信号量,或超时。release(释放)实际上会将信号量的值加1,然后唤醒等待线程。

    信号量主要用于两个目的,一个是用于多个共享资源的互斥使用,另一个用于并发线程数的控制。

  • 说说 Exchanger 原理

Exchanger ————交换器,是JDK1.5时引入的一个同步器,从字面上就可以看出,这个类的主要作用是交换数据。

Exchanger有点类似CyclicBarrier,CyclicBarrier是一个栅栏,到达栅栏的线程需要等待其他一定数量的线程到达后,才能通过栅栏。
Exchanger可以看成是一个双向栅栏,如上图:Thread1线程到达栅栏后,会首先观察有没有其它线程已到达栅栏,如果没有就等待,
如果已经有其他线程(Thread2)已经到达了,就会以成对的方式交换各自携带的信息,因此Exchange非常适合于两个线程之间的数据交换。

Exchanger<String> exchanger=new Exchanger<String>();
exchanger.exchange(tool) tool为交换的数据

  • ThreadLocal 原理分析

    ThreadLocal简介:

    ThreadLocal,这个类提供线程局部变量,这些变量与其他正常的变量的不同之处在于,每一个访问该变量的线程在其内部都有一个独立的初始化的变量副本;ThreadLocal实例变量通常用private static在类中修饰。

    只要ThreadLocal的变量能被访问,并且线程存活,那每个线程都会持有ThreadLocal变量的副本。当一个线程结束时,它所持有的所有ThreadLocal相对的实力副本都可被回收。

    ThreadLocal适用于每个线程需要自己独立的实例且该实例需要在多个方法中被使用(相同线程数据共享),也就是变量在线程间隔离(不同的线程数据隔离)而在方法或类间共享的场景。

    ThreadLocal原理分析: blog.csdn.net/Mrs_chens/a…

    对象实例与ThreadLocal变量的映射关系是由线程Thread来维护的。

      对象实例与ThreadLocal变量的映射关系是存放在一个Map里面(这个Map是个抽象的Map并不是java.util中的Map),
      这个Map是Thread类的一个字段!而真正存放映射关系的Map就是ThreadLocalMap。
      
      在set方法中首先要获取当前线程,然后通过getMap获取当前线程的ThreadLocalMap类型的变量threadLocals,如果存在则直接赋值,如果不存
      在则给该线程ThreadLocalMap变量赋值。赋值的时候这里的this就是调用变量的对象实例本身。
      
      get方法,同样也是先获取当前线程的ThreadLocalMap变量,如果存在则返回值,不存在则创建并返回初始值。setInitialValue()
    
  • 讲讲线程池的实现原理

    • 线程池简介:

      正常情况下使用线程的时候就会去创建一个线程,但是在并发情况下线程的数量很多,每个线程执行一个很短的任务就结束了,这样频繁创建线程就会大大降低系统的效率,因为频繁创建线程和销货线程需要时间。

    线程池使得线程可以复用,执行完一个任务,并不被销毁,而是可以继续执行其他的任务。

    线程池的好处,就是可以方便的管理线程,也可以减少内存的消耗。

    • 线程池状态:

      runState: 表示当前线程池的状态,在ThreadPoolExecutor中为一个volatile变量,用来保证线程之间的可见性。

      RUNNING: 当创建完线程后的初始值。

      SHUTDOWN: 调用shutdow()方法后,此时线程池不能接受新的任务,它会等待所有任务执行完毕。

      STOP: 调用shutdownnow()方法后,此时线程池不能接受新的任务,并且会去尝试终止正在执行的任务。

      TERMINATED: 当线程池已处于SHUTDOWN或STOP状态,并且所有工作线程已经销毁,任务缓存队列已经清空或执行结束后,线程池被设置为TERMINATED状态。

    • 线程池执行流程:

      任务进来时,首先要执行判断,判断核心线程是否处于空闲状态,
      如果不是,核心线程就会先执行任务,如果核心线程已满,则判断任务队列是否有地方存放任务,
      如果有,就将任务保存在队列中,等待执行,如果满了,在判断最大可容纳的线程数,
      如果没有超出这个数量就开创非核心线程执行任务,如果超出了,就调用handler实现拒绝策略。
      
      handler的拒绝策略:
      第一种(AbortPolicy):不执行新的任务,直接抛出异常,提示线程池已满
      第二种(DisCardPolicy):不执行新的任务,也不抛出异常
      第三种(DisCardOldSetPolicy):将消息队列中的第一个任务替换为当前新进来的任务执行
      第四种(CallerRunsPolicy):直接调用execute来执行当前任务
    
  • 线程池的几种方式

    • CachedThreadPool: 可缓存的线程池,该线程池中没有核心线程,非核心线程的数量为Intger.Max_value,就是无限大,当有需要时创建线程来执行任务,没有需要时回收线程,适用于耗时少,任务量大的情况。
    • SecudleThreadPool: 周期性执行任务的线程池,按照某种特定的计划执行线程中的任务,有核心线程,但也有非核心线程,非核心线程的大小也为无限大。适用于执行周期性的任务。
    • SingleThreadPool: 只有一条线程来执行任务,适用于有顺序的任务的应用场景。
    • FixedThreadPool: 定长的线程池,有核心线程,核心线程的即为最大的线程数量,没有非核心线程。
  • 线程的生命周期

第一步是用new Thread()的方法新建一个线程,在线程创建完成之后,线程就进入了就绪状态(Runnable),
此时创建出来的线程进入抢占CPU资源的状态,当线程抢到了CPU的执行权之后,线程就进入了运行状态(Running),
当线程的任务执行完成之后或者是非常态的调用stop()方法之后,线程就进入了死亡状态。

以下几种情况容易造成线程阻塞:
1. 当线程主动调用了sleep()方法时,线程会进入阻塞状态;
2. 当线程主动调用了阻塞时的IO方法时,这个方法有一个返回参数,当参数返回之前,线程也会进入阻塞状态;
3. 当线程进入正在等待某个通知时,会进入阻塞状态;

如何跳出阻塞过程:
1. 当sleep()方法的睡眠时长过去后,线程就自动跳出了阻塞状态
2. 第二种则是在返回一个参数之后,在获取到了等待的通知时,就自动跳出了线程的阻塞过程。

【基础篇】- 锁

  • 什么是线程安全

blog.csdn.net/csdnnews/ar…

当多个线程访问某个方法时,不管你通过怎么的调用方式,或者说这些线程如何交替的执行,我们在主程序中不需要去做任何的同步,这个类的结果行为都是我们设想的正确行为,我们就说这个类是线程安全的。

无状态的对象是线程安全的(代码中不包含任何的作用域,也没有引用其他类中的域)。
  • 如何确保线程安全?

    • synchronized:用来控制线程同步,保证在多线程环境下,不被多个线程同时执行,确保数据的完整性,一般是加在方法上。当synchronized锁住一个对象后,别的线程要想获取锁对象,那么就必须等这个线程执行完释放锁对象之后才可以使用。
    public class ThreadDemo {
           int count = 0; // 记录方法的命中次数
           public synchronized void threadMethod(int j) {
               count++ ;
               int i = 1;
               j = j + i;
           }
        }
    
    • Lock:Lock是在java1.6被引入进来的,Lock的引入让锁有了可操作性,在需要的时候去手动的获取锁和释放锁

      • Lock()在获取锁的时候,如果拿不到锁,就一直处于等待状态,直到拿到锁
      • tryLock()是有一个Boolean的返回值的,如果没有拿到锁,直接返回false,停止等待,它不会像Lock()那样去一直等待获取锁,tryLock()是可以设置等待的相应时间的。
    private Lock lock = new ReentrantLock(); // ReentrantLock是Lock的子类
    
    private void method(Thread thread){
       lock.lock(); // 获取锁对象
       try {
           System.out.println("线程名:"+thread.getName() + "获得了锁");
           // Thread.sleep(2000);
       }catch(Exception e){
           e.printStackTrace();
       } finally {
           System.out.println("线程名:"+thread.getName() + "释放了锁");
           lock.unlock(); // 释放锁对象
       }
    }
    
  • synchronized 与 lock 的区别

    类别synchronizedlock
    存在层次java内置关键字,在jvm层面Lock是个java类
    锁状态无法判断是否获取锁的状态可以判断是否获取到锁
    锁的释放会自动释放锁
    (a线程执行完同步代码会释放锁)
    (b线程执行过程中发生异常会释放锁)
    需要在finally中手动释放锁
    (unlock()方法释放锁)
    否则会造成线程死锁
    锁的获取使用关键字的两个线程1和线程2
    如果当前线程1获得锁,线程2等待
    如果线程1阻塞,线程2则会一直等待下去
    如果尝试获取不到锁
    线程可以不用一直等待就结束了
    锁类型可重入,不可中断,非公平可重入,可判断,可公平
    性能适合代码少量的同步问题适合大量同步代码的同步问题
    ------------------------------
  • 锁的类型

    • 可重入锁: 在执行对象中所有同步方法不用再次获得锁
    • 可中断锁: 在等待获取锁过程中可中断
    • 公平锁: 按等待获取锁的线程的等待时间进行获取,等待时间长的具有优先获取锁权利
    • 读写锁: 对资源读取和写入的时候拆分为2部分处理,读的时间可以多线程一起读,写的时候必须同步的写
  • volatile 实现原理

    volatile通常被比喻成“轻量级的synchronize”,也是并发编程中比较重要的一个关键字。和synchronized不同,volatile是一个变量修饰符,只能用来修饰变量,无法修饰方法及代码块等。

    使用volatile只需要在声明一个可能被多线程同时访问的变量时,使用volatile修饰符就可以了。

    实现原理:

      为了提交处理器的执行速度,在处理器和内存之间增加了多级缓存来提升。但是由于引入了多级缓存,就存在缓存数据不一致问题。
      
      但是对于volatile变量,当对volatile变量进行读写操作的时候,jvm会向处理器发送一条lock前缀的指令,将这个缓存中的变量回写到系统主存中。
      
      但是就算写回到内存,如果其他处理器缓存的值还是旧的,再执行计算机操作就会有问题,所以在多处理器下,为了保证每个处理器的缓存是一致的,
      就会实现缓存一致性协议。
      
      缓存一致性协议:每个处理器通过嗅探在总线上传播的数据来检查自己缓存是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,
      就会将当前处理器的缓存行设置成无效状态,当处理器要对这个数据进行修改操作的时候,会强制重新从系统内存里把数据读到处理器缓存里。
      
      所以,如果一个变量被volatile所修饰的话,在每次数据变化之后,其值都会被强制刷入主存。而其他处理器的缓存由于遵守了缓存一致性协议,
      也会把这个变量的值从主存加载到自己的缓存中。这就保证了一个volatile在并发编程中,其值在多个缓存中是可见的。
      
      可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。
    
  • synchronized 实现原理

    参考链接: blog.csdn.net/javazejian/…

    synchronized是基于Java对象头的同步锁

    synchronized实现同步的基础:java中的每一个对象都可以作为锁

    • 对于普通方法,锁是当前实例对象

    • 对于静态同步方法,锁是当前类的Class对象

    • 对于同步方法块,锁是synchonized括号里配置的对象

    【概念】monitor: 每一个对象都有一个监视器锁(monitor),当线程执行时对对象进行加锁,实际上就是将对象的monitor的状态设置为锁定状态,monitorenter指令执行的就是这个动作;线程对对象释放锁就是执行monitorexit指令,将对象的monitor的状态置为无锁状态(假设我们先不考虑锁的优化)。

    实现原理:

    1. Java虚拟机中的同步(Synchronization)基于进入和退出管理(Monitor)对象实现的。在Java语言中,同步用的最多的地方可能是被synchronized修饰的同步方法。同步方法不是由monitorenter和monitorexit指令来实现同步的,而是由方法调用指令读取运行时常量池中方法的ACC_SYNCHRONIZED标志来隐式实现的。

    • 实例变量:存放类的属性数据信息,包括父类的属性信息,如果是数组的实例部分还包括数组的长度,这部分内存按4字节对齐。

    • 填充数据:由于虚拟机要求对象起始地址必须是8字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐,了解即可。

    • 对象头,它是实现synchronized锁对象的基础。synchronized使用的锁对象是存储在java对象头里的,jvm中采用2个字节来存储对象头(如果对象是数组则会分配3个字,多出来的1个字记录的是数组长度),其主要结构由以下组成:

      虚拟机位数头对象结构说明
      32/64bitMark Word存储对象的hashCode,锁信息或
      或分代年龄或GC标识等信息
      32/64bitClass Metadata Address类型指针指向对象的类元数据,JVM
      通过这个指针确定该对象是哪个类的实例
      其中Mark Word在默认情况下存储着对象的HashCode、分代年龄,锁标记等以下是32位JVM的Mard Word默认存储结构
      锁状态25bit4bit1bit
      是否是偏向锁
      2bit
      锁标志位
      无锁状态对象HashCode对象分代年龄001

      由于对象头的信息是与对象自身定义的数据没有关系的额外存储成本,因此考虑到JVM的空间效率,Mark Word 被设计成一个非固定的数据结构,以便存储更多有效的数据,它会根据对象本身的状态复用自己的存储空间。

    1. 轻量级锁和偏向锁是Java6对synchronized锁进行优化后新增加的,重量级锁也就是通常说synchronized的对象锁,锁标识位为10,其中指针指向的是monitor对象(也称为管理或监视器锁)的起始地址。每个对象都存在着一个monitor与之关联,对象与其monitor之间的关系存在多种实现方式。如monitor可以与对象一起创建销毁或当线程试图获取对象锁时自动生成,但当一个monitor被某个线程持有后,它便处于锁定状态。在java虚拟机中,monitor是由ObjectMonitor实现的(C++实现)。

    2. ObjectMonitor中有两个队列,_WaitSet和_EntryList,用来保存ObjectWaiter对象列表(每个等待锁的线程都会被封装成ObjectWaiter对象),_owner指向持有ObjectMonitor对象的线程,当多个线程同时访问一段同步代码时,首先会进入_EntryList集合,当线程获取到对象的monitor后进入_Owner区域并把monitor中的owner变量设置为当前线程同时monitor中的计数器count加1,若线程调用wait()方法,将释放当前持有的monitor,owner变量恢复为null,count自减1,同时该线程进入WaitSet集合中等待被唤醒。若当前线程执行完毕也将释放monitor(锁)并复位变量的值,以便其他线程进入获取monitor(锁)。

    3. 由此看来,monitor对象存在于每个java对象的对象头中(存储的指针的指向),synchronized锁便是通过这种方式获取锁的,也是为什么java中任意对象可以作为锁的原因,同时也是notify/motifyAll/Wait等方法存在于顶级对象Object中的原因。

  • volatile和synchronized区别

    • volatile:本质是在告诉jvm当前变量在寄存器(工作内存)中的值是不确定的,需要从主存中读取;

      synchronized:则是锁定当前变量,只有当线程可以访问该变量,其他线程被阻塞住。

    • volatile:仅能使用在变量级别;

      synchronized:则可以使用在变量,方法和类级别的。

    • volatile:仅能实现变量的修改可见性,不能保证原子性;

      synchronized:则可以保证变量的修改可见性和原子性。

    • volatile:不会造成线程的阻塞;

      synchronized:可能会造成线程的阻塞。

    • volatile:标记的变量不会被编译器优化;

      synchronized:标记的变量可以被编译器优化。

  • CAS 乐观锁

    blog.csdn.net/qq_35571554…

    • 悲观锁:

      独占锁是一种悲观锁,而synchronized就是一种独占锁,synchronized会导致其它所有未持有的锁的线程阻塞,而等待持有锁的线程释放锁。 synchronized属于悲观锁,悲观地认为程序中的并发情况严重,所以严防死守。

    • 乐观锁:

      每次不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止,而乐观锁用到的机制就是CAS。

    • CAS(Compare And Swap)(比较并替换):

      CAS机制当中使用了3个基本操作数:内存地址V,旧的预期值A,要修改的新值B

      更新一个变量的时候,只有当变量的预期值A和内存地址V当中的实际值相同时,才会将内存地址V对应的值修改为B。

      CAS底层利用了unsafe提供了原子性操作方法。

      例如:

      1. 在内存地址V当中,存储着值为10的变量。
      2. 此时线程1想要把变量的值增加1。对线程1来说,旧的预期值A=10,要修改的新值B=11。
      3. 在线程1要提交更新之前,另一个线程2抢先一步,把内存地址V中的变量值率先更新成了11。
      4. 线程1开始提交更新,首先进行A和地址V的实际值比较(Compare),发现A不等于V的实际值,提交失败。
      5. 线程1重新获取内存地址V的当前值,并重新计算想要修改的新值。此时对线程1来说,A=11,B=12。这个重新尝试的过程被称为自旋。
      6. 这一次比较幸运,没有其他线程改变地址V的值。线程1进行Compare,发现A和地址V的实际值是相等的。
      7. 线程1进行SWAP,把地址V的值替换为B,也就是12。
    • CAS的缺点:

      1. CPU开销较大: 在并发量比较高的情况下,如果许多线程反复尝试更新某一个变量,却又一直更新不成功,循环往复,会给CPU带来很大的压力。

      2. 不能保证代码块的原子性: CAS机制所保证的只是一个变量的原子性操作,而不能保证整个代码块的原子性。比如需要保证3个变量共同进行原子性的更新,就不得不使用Synchronized了。

      因为它本身就只是一个锁住总线的原子交换操作啊。两个CAS操作之间并不能保证没有重入现象。

  • ABA 问题

    • 可以发现,CAS实现的过程是先取出内存中某时刻的数据,在下一时刻比较并替换,那么在这个时间差会导致数据的变化,此时就会导致出现“ABA”问题。

    • 什么是”ABA”问题? 比如说一个线程one从内存位置V中取出A,这时候另一个线程two也从内存中取出A,并且two进行了一些操作变成了B,然后two又将V位置的数据变成A,这时候线程one进行CAS操作发现内存中仍然是A,然后one操作成功。尽管线程one的CAS操作成功,但是不代表这个过程就是没有问题的。

      例如:

      1. 从取款机取50块钱,余额为100
      2. 当线程1执行成功后,当前余额为50,由于内存地址的值改变,导致线程2阻塞
      3. 这时正好有转账50元信息,线程3执行成功
      4. 当线程2在自旋的过程中检测到内存地址的值与旧的预期值是一致的,所以就会再次进行取款操作,正常情况下线程2应该是执行失败的,结果由于ABA的问题提交成功了。
    • 解决ABA问题

      当一个值从A更新到B,又更新为A,普通的CAS机制会误判通过检测。

      利用版本号比较就可以有效解决ABA问题。

  • 乐观锁的业务场景及实现方式

    • 乐观锁的应用场景: 在多节点部署或者多线程执行时,同一个时间可能有多个线程更新相同数据,产生冲突,这就是并发问题。这样的情况下会出现以下问题:

      • 更新丢失:一个事务更新数据后,被另一个更新数据的事务覆盖。
      • 脏读:一个事务读取另一个事务为提交的数据,即为脏读。
      • 其次还有幻读。

      针对并发引入控制机制,即加锁。

      加锁的目的是在同一时间只有一个事务在更新数据,通过锁独占数据的修改权。

    • 乐观锁的实现方式:

      • version方式:一般是在数据表中加上一个数据版本号version字段,表示数据被修改的次数,当数据被修改时,version值会加一。当线程A要更新数据值时,在读取数据的同时也会读取version值,在提交更新时,若刚才读取到的version值为当前数据库中的version值相等时才更新,否则重试更新操作,知道操作成功。
      update table set x=x+1, version=version+1 where id=#{id} and version=#{version};  
      
      • CAS操作方式:即compare and swap或者compare and set,涉及到三个操作数,数据所在的内存值,预期值,新值。当需要更新时,判断当前内存值与之前取到的值是否相等,若相等,则用新值更新,若失败则重试,一般情况下是一个自旋操作吗,即不断的重试。

更详细的面试总结链接请戳:👇👇
juejin.cn/post/684490…

【推荐篇】- 书籍内容整理笔记链接地址
【推荐】【Java编程思想】【笔记】juejin.cn/post/684490…
【推荐】【Java核心技术 卷Ⅰ】【笔记】juejin.cn/post/684490…
若有错误或者理解不当的地方,欢迎留言指正,希望我们可以一起进步,一起加油!😜😜