https://www.bilibili.com/video/BV1QF411Y7GY?p=2

周阳主讲

https://www.bilibili.com/video/BV1zb411M7NQ?p=2&spm_id_from=pageDriver

JUC

Volatile

1、谈谈你对volatile的理解

volatile是Java虚拟机提供的轻量级同步机制;其包含三个特点:禁止指令重排、保证可见性、但不保证原子性;回答的时候从这三个方面入手,但是要从这几个方面入手,又不得不对JMM有一定的了解;

2、说说你对JMM的理解,JMM是什么?

1、Java基础

Java面向对象有哪些特征?

Java面向对象的特征有封装继承多态,这谁都能答出来,在我看来你不仅要答出来还得说明为什么这些特征能够帮我们写出更好的代码:封装、继承—>复用性;多态—->可移植性,健壮性,灵活性;

ArrayList跟LinkedList的区别

基本上从插入、删除、访问,这三个方面的效率去回答。

jdk1.8的新特性有哪些?

多线程与高并发试题

基础相关

进程与线程

什么是进程?

回答思路:由我们程序员编写的程序入手引出进程就是在内存中运行起来的程序指令;

  • 程序由指令和数据组成,但这些指令要运行,数据要读写,就必须将指令加载至 CPU,数据加载至内存。在 指令运行过程中还需要用到磁盘、网络等设备。
  • 当一个程序被运行,从磁盘加载这个程序的代码至内存,这时就开启了一个进程,来加载指令、管理内存、管理 IO 的
  • 进程就可以视为程序的一个实例。大部分程序可以同时运行多个实例进程(例如记事本、画图、浏览器 等),也有的程序只能启动一个实例进程(例如网易云音乐、360 安全卫士等)[ 运行多个进程实例就比如说一个笔记本应用可以打开多个;而网易云不论你点击多少次都是打开同一个进程。

    什么是线程?

  • 一个程序的执行有一条或多条执行路径,每一条执行路径就是一个线程;一个进程可以分为一到多个线程。

  • 一个线程就是一个指令流,将指令流中的一条条指令以一定的顺序交给 CPU 执行
  • Java 中,线程作为最小调度单位(最小调度单位:CPU从线程中获取指令),进程作为资源分配的最小单位。 在 windows 中进程是不活动的,只是作 为线程的容器

    进程和线程的区别与联系是什么?

  • 进程之间基本上相互独立的,而线程存在于进程内;进程是运行中的程序,线程是进程的内部的一个执行路径

  • 进程拥有共享的资源,如内存空间等,供其内部的线程共享 ;进程是资源分配的单元,线程是执行单元
  • 进程间通信较为复杂
    • 同一台计算机的进程通信称为 IPC(Inter-process communication)
    • 不同计算机之间的进程通信,需要通过网络,并遵守共同的协议,例如 HTTP
  • 线程通信相对简单,因为它们共享进程内的内存,一个例子是多个线程可以访问同一个共享变量
  • 线程更轻量,线程上下文切换成本一般上要比进程上下文切换低

串行、并行和并发是什么,有什么区别?

串行( Sequential )简单来说就是一次只能做一件事情,而且还得按照顺序依次执行,后面的代码段必须等到前面代码段的任务执行完毕后才能执行。
并发( concurrency )是以交替的方式利用等待某件事情完成的时间来做其他事情。整个周期的总耗时:在一件任务等待时间内,节省这段等待时间来完成另一件任务。
串行和并发的区别是必须做完A才能做B,并发是在做A的时候若是A需要等待其他资源此时就可以去做B;
并行( parallelism )指的是在同一时刻,任务可以同时开始进行,彼此之间没有依赖关系。整个周期的总耗时取决于耗时最长的那件事情所需的时间。
单核 cpu 下,线程实际还是串行执行的。操作系统中有一个组件叫做任务调度器,将 cpu 的时间片(windows 下时间片最小约为 15 毫秒)分给不同的程序使用,只是由于 cpu 在线程间(时间片很短)的切换非常快,所以就给人类感 觉是 同时运行的 。
简单地总结为一句话就是: 微观串行,宏观并行 。 一般会将这种 线程轮流使用 CPU 的做法称为并发, concurrent。
区别:

  • 并行(多核)是指两个或者多个事件在同一时刻发生;而并发(单核)是指两个或多个事件在同一时间间隔发生。
  • 并行是在不同实体上的多个事件,并发是在同一实体上的多个事件。
  • 在一台处理器上“同时”处理多个任务,在多台处理器上同时处理多个任务。如hadoop分布式集群。

    对于单核cpu,能否做到真正的多线程并发?

    对于多核的cpu电脑来说,真正的多线程并发是没问题的,比如4核的cpu就能够在同一个时间点上让四个进程并发执行。但是对于单核cpu来说,不可能真正的做到多线程并发,它只能做到给人一种并发的感觉。实际上它的将cpu执行时间高速的来回切换而已。

在 java 程序中怎么保证多线程的运行安全?

线程安全在三个方面体现:

  • 原子性:提供互斥访问,同一时刻只能有一个线程对数据进行操作,(atomic,synchronized);
  • 可见性:一个线程对主内存的修改可以及时地被其他线程看到,(synchronized,volatile);
  • 有序性:一个线程观察其他线程中的指令执行顺序,由于指令重排序,该观察结果一般杂乱无序,(happens-before原则)。

    举例说明同步和异步。

    答:如果系统中存在临界资源(资源数量少于竞争资源的线程数量的资源),例如正在写的数据以后可能被另一个线程读到,或者正在读的数据可能已经被另一个线程写过了,那么这些数据就必须进行同步存取(数据库操作中的排他锁就是最好的例子)。当应用程序在对象上调用了一个需要花费很长时间来执行的方法,并且不希望让程序等待方法的返回时,就应该使用异步编程,在很多情况下采用异步途径往往更有效率。事实上,所谓的同步就是指阻塞式操作,而异步就是非阻塞式操作。

    守护线程是什么?

    守护线程是区别于用户线程,用户线程即我们手动创建的线程,而守护线程是程序运行的时候在后台提供一种通用服务的线程。 只要当前JVM实例中尚存在任何一个非守护线程没有结束,守护线程就全部工作;只有当最后一个非守护线程结束时,守护线程随着JVM一同结束工作。
    守护线程应用场景
    守护线程拥有自动结束自己生命周期的特性,非守护线程却没有。如果垃圾回收线程是非守护线程,当JVM 要退出时,由于垃圾回收线程还在运行着,导致程序无法退出,这就很尴尬。这就是为什么垃圾回收线程需要是守护线程
    通常来说,守护线程经常被用来执行一些后台任务,但是呢,你又希望在程序退出时,或者说 JVM 退出时,线程能够自动关闭,此时,守护线程是你的首选。


同时多线程编程也会存在的问题与风险

1.线程安全(Thread safe)问题: 多线程共享数据时,如果没有采取 正确的并发访问控制措施,就可能会产生数据一致性问题,如读取脏数 据(过期的数据), 如丢失数据更新。
2.线程活性(thread liveness)问题: 由于程序自身的缺陷或者由资 源稀缺性导致线程一直处于非 RUNNABLE 状态,这就是线程活性问题常见的活性故障有以下几种:
(1) 死锁(Deadlock). 类似鹬蚌相争.
(2) 锁死(Lockout), 类似于睡美人故事中王子挂了
(3) 活锁(Livelock). 类似于小猫咬自己尾巴
(4) 饥饿(Starvation).类似于健壮的雏鸟总是从母鸟嘴中抢到食物.
3.上下文切换(Context Switch): 处理器从执行一个线程切换到执 行另外一个线程
4.可靠性: 可能会由一个线程导致 JVM 意外终止,其他的线程也 无法执行

创建线程

创建线程有哪几种方式?

①. 继承Thread类创建线程类

  • 定义Thread类的子类,并重写该类的run方法,该run方法的方法体就代表了线程要完成的任务。因此把run()方法称为执行体。
  • 创建Thread子类的实例,即创建了线程对象。
  • 调用线程对象的start()方法来启动该线程。

②. 通过Runnable接口创建线程类

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

③. 通过Callable和Future创建线程

  • 创建Callable接口的实现类,并实现call()方法,该call()方法将作为线程执行体,并且有返回值
  • 创建Callable实现类的实例,使用FutureTask类来包装Callable对象,该FutureTask对象封装了该Callable对象的call()方法的返回值。
  • 使用FutureTask对象作为Thread对象的target创建并启动新线程。
  • 调用FutureTask对象的get()方法来获得子线程执行结束后的返回值。

    说一下 runnable 和 callable 有什么区别?

    有点深的问题了,也看出一个Java程序员学习知识的广度。

  • Runnable接口中的run()方法的返回值是void,它做的事情只是纯粹地去执行run()方法中的代码而已;

  • Callable接口中的call()方法是有返回值的,是一个泛型,和Future、FutureTask配合可以用来获取异步执行的结果。

    编写多线程程序有几种实现方式?

    答:Java 5以前实现多线程有两种实现方法:一种是继承Thread类;另一种是实现Runnable接口。两种方式都要通过重写run()方法来定义线程的行为,推荐使用后者,因为Java中的继承是单继承,一个类有一个父类,如果继承了Thread类就无法再继承其他类了,显然使用Runnable接口更为灵活。
    Java 5以后创建线程还有第三种方式:实现Callable接口,该接口中的call方法可以在线程执行结束时产生一个返回值。

    线程状态

    线程的基本状态以及状态之间的关系?

    https://www.nowcoder.com/ta/review-java/review?page=13
    image.png

  • 新建( new ):新创建了一个线程对象。

  • 可运行( runnable ):线程对象创建后,其他线程(比如 main 线程)调用了该对象 的 start ()方法。该状态的线程位于可运行线程池中,等待被线程调度选中,获 取 cpu 的使用权 。
  • 运行( running ):可运行状态( runnable )的线程获得了 cpu 时间片( timeslice ) ,执行程序代码。
  • 阻塞( block ):阻塞状态是指线程因为某种原因放弃了 cpu 使用权,也即让出了 cpu timeslice ,暂时停止运行。直到线程进入可运行( runnable )状态,才有 机会再次获得 cpu timeslice 转到运行( running )状态。阻塞的情况分三种:
    • 等待阻塞:运行( running )的线程执行 o . wait ()方法, JVM 会把该线程放 入等待队列( waitting queue )中。
    • 同步阻塞:运行( running )的线程在获取对象的同步锁时,若该同步锁 被别的线程占用,则 JVM 会把该线程放入锁池( lock pool )中。
    • 其他阻塞: 运行( running )的线程执行 Thread . sleep ( long ms )或 t . join ()方法,或者发出了 I / O 请求时, JVM 会把该线程置为阻塞状态。 当 sleep ()状态超时、 join ()等待线程终止或者超时、或者 I / O 处理完毕时,线程重新转入可运行( runnable )状态。
  • 死亡( dead ):线程 run ()、 main () 方法执行结束,或者因异常退出了 run ()方法,则该线程结束生命周期。死亡的线程不可再次复生。

简单来说,当需要新起一个线程来执行某个子任务时,就需要创建一个线程。但是线程创建之后,不会立即进入就绪状态,因为线程的运行需要一些条件(比如内存资源,程序计数器、Java栈、本地方法栈都是线程私有的,所以需要为线程分配一定的内存空间),只有线程运行需要的所有条件满足了,才进入就绪状态。当线程进入就绪状态后,也不代表立刻就能获取CPU执行时间,进入运行态,也许此时CPU正在执行其他的事情,因此这个线程要等待。当得到CPU执行时间之后,线程便真正进入运行状态。线程在运行状态过程中,可能有多个原因导致当前线程不继续运行下去,比如用户主动让线程睡眠(睡眠一定的时间之后再重新执行)、用户主动让线程等待,或者被同步块给阻塞,此时就对应着多个状态:time waiting(睡眠或等待一定的事件)、waiting(等待被唤醒)、blocked(阻塞)。当由于突然中断或者子任务执行完毕,线程就会被消亡而结束。
Thread.State枚举类记录了各个状态的信息
image.png

线程状态转化时涉及到的上下文切换

对于单核CPU来说(对于多核CPU,此处就理解为一个核),CPU在一个时刻只能运行一个线程,当在运行一个线程的过程中转去运行另外一个线程,这个叫做线程的上下文切换(对于进程也是类似)。
  由于可能当前线程的任务并没有执行完毕,而线程的时间片用完了,或者线程自己调用了 sleep、yield、wait、join、park、synchronized、lock 等方法,所以在切换时需要保存线程的运行状态,以便下次重新切换回来时能够继续切换之前的状态运行。举个简单的例子:比如一个线程A正在读取一个文件的内容,正读到文件的一半,此时需要暂停线程A,转去执行线程B,当再次切换回来执行线程A的时候,我们不希望线程A又从文件的开头来读取。
  因此需要记录线程A的运行状态,那么会记录哪些数据呢?因为下次恢复时需要知道在这之前当前线程已经执行到哪条指令了,所以需要记录程序计数器的值,另外比如说线程正在进行某个计算的时候被挂起了,那么下次继续执行的时候需要知道之前挂起时变量的值时多少,因此需要记录CPU寄存器的状态。所以一般来说,线程上下文切换过程中会记录程序计数器、CPU寄存器状态等数据。
  说简单点的:对于线程的上下文切换实际上就是 存储和恢复CPU状态的过程,它使得线程执行能够从中断点恢复执行。
  虽然多线程可以使得任务执行的效率得到提升,但是由于在线程切换时同样会带来一定的开销代价,并且多个线程会导致系统资源占用的增加,所以在进行多线程编程时要注意这些因素。

查看进程线程的运行情况

线程运行的大概原理是什么、

常见的关键API

Thread类的sleep()方法和对象的wait()方法都可以让线程暂停执行,它们有什么区别?

答:sleep()方法(休眠)是线程类(Thread)的静态方法,调用此方法会让当前线程暂停执行指定的时间,将执行机会(CPU)让给其他线程,但是对象的锁依然保持,因此休眠时间结束后会自动恢复(是继续执行还是回到就绪状态等待)
wait()是Object类的方法,调用对象的wait()方法导致当前线程放弃对象的锁(线程暂停执行),进入对象的等待池(wait pool),只有调用对象的notify()方法(或notifyAll()方法)时才能唤醒等待池中的线程进入等锁池(lock pool),如果线程重新获得对象的锁就可以进入就绪状态。

Sleep和 Wait的区别

1、sleep是Thread方法,而wait是Object的方法
2、sleep不需要强制和synchronized配合使用,但wait需要和synchronized一起用。
3、sleep在睡眠的同时,不会释放对象锁的,但wait在等待的时候会释放对象锁。
4、Sleep(long n) 和 Wait(long n)的区别 (重点)
相同点

  • 阻塞状态都为TIMED_WAITING (限时等待)

notify()和 notifyAll()有什么区别?

  • 如果线程调用了对象的 wait()方法,那么线程便会处于该对象的等待池中,等待池中的线程不会去竞争该对象的锁。
  • 当有线程调用了对象的 notifyAll()方法(唤醒所有 wait 线程)或 notify()方法(只随机唤醒一个 wait 线程),被唤醒的的线程便会进入该对象的锁池中,锁池中的线程会去竞争该对象锁。也就是说,调用了notify后只有一个线程会由等待池进入锁池,而notifyAll会将该对象等待池内的所有线程移动到锁池中,等待锁竞争。
  • 优先级高的线程竞争到对象锁的概率大,假若某线程没有竞争到该对象锁,它还会留在锁池中,唯有线程再次调用 wait()方法,它才会重新回到等待池中。而竞争到对象锁的线程则继续往下执行,直到执行完了 synchronized 代码块,它会释放掉该对象锁,这时锁池中的线程会继续竞争该对象锁。

线程的sleep()方法和yield()方法有什么区别?

答:
① sleep()方法给其他线程运行机会时不考虑线程的优先级,因此会给低优先级的线程以运行的机会;yield()方法只会给相同优先级或更高优先级的线程以运行的机会;
② 线程执行sleep()方法后转入阻塞(blocked)状态,而执行yield()方法后转入就绪(ready)状态;
③ sleep()方法声明抛出InterruptedException,而yield()方法没有声明任何异常
④ sleep()方法比yield()方法(跟操作系统CPU调度相关)具有更好的可移植性

当一个线程进入一个对象的synchronized方法A之后,其它线程是否可进入此对象的synchronized方法B?

答:不能。其它线程只能访问该对象的非同步方法,同步方法则不能进入。因为非静态方法上的synchronized修饰符要求执行方法时要获得对象的锁,如果已经进入A方法说明对象锁已经被取走,那么试图进入B方法的线程就只能在等锁池(注意不是等待池哦)中等待对象的锁。

请说出与线程同步以及线程调度相关的方法。

答:
wait():使一个线程处于等待(阻塞)状态,并且释放所持有的对象的锁;
sleep():使一个正在运行的线程处于睡眠状态,是一个静态方法,调用此方法要处理InterruptedException异常;
notify():唤醒一个处于等待状态的线程,当然在调用此方法的时候,并不能确切的唤醒某一个等待状态的线程,而是由JVM确定唤醒哪个线程,而且与优先级无关;
notityAll():唤醒所有处于等待状态的线程,该方法并不是将对象的锁给所有线程,而是让它们竞争,只有获得锁的线程才能进入就绪状态;

线程的 run()和 start()有什么区别?

每个线程都是通过某个特定Thread对象所对应的方法run()来完成其操作的,方法run()称为线程体。通过调用Thread类的start()方法来启动一个线程。
start()方法来启动一个线程,真正实现了多线程运行。这时无需等待run方法体代码执行完毕,可以直接继续执行下面的代码; 这时此线程是处于就绪状态, 并没有运行。 然后通过此Thread类调用方法run()来完成其运行状态, 这里方法run()称为线程体,它包含了要执行的这个线程的内容, Run方法运行结束, 此线程终止。然后CPU再调度其它线程。
run()方法是在本线程里的,只是线程里的一个函数,而不是多线程的。 如果直接调用run(),其实就相当于是调用了一个普通函数而已,直接待用run()方法必须等待run()方法执行完毕才能执行下面的代码,所以执行路径还是只有一条,根本就没有线程的特征,所以在多线程执行时要使用start()方法而不是run()方法。

启动一个线程是调用run()还是start()方法?

答:启动一个线程是调用start()方法,使线程所代表的虚拟处理机处于可运行状态,这意味着它可以由JVM 调度并执行,这并不意味着线程就会立即运行。run()方法是线程启动后要进行回调(callback)的方法。

static void yield()方法

yield() :线程让步。调用yield方法会让当前线程交出CPU权限,让CPU去执行其他的优先级更高的线程。它跟sleep方法类似,同样不会释放锁。但是yield不能控制具体的交出CPU的时间,另外,yield方法只能让拥有相同优先级的线程有获取CPU执行时间的机会。但是调用yield方法并不会让线程进入阻塞状态,而是让线程重回就绪状态,它只需要重新争抢cpu的时间片,争抢时是否获取到时间片看cpu的分配,这一点是和sleep方法不一样的。
暂停当前正在执行的线程,把执行机会让给优先级相同或更高的线程
若队列中没有同优先级的线程,忽略此方法

join()方法

当某个程序执行指令流中调用其他线程的 join() 方法时,调用线程将被阻塞,直到 join() 方法加入的 join 线程执行完为止。
假如在main线程中,调用thread.join方法,则main方法会等待thread线程执行完毕或者等待一定的时间,等待某线程执行完成后恢复运行。如果调用的是无参join方法,则等待thread执行完毕,如果调用的是指定了时间参数的join方法,则等待一定的时间。
使用这个方法使得低优先级的线程也可以获得执行权

线程调度

线程的优先级

Java线程有优先级,优先级高的线程会获得较多的运行机会。
static int MAX_PRIORITY 线程可以具有的最高优先级,取值为10。static int MIN_PRIORITY 线程可以具有的最低优先级,取值为1。static int NORM_PRIORITY 分配给线程的默认优先级,取值为5。
涉及的方法
getPriority() : :返回线程优先值
setPriority(int newPriority) : :改变线程的优先级
说明
线程创建时继承父线程的优先级
低优先级只是获得调度的概率低,并非一定是在高优先级线程之后才被调用

线程睡眠

Thread.sleep(long millis)方法,使线程转到阻塞状态。millis参数设定睡眠的时间,以毫秒为单位。当睡眠结束后,就转为就绪(Runnable)状态。sleep()平台移植性好。

线程等待

Object类中的wait()方法,导致当前的线程等待,直到其他线程调用此对象的 notify() 方法或 notifyAll() 唤醒方法。这个两个唤醒方法也是Object类中的方法,行为等价于调用 wait(0) 一样。

线程让步

Thread.yield() 方法,暂停当前正在执行的线程对象,把执行机会让给相同或者更高优先级的线程。
yield()应该做的是让当前运行线程回到可运行状态,以允许具有相同优先级的其他线程获得运行机会。因此,使用yield()的目的是让相同优先级的线程之间能适当的轮转执行。但是,实际中无法保证yield()达到让步目的,因为让步的线程还有可能被线程调度程序再次选中。

线程加入

join()方法,等待其他线程终止。在当前线程中调用另一个线程的join()方法,则当前线程转入阻塞状态,直到另一个进程运行结束,当前线程再由阻塞转为就绪状态。

线程唤醒

Object类中的notify()方法,唤醒在此对象监视器上等待的单个线程。如果所有线程都在此对象上等待,则会选择唤醒其中一个线程。选择是任意性的,并在对实现做出决定时发生。线程通过调用其中一个 wait 方法,在对象的监视器上等待。 直到当前的线程放弃此对象上的锁定,才能继续执行被唤醒的线程。
被唤醒的线程将以常规方式与在该对象上主动同步的其他所有线程进行竞争;例如,唤醒的线程在作为锁定此对象的下一个线程方面没有可靠的特权或劣势。类似的方法还有一个notifyAll(),唤醒在此对象监视器上等待的所有线程。

线程池

什么是线程池(thread pool)?

答:在面向对象编程中,创建和销毁对象是很费时间的,因为创建一个对象要获取内存资源或者其它更多资源。在Java中更是如此,虚拟机将试图跟踪每一个对象,以便能够在对象销毁后进行垃圾回收。所以提高服务程序效率的一个手段就是尽可能减少创建和销毁对象的次数,特别是一些很耗资源的对象创建和销毁,这就是”池化资源”技术产生的原因。
线程池顾名思义就是事先创建若干个可执行的线程放入一个池(容器)中,需要的时候从池中获取线程不用自行创建,使用完毕不需要销毁线程而是放回池中,从而减少创建和销毁线程对象的开销。

创建各种类型的线程池对象

Java 5+中的Executor接口定义一个执行线程的工具。它的子类型即线程池接口是ExecutorService。要配置一个线程池是比较复杂的,尤其是对于线程池的原理不是很清楚的情况下,因此在工具类Executors面提供了一些静态工厂方法,生成一些常用的线程池,如下所示:

  • newSingleThreadExecutor:创建一个单线程的线程池。这个线程池只有一个线程在工作,也就是相当于单线程串行执行所有任务。如果这个唯一的线程因为异常结束,那么会有一个新的线程来替代它。此线程池保证所有任务的执行顺序按照任务的提交顺序执行。
  • newFixedThreadPool:创建固定大小的线程池。每次提交一个任务就创建一个线程,直到线程达到线程池的最大大小。线程池的大小一旦达到最大值就会保持不变,如果某个线程因为执行异常而结束,那么线程池会补充一个新线程
  • newCachedThreadPool:创建一个可缓存的线程池。如果线程池的大小超过了处理任务所需要的线程,那么就会回收部分空闲(60秒不执行任务)的线程,当任务数增加时,此线程池又可以智能的添加新线程来处理任务。此线程池不会对线程池大小做限制,线程池大小完全依赖于操作系统(或者说JVM)能够创建的最大线程大小。
  • newScheduledThreadPool:创建一个大小无限的线程池。此线程池支持定时以及周期性执行任务的需求。

同步代码块

同步方法和同步代码块的区别是什么?

区别:
同步方法默认用this**或者**当前类class对象作为锁;
同步代码块可以选择以什么来加锁,比同步方法要更细颗粒度,我们可以选择只同步会发生同步问题的部分代码而不是整个方法;
同步方法使用关键字 synchronized修饰方法,而同步代码块主要是修饰需要进行同步的代码,用 synchronized(object){代码内容}进行修饰;

synchronized关键字的用法

答:synchronized关键字可以将对象或者方法标记为同步,以实现对对象和方法的互斥访问,可以用synchronized(对象) { … }定义同步代码块,或者在声明方法时将synchronized作为方法的修饰符。

在监视器(Monitor)内部,是如何做线程同步的?程序应该做哪种级别的同步?

监视器和锁在Java虚拟机中是一块使用的。监视器监视一块同步代码块,确保一次只有一个线程执行同步代码块。每一个监视器都和一个对象引用相关联。线程在获取锁之前不允许执行同步代码。
感觉没答到点子上

说一下 synchronized 底层实现原理?

synchronized可以保证方法或者代码块在运行时,同一时刻只有一个方法可以进入到临界区,同时它还可以保证共享变量的内存可见性。
Java中每一个对象都可以作为锁,这是synchronized实现同步的基础:

  • 普通同步方法,锁是当前实例对象
  • 静态同步方法,锁是当前类的class对象
  • 同步方法块,锁是括号里面的对象

synchronized 和 volatile 的区别是什么?

  • volatile本质是在告诉jvm当前变量在寄存器(工作内存)中的值是不确定的,需要从主存中读取; synchronized则是锁定当前变量,只有当前线程可以访问该变量,其他线程被阻塞住。
  • volatile仅能使用在变量级别;synchronized则可以使用在变量、方法、和类级别的。
  • volatile仅能实现变量的修改可见性,不能保证原子性;而synchronized则可以保证变量的修改可见性和原子性。
  • volatile不会造成线程的阻塞;synchronized可能会造成线程的阻塞。
  • volatile标记的变量不会被编译器优化;synchronized标记的变量可以被编译器优化。

    synchronized 和 Lock 有什么区别?

  • 首先synchronized是java内置关键字,在jvm层面,Lock是个java类;

  • synchronized无法判断是否获取锁的状态,Lock可以判断是否获取到锁;
  • synchronized会自动释放锁(a 线程执行完同步代码会释放锁 ;b 线程执行过程中发生异常会释放锁),Lock需在finally中手工释放锁(unlock()方法释放锁),否则容易造成线程死锁;
  • 用synchronized关键字的两个线程1和线程2,如果当前线程1获得锁,线程2线程等待。如果线程1阻塞,线程2则会一直等待下去,而Lock锁就不一定会等待下去,如果尝试获取不到锁,线程可以不用一直等待就结束了;
  • synchronized的锁可重入、不可中断、非公平,而Lock锁可重入、可中断、可公平(两者皆可);
  • Lock锁适合大量同步的代码的同步问题,synchronized锁适合代码少量的同步问题。

    synchronized 和 ReentrantLock 区别是什么?

    synchronized是和if、else、for、while一样的关键字,ReentrantLock是类,这是二者的本质区别。
    既然ReentrantLock是类,那么它就提供了比synchronized更多更灵活的特性,可以被继承、可以有方法、可以有各种各样的类变量,ReentrantLock比synchronized的扩展性体现在几点上:

  • ReentrantLock可以对获取锁的等待时间进行设置,这样就避免了死锁

  • ReentrantLock可以获取各种锁的信息
  • ReentrantLock可以灵活地实现多路通知

另外,二者的锁机制其实也是不一样的:ReentrantLock底层调用的是Unsafe的park方法加锁,synchronized操作的应该是对象头中mark word。

锁相关基础

什么是死锁?

将锁的粒度细分可以增强并发度 但也容易发生死锁。什么是死锁?死锁是指两个或两个以上的进程(线程)在运行过程中因争夺资源而造成的一种僵局(Deadly-Embrace) ) ,若无外力作用,这些进程(线程)都将无法向前推进。
比如说,(有实习经历才能找实习)
t1线程已经获得A对象锁,接下来想获取B对象的锁。

  1. t2线程获得B对象锁,接下来想获取A对象的锁。

两个线程都拥有对方想拥有的资源且需不愿意放弃自己拥有的资源,这就造成了一种僵局,使得两个线程都无法继续向前推进,从而产生死锁。

死锁产生的4个必要条件:

  • 互斥条件:进程要求对所分配的资源(如打印机)进行排他性控制,即在一段时间内某 资源仅为一个进程所占有。此时若有其他进程请求该资源,则请求进程只能等待。
  • 不剥夺条件:进程所获得的资源在未使用完毕之前,不能被其他进程强行夺走,即只能 由获得该资源的进程自己来释放(只能是主动释放)。
  • 请求和保持条件:进程已经保持了至少一个资源,但又提出了新的资源请求,而该资源 已被其他进程占有,此时请求进程被阻塞,但对自己已获得的资源保持不放。
  • 循环等待条件:存在一种进程资源的循环等待链,链中每一个进程已获得的资源同时被 链中下一个进程所请求。

预防死锁

  • 资源一次性分配:一次性分配所有资源,这样就不会再有请求了:(破坏请求条件)
  • 只要有一个资源得不到分配,也不给这个进程分配其他的资源:(破坏请保持条件)
  • 可剥夺资源:即当某进程获得了部分资源,但得不到其它资源,则释放已占有的资源(破坏不可剥夺条件)
  • 资源有序分配法:系统给每类资源赋予一个编号,每一个进程按编号递增的顺序请求资源,释放则相反(破坏环路等待条件)

    如何确保N个线程可以访问N个资源同时又不导致死锁?

    使用多线程的时候,一种非常简单的避免死锁的方式就是:指定获取锁的顺序,并强制线程按照指定的顺序获取锁。因此,如果所有的线程都是以同样的顺序加锁和释放锁,就不会出现死锁了。
    多线程产生死锁需要四个条件,分别是互斥性,保持和请求,不可剥夺性还有要形成闭环,这四个条件缺一不可,只要破坏了其中一个条件就可以破坏死锁,其中最简单的方法就是线程都是以同样的顺序加锁和释放锁,也就是破坏了第四个条件。
    好像课本中有(如何去预防死锁)

    简述synchronized 和java.util.concurrent.locks.Lock的异同?

    答:Lock是Java 5以后引入的新的API,和关键字synchronized相比主要相同点:Lock 能完成synchronized所实现的所有功能;
    主要不同点:Lock有比synchronized更精确的线程语义和更好的性能,而且不强制性的要求一定要获得锁。synchronized会自动释放锁,而Lock一定要求程序员手工释放,并且最好在finally 块中释放(这是释放外部资源的最好的地方)。

    ThreadLocal

    ThreadLocal 是什么?有哪些使用场景?

    线程局部变量是局限于线程内部的变量,属于线程自身所有,不在多个线程间共享。Java提供ThreadLocal类来支持线程局部变量,是一种实现线程安全的方式。但是在管理环境下(如 web 服务器)使用线程局部变量的时候要特别小心,在这种情况下,工作线程的生命周期比任何应用变量的生命周期都要长。任何线程局部变量一旦在工作完成后没有释放,Java 应用就存在内存泄露的风险。

    说一下 atomic 的原理?

    Atomic包中的类基本的特性就是在多线程环境下,当有多个线程同时对单个(包括基本类型及引用类型)变量进行操作时,具有排他性,即当多个线程同时对该变量的值进行更新时,仅有一个线程能成功,而未成功的线程可以向自旋锁一样,继续尝试,一直等到执行成功。
    Atomic系列的类中的核心方法都会调用unsafe类中的几个本地方法。我们需要先知道一个东西就是Unsafe类,全名为:sun.misc.Unsafe,这个类包含了大量的对C代码的操作,包括很多直接内存分配以及原子操作的调用,而它之所以标记为非安全的,是告诉你这个里面大量的方法调用都会存在安全隐患,需要小心使用,否则会导致严重的后果,例如在通过unsafe分配内存的时候,如果自己指定某些区域可能会导致一些类似C++一样的指针越界到其他进程的问题。

实际应用

哲学家就餐问题

1、什么是哲学家就餐问题?

五个哲学家只干两件事:吃饱了就睡觉,饿醒了就吃饭;但是要想吃到饭必须左右手都拿到一根筷子才行;而筷子只有五根;
在并发的情况下,五个哲学家同时拿起了左边的筷子或者同时拿起了右边的筷子;
此时,五只筷子立刻都被占用了,没有可用的筷子了,当所有的哲学家再想拿起右边筷子的时候,因为临界资源不足,只能将自身阻塞
而所有的哲学家全部都会阻塞,并且不会释放自己手中拿着的左边的筷子,因此就会一直处于阻塞状态,无法进行吃饭,吃不饱饭也不能睡觉了,只会一直饿着肚子。

2、怎么去用程序模拟哲学家问题?

从上面题目的描述中可以知道,筷子是临界资源,同一根筷子同一时刻只能有一个哲学家可以拿到;五个哲学家对应五个线程;五只筷子就是五个临界资源;
哲学家想要吃饭,必须要同时获得左边和右边的筷子,这就是要同时进入两个临界区(使用临界资源),才能吃饭。
很明显,从面向对象的角度去设计这个程序的话,有两个类:哲学家以及筷子;筷子的属性,给个编号就行;而哲学家的属性,包括左手的筷子以及右手的筷子,当然再加个编号更好;

3、怎么解决五个哲学家资源争用的问题?

为了解决五个哲学家争用的资源的问题,我们可以采用以下几种解决方法:

  • 方法一:至多只允许有四位哲学家同时去拿左边的筷子,最终能保证至少有一位哲学家能够进餐,并在用餐完毕后能释放他占用的筷子,从而使别的哲学家能够进餐;
  • 方法二:仅当哲学家的左、右两支筷子可用时,才允许他拿起筷子;
  • 方法三:规定奇数号哲学家先拿他左边的筷子,然后再去拿右边的筷子;而偶数号哲学家则相反。

其他

synchronized

说说你对synchronized的理解

由于现在的操作系统大多是分时系统, 在[多线程]的环境下, 共享资源可能会被多个线程共享, 这就很容易导致数据错乱或发生数据安全问题, 即:数据有可能丢失, 有可能增加, 有可能错乱(数据一致性问题)。
出现这个问题的根本原因是由于线程时间片用完导致线程切换以及对共享资源的操作不是原子操作;因为线程的数量远远多于cpu的数量,线程的切换在所难免;所以我们只能从原子操作方面去避免临界区竞态条件的发生;比较常见的手段有如下几种:

  • 阻塞式的解决方案:synchronized对象锁,Lock锁接口
  • 非阻塞式的解决方案:原子变量

synchronized便是俗称的【对象锁】,它采用互斥的方式让同一时刻至多只有一个线程能持有【对象锁】,其它线程再想获取这个【对象锁】时就会阻塞住。这样就能保证拥有锁 的线程可以安全的执行临界区内的代码,不用担心线程上下文的切换导致产生数据的不一致性问题;

互斥与同步的区别

虽然 java 中互斥和同步都可以采用 synchronized 关键字来完成,但它们还是有区别的:

  • 互斥是保证临界区的竞态条件发生,同一时刻只能有一个线程执行临界区代码
  • 同步是由于线程执行的先后、顺序不同、需要一个线程等待其它线程运行到某个点


一般情况下如何去使用synchronized 关键字

一般会将涉及到对临界区的共享资源的操作封装进一个类中,在该类中封装对该临界区的操作,并给每一个操作都加上synchronized 互斥锁,这样更方便管理。这样在使用的时候只需要创建一个对象,然后调用该对象的方法进行临界区操作即可;调用的时候方便多,也体现了面向对象的思想,可重用性高;
如果synchronized 声明在普通方法上就相当于将当前的this对象作为对象锁,如果synchronized 声明在静态方法上将类作为锁对象;如果是将当前对象this当成对象锁,那么每次new出一个实例对象的对象锁都不一样;如果是将类作为锁对象的话,不管new了多少个实例对象,锁都是同一个;

如何判断一个变量是否会产生线程安全问题?

一个对象是否会产生线程安全问题首先取决于它们是否被多个线程共享,如果它们没有共享,则线程安全 ;如果它们被共享了,根据它们的状态是否能够改变,又分两种情况

  • 如果只有读操作,则线程安全
  • 如果有读写操作,则这段代码是临界区,需要考虑线程安全

但是这里需要注意;局部变量的特点是每个线程都会一个副本,所以不存在线程安全问题;虽然局部变量是线程安全的 ,但局部变量引用的对象(比如说引用到堆中的对象)则未必 。 如果该对象没有逃离方法的作用域,它是线程安全的 ;如果该对象逃离方法的作用范围(比如说被return出去了),就需要考虑线程安全了;

说说你对Java对象头的理解

HotSpot[虚拟机] 中,Java对象在内存中存储的布局可以分为三块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。
对象头在32位虚拟机下是64位,即8个字节;其中的四个字节是 Mark Word ,另外4个字节是Klass Word,其中的Klass Word为 指向类的指针 ,指向该对象对应的Class,表明这个对象所属的类型;Mark Word记录了对象和锁有关的信息,当这个对象被synchronized关键字当成对象锁时,围绕这个锁的一系列操作都和对象头的Mark Word有关。不同的锁状态下Mark Word存储的内容不同, 其中无锁和偏向锁的锁标志位都是01,只是在前面的1bit区分了这是无锁状态还是偏向锁状态(32位JVM)。image.png
image.png
25位hashcode 表示每个对象都有的哈希码,4位 age 表示垃圾回收的分代年龄;其中1位表示偏向锁,还有两位表示加锁的状态( **无锁(001)、偏向锁(101)、轻量级锁(00)、重量级锁(10)** )。
所以实际上一个对象,它的对象头占用了不少的空间;比如说Integer对象(12字节)在内存中比普通类型int(4字节)多占8个字节。这是在32位虚拟机的情况下(8字节是对象头的,4字节是存储int的value的)。这也是在内存敏感的场景下使用int类型而不是Integer类型的原因(太大了占内存)。

什么是轻量级锁?

  • 如果一个对象虽然有多个线程要对它进行加锁,但是加锁的时间是错开的(也就是没有人可以竞争的),那么可以使用轻量级锁来进行优化。轻量级锁对使用者是透明的,即语法仍然是synchronized,假设有两个方法同步块,可以利用同一个对象加锁
  • eg: 简单来说,线程A来操作临界区的资源, 给资源加锁,到执行完临界区代码,释放锁的过程, 没有线程来竞争, 此时就可以使用轻量级锁; 如果这期间有线程来竞争的话, 就会升级为重量级锁(synchronized)

    为什么会有偏向锁?

    在轻量级的锁中,我们发现如果同一个线程对同一个对象进行锁重入时,也需要执行CAS操作,这是有点耗时的。那么java6开始引入了偏向锁的,即只有第一次使用CAS时将对象的Mark Word头设置为入锁线程ID之后这个入锁线程再进行重入锁时,**发现线程ID是自己的,那么就不用再进行CAS了** 。并且只要以后不发生竞争,这个锁对象就会偏向该线程,归该线程所有;

  • 升级为轻量级锁的情况 (会进行偏向锁撤销) : 获取偏向锁的时候, 发现线程ID不是自己的, 此时通过CAS替换操作, 操作成功了, 此时该线程就获得了锁对象。( 此时是交替访问临界区, 撤销偏向锁, 升级为轻量级锁)

  • 升级为重量级锁的情况 (会进行偏向锁撤销) : 获取偏向锁的时候, 发现线程ID不是自己的, 此时通过CAS替换操作, 操作失败了, 此时说明发生了锁竞争。( 此时是多线程访问临界区, 撤销偏向锁, 升级为重量级锁)

    什么是自旋优化?

    当发生重量级锁竞争的时候,我们可以使用自旋来进行优化 (即暂时不加入Monitor的阻塞队列EntryList中,而是进行几次循环之后再做打算)。
    如果当前线程自旋成功(即在自旋的时候持锁的线程释放了锁),那么当前线程就不用进行阻塞了,也就可以不用进行上下文切换(持锁线程执行完synchronized同步块后,释锁,Owner为空,唤醒阻塞队列来竞争,胜出的线程得到cpu执行权的过程) 就获得了锁 。
    自旋成功的情况(时间线自上而下)
    image.png
    自旋重试失败的情况,自旋了一定次数还是没有等到持锁的线程释放锁
    image.png
    注意:
    自旋会占用 CPU 时间,单核 CPU 自旋就是浪费,多核 CPU 自旋才能发挥优势。
    在 Java 6 之后自旋锁是自适应的,比如对象刚刚的一次自旋操作成功过,那么认为这次自旋成功的可能性会高,就多自旋几次;反之,就少自旋甚至不自旋,总之,比较智能。
    Java 7 之后不能控制是否开启自旋功能 。

    synchronized 锁升级流程小结(重点)

    JDK1.6以后的版本在处理同步锁时存在锁升级的概念,JVM对于同步锁的处理是从偏向锁开始的,随着竞争越来越激烈,处理方式从偏向锁升级到轻量级锁,最终升级到重量级锁。

    JVM一般是这样使用锁和Mark Word的:
    1,当对象没有被当成锁对象时,这就是一个普通的对象,对象头中的Mark Word记录对象的HashCode,锁标志位是01,是否偏向锁那一位是0。
    2,当对象被当做同步锁并有一个线程A执行到synchronized代码块抢到了锁时,对象头的锁标志位还是01,但是否偏向锁那一位改成1,前23bit记录抢到锁的线程id,表示进入偏向锁状态。
    3,当线程A再次试图来获得锁时,JVM发现同步锁对象的标志位是01,是否偏向锁是1,也就是偏向锁状态,Mark Word中记录的线程id也是线程A自己的id,表示线程A已经获得了这个偏向锁,可以执行同步锁的代码。 则无需使用CAS来加锁、解锁。
    4,当另一个线程B试图获得这个锁时,JVM发现同步锁处于偏向状态,且Mark Word中的线程id记录的不是B,那么线程B会先检查该线程是否还存在(偏向锁不会主动释放锁),然后通过CAS操作替换线程 lD来试图获得锁,这里的获得锁操作是有可能成功的,因为线程A一般不会自动释放偏向锁。如果抢锁成功,就把Mark Word里的线程id改为线程B的id,代表线程B获得了这个偏向锁,可以执行同步锁代码。如果抢锁失败,则继续执行下一步步骤5。
    5,偏向锁状态抢锁失败,代表当前锁有一定的竞争,偏向锁将升级为轻量级锁,JVM会在当前线程的栈帧创建锁记录(Lock Record)对象,让锁记录中的Object reference指向被锁住的对象,并且尝试进行CAS(compare and sweep)替换Object锁对象的Mark Word ,将Mark Word 的值存入锁记录对象中 。(交换的目的是为了表示加锁);即开辟一块单独的空间,里面保存指向对象锁Mark Word的指针,同时在对象锁Mark Word中保存指向这片空间的指针。 (注意锁对象是synchronized关键字中的对象,锁记录对象是Lock Record对象
    上述两个保存操作都是CAS操作,如果替换成功,代表线程抢到了同步锁,锁对象的对象头储存的就是锁记录lock record的地址和状态00,而锁记录对象里面就是哈希码、分代年龄、锁状态01,方便之后锁恢复的时候交换回来;如下所示 。状态00表示轻量级锁。如果保存失败,表示抢锁失败,竞争太激烈,继续执行步骤6。
    6,轻量级锁抢锁失败,JVM会使用自旋锁,自旋锁不是一个锁状态,只是代表不断的重试,尝试抢锁。从JDK1.7开始,自旋锁默认启用,自旋次数由JVM决定。如果抢锁成功则执行同步锁代码,如果失败则继续执行步骤7进入锁膨胀阶段。
    7,自旋锁重试之后如果抢锁依然失败,即如果自旋次数到了该线程还没有释放锁,或者该线程还在执行,线程还在自旋等待,这时又有另一个一个线程过来竞争这个锁对象,,同步锁会升级至重量级锁,锁标志位改为10。在这个状态下,未抢到锁的线程都会被阻塞,即重量级锁把除了拥有锁的线程都阻塞,防止CPU空转。

BLOCKEDWAITING状态的区别

线程0获得到了, 成为MonitorOwner, 但是它发现自己想要执行synchroized代码块的条件没有完全满足; 此时为了避免资源浪费它就可以调用obj.wait方法, 进入到Monitor中的WaitSet集合, 此时线程0的状态就变为WAITING

image.png

  • 处于BLOCKEDWAITING状态的线程都为阻塞状态,CPU都不会分给他们时间片,但是也有差别。
    • BLOCKED状态的线程是在竞争锁对象还没获得锁对象时,发现Monitor的Owner已经是别的线程了,此时就会进入EntryList中,并处于BLOCKED状态
    • WAITING状态的线程是获得了对象的锁,但是自身的原因无法执行synchroized的临界区资源需要进入阻塞状态时,锁对象调用了wait方法而进入了WaitSet中,处于WAITING状态 。
  • 处于BLOCKED状态的线程会在锁被释放的时候被唤醒
  • 处于WAITING状态的线程**只有被锁对象调用了notify方法(obj.notify/obj.notifyAll),才会被唤醒。** **然后它会进入到EntryList**, 重新竞争锁(此时就将锁升级为重量级锁)

线程等待唤醒的实现方法

  • Object对象中的wait()方法可以让线程等待,使用Object中的notify()方法唤醒线程;

必须都在同步代码块内使用;
调用wait,notify的对象是加锁对象;
notify必须在wait后执行才能唤醒;
wait后能释放锁对象,线程处于wait状态;

  • 使用JUC包中Condition的await()方法让线程等待,使用signal()方法唤醒线程;

必须在lock同步代码块内使用;
signal必须在await后执行才能唤醒;

  • LockSupport类可以阻塞当前线程以及唤醒指定被阻塞的线程;

不需要锁块;
unpark()可以在park()前唤醒;

如何正确的使用wait/notify方法

某个线程在获取到对象锁之后,由于继续往下执行的条件没有满足,比如说等待其他线程的资源,就会调用sleep方法进入睡眠等待系统资源;而sleep方法并不会释放锁,这样的话这个线程拿着锁对象又干不了活,其他线程又没法拿到锁对象,严重影响了其他线程的效率。
而如果使用的是wait方法去睡眠,跟sleep不同的是wait方法会将当前线程占有的锁对象释放掉,这样其他线程也能持有该对象锁。这样就解决了其他线程阻塞的问题,但是注意,调用wait方法进入的睡眠只能通过调用notify方法唤醒,而notify方法只能随机唤醒一个WaitSet中的线程,这时如果有其它线程也在等待,那么就可能唤醒不了正确的线程,这种情况就被称之为【虚假唤醒】;
为了解决这虚假唤醒的问题,我们可以将notify方法换成notifyAll方法将全部正在等待唤醒的线程叫醒。同时在线程调用wait方法时采用while循环 + wait方法这种方式,当条件不成立,再次 wait等待。

你知道LockSupport这个类吗

这个类是一个线程阻塞工具类,所有的方法都是静态方法,可以让线程在任意位置阻塞,阻塞后也有对应的唤醒方法。
他的特点是无需在加锁环境中使用;park()和unpark()的作用分别是阻塞线程和解除线程;可先唤醒后等待;
他的基本原理是LockSupport使用了一种许可证机制(Permit)来实现阻塞和唤醒,每个线程都有一个许可证(Permit);其中,许可证只有两个值,1和0;默认是0;我们可以将许可证视为信号量,只是许可证不能超过1;
park():permit默认为0,一旦调用park()方法,当前线程就会阻塞,直到别的线程将当前线程的permit设置为1时,park()方法会被唤醒;然后会将permit重新设置为0后返回。
unpark(thread): 会将线程的许可证设置为1(多次调用unpark,许可证不会累加)并自动唤醒park()的等待线程,被阻塞的线程将立即返回;

LockSupport为什么可以先唤醒线程后阻塞线程但不会阻塞?

答:因为unpark()方法获得了一个许可证,许可证值为1,再调用park()方法,就可消费这个许可证,所以不会阻塞;

为什么唤醒两次后阻塞两次,最终还是会阻塞?

答:如果线程A调用两遍park(),线程B调用两边unpark(),那么只会解锁一个park(),因为许可证最多只能为1,不能累加;

park 与 unpack的原理 (重要)

park/unpark都是LockSupport工具类中的的方法,与Object类中的wait & notify相比, wait,notify和notifyAll必须配合Object Monitor一起使用,而park,unpark不必。
park & unpark是以线程为单位来进行【阻塞】和【唤醒】线程,哪个线程需要阻塞就直接在该线程体内调用park方法即可、需要唤醒就直接调用这个unpark方法并传入需要唤醒的线程即可,而notify 只能随机唤醒一个等待线程,notifyAll是唤醒所有等待线程,就不是那么【精确】。
这两个方法的原理很简单,每个线程都有自己的一个 Parker 对象(比较底层的c代码实现的),由三部分组成 _counter, _cond和 _mutex。
正常情况下先调用park再调用upark时,首先调用park后会去检查 _counter为0, 然后 获得_mutex 互斥锁(mutex对象有个等待队列 _cond),线程进入 _cond 条件变量阻塞并设置_counter = 0 ;
需要唤醒时调用unpark方法,设置_counter 为 1,然后精确的唤醒 _cond 条件变量中的 阻塞线程,线程恢复运行后设置_counter 为 0;
还有一种情况是先调用upark再调用park的过程;调用线程的unpark方法,设置该线程的 _counter 为 1 ;然后线程调用park() 方法时 发现_counter为 1,这时线程 就无需阻塞,继续运行然后_counter 为 0
简单总结就是,调用park方法时首先会检查_counter是否为0,如果是则阻塞当前线程,如果不是0则不阻塞线程;调用unpark方法时则会设置_counter为 1 然后唤醒线程;

ReentrantLock


谈谈你对ReentrantLock的理解

ReentrantLock 是Java5中引入的java.util.concurrent.locks包下的一个类,跟synchronized 不一样的是,synchronized 是关键字级别来保护临界区,而ReentrantLock是在对象级别来保护临界区;
lock必须被显式地创建、锁定和释放,为了可以使用更多的功能,一般用ReentrantLock为其实例化。
而且为了保证锁最终一定会被释放(可能会有异常发生),要把互斥区(临界区)放在try语句块内,并在finally语句块中释放锁,尤其当有return语句时,return语句必须放在try字句中,以确保unlock()不会过早发生(执行),从而将数据暴露给第二个任务。
所以要先去创建一个ReentrantLock对象,然后调用该对象的lock方法去获取锁。然后就是try跟finally块,其中try中的代码就是临界区中的代码。finally就是确保将来是否出现异常都将锁释放掉。

ReentrantLock跟synchronized的比较

在JDK1.5中,synchronized是性能低效的。因为这是一个重量级操作,它对性能最大的影响是阻塞的是实现,挂起线程和恢复线程的操作都需要转入内核态中完成,这些操作给系统的并发性带来了很大的压力。
但是到了JDK1.6之后,就发生了变化,对synchronize加入了很多优化措施,有自适应自旋,锁消除,锁粗化,轻量级锁,偏向锁等等。导致在JDK1.6之后的synchronize性能并不比Lock差。

基本语法上,ReentrantLock与synchronized很相似,它们都具备一样的线程重入特性,只是代码写法上有点区别而已,一个表现为API层面的互斥锁(Lock),一个表现为原生语法层面的互斥锁(synchronized)。ReentrantLock相对synchronized而言还是增加了一些高级功能,主要有以下三项:
1、等待可中断:当持有锁的线程长期不释放锁时,正在等待锁的线程可以选择放弃等待或者设置锁超时防止无限制等待, 减少死锁的发生,改为处理其他事情,它对处理执行时间非常上的同步块很有帮助。而在等待由synchronized产生的互斥锁时,会一直阻塞,是不能被中断的。interrupt方法是打断获取锁之后的阻塞线程;
2、可实现公平锁:多个线程在等待同一个锁时,必须按照申请锁的时间顺序排队等待,而非公平锁则不保证这点,在锁释放时,任何一个等待锁的线程都有机会获得锁。synchronized中的锁时非公平锁,ReentrantLock默认情况下也是非公平锁,但可以通过构造方法ReentrantLock(ture)来要求使用公平锁。
3、锁可以绑定多个条件:ReentrantLock对象可以同时绑定多个Condition对象(名曰:条件变量或条件队列),而在synchronized中,锁对象的wait()和notify()或notifyAll()方法可以实现一个隐含条件,但如果要和多于一个的条件关联的时候,就不得不额外地添加一个锁,而ReentrantLock则无需这么做,只需要多次调用newCondition()方法即可。而且我们还可以通过绑定Condition对象来判断当前线程通知的是哪些线程(即与Condition对象绑定在一起的其他线程)。

ReentrantLock的公平锁与非公平锁的特点是什么?

ReentrantLock的默认实现是非公平锁, 但可以通过设置参数为true指定为公平锁实现;
公平锁, 可以把竞争的线程放在一个先进先出的阻塞队列上。只要持有锁的线程执行完了, 唤醒阻塞队列中的下一个线程获取锁即可; 此时先进入阻塞队列的线程先获取到锁;
非公平锁, 当阻塞队列中已经有等待的线程A了, 此时后到的线程B, 先去尝试看能否获得到锁对象. 如果获取成功, 此时就不需要进入阻塞队列了,这样一来后来的线程B就先获得锁了;
所以公平和非公平的区别是 : 线程执行同步代码块时, 是否会去尝试获取锁, 如果会尝试获取锁, 那就是非公平的, 如果不会尝试获取锁, 直接进入阻塞队列, 再等待被唤醒, 那就是公平的;

ReentrantLock的非公平锁的加解锁流程?

ReentrantLock的公平锁的原理

ReentrantLock的可打断锁原理?

ReentrantLock的条件变量

可以通过ReentrantLock实例对象的newCondition()方法创建条件变量对象; 然后通过条件变量对象调用**await/signal**方法实现线程的 等待/唤醒效果;
虽然Synchronized 中也有条件变量,当条件不满足时进入Monitor监视器中的 waitSet等待集合;但是ReentrantLock 的条件变量比 synchronized 强大之处在于,它是 支持多个条件变量。这就好比synchronized 是那些不满足条件的线程都在一间休息室等通知; (此时会造成虚假唤醒), 而 ReentrantLock 支持多间休息室,有专门等烟的休息室、专门等早餐的休息室、唤醒时也是按休息室来唤醒; (可以避免虚假唤醒)
之前我们用synchronized实现互斥,并配合使用Object对象的wait()和notify()或notifyAll()方法来实现线程间协作。
Java 5之后,我们可以用Reentrantlock锁配合Condition对象上的await()和signal()或signalAll()方法来实现线程间协作。在ReentrantLock对象上newCondition()可以得到一个Condition对象,可以通过在Condition上调用await()方法来挂起一个任务(线程),通过在Condition上调用signal()来通知任务,从而唤醒一个任务,或者调用signalAll()来唤醒所有在这个Condition上被其自身挂起的任务。另外,如果使用了公平锁,signalAll()的与Condition关联的所有任务将以FIFO队列的形式获取锁,如果没有使用公平锁,则获取锁的任务是随机的,这样我们便可以更好地控制处在await状态的任务获取锁的顺序。与notifyAll()相比,signalAll()是更安全的方式。另外,它可以指定唤醒与自身Condition对象绑定在一起的任务。

条件变量实现原理

Java内存区域与JMM内存模型

简要谈谈你对Java内存区域的理解

Java虚拟机在运行程序时会把其自动管理的内存划分为几个区域,每个区域都有的用途以及创建销毁的时机,其中蓝色部分代表的是所有线程共享的数据区域,而绿色部分代表的是每个线程的私有数据区域。
image.png
方法区(Method Area):
方法区属于线程共享的内存区域,又称Non-Heap(非堆),主要用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据,根据Java 虚拟机规范的规定,当方法区无法满足内存分配需求时,将抛出OutOfMemoryError 异常。值得注意的是在方法区中存在一个叫运行时常量池(Runtime Constant Pool)的区域,它主要用于存放编译器生成的各种字面量和符号引用,这些内容将在类加载后存放到运行时常量池中,以便后续使用。
JVM堆(Java Heap):
Java 堆也是属于线程共享的内存区域,它在虚拟机启动时创建,是Java 虚拟机所管理的内存中最大的一块,主要用于存放对象实例,几乎所有的对象实例都在这里分配内存,注意Java 堆是垃圾收集器管理的主要区域,因此很多时候也被称做GC 堆,如果在堆中没有内存完成实例分配,并且堆也无法再扩展时,将会抛出OutOfMemoryError 异常。
程序计数器(Program Counter Register):
属于线程私有的数据区域,是一小块内存空间,主要代表当前线程所执行的字节码行号指示器。字节码解释器工作时,通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。
虚拟机栈(Java Virtual Machine Stacks):
属于线程私有的数据区域,与线程同时创建,总数与线程关联,代表Java方法执行的内存模型。每个方法执行时都会创建一个栈桢来存储方法的的变量表、操作数栈、动态链接方法、返回值、返回地址等信息。每个方法从调用直结束就对于一个栈桢在虚拟机栈中的入栈和出栈过程。
本地方法栈(Native Method Stacks):
本地方法栈属于线程私有的数据区域,这部分主要与虚拟机用到的 Native 方法相关,一般情况下,我们无需关心此区域。
这里之所以简要说明这部分内容,注意是为了区别Java内存模型与Java内存区域的划分,毕竟这两种划分是属于不同层次的概念。

简要谈谈你对Java内存模型的理解

Java内存模型 JMM 即 Java Memory Model, 本身是一种抽象的概念,并不真实存在,它描述的是一组规则或规范,通过这组规范定义了程序中各个变量(包括实例字段,静态字段和构成数组对象的元素)的访问方式。
由于JVM运行程序的实体是线程,而每个线程创建时JVM都会为其创建一个工作内存(有些地方称为栈空间),用于存储线程私有的数据,而Java内存模型中规定所有变量都存储在主内存,主内存是共享内存区域,所有线程都可以访问,但线程对变量的操作(读取赋值等)必须在每个线程自己的工作内存中进行;
操作的时候首先要将数据、变量从主内存拷贝到线程自己的工作内存空间,然后才能对变量进行操作,操作完成后再将工作内存中的变量写回主内存;而不能直接操作主内存中的变量,工作内存中存储着主内存中的变量副本拷贝,前面说过,工作内存是每个线程的私有数据区域,因此不同的线程间无法访问对方的工作内存,线程间的通信(传值)必须通过主内存来完成,其简要访问过程如下图 ;
image.png
这里再提一嘴,JMM与Java内存区域的划分是不同的概念层次,更恰当说JMM描述的是一组规则,通过这组规则控制程序中各个变量在共享数据区域和私有数据区域的访问方式;
JMM是围绕并发的三大特性:原子性,有序性、可见性以及JMM八大原子操作展开的(稍后会分析)。JMM与Java内存区域唯一相似点,就是都存在共享数据区域和私有数据区域,在JMM中主内存属于共享数据区域,从某个程度上讲应该包括了堆和方法区,而工作内存数据线程私有数据区域,从某个程度上讲则应该包括程序计数器、虚拟机栈以及本地方法栈。
或许在某些地方,我们可能会看见主内存被描述为堆内存,工作内存被称为线程栈,实际上他们表达的都是同一个含义。

关于主内存以及工作内存的更深入理解

主内存
主要存储的是Java实例对象,所有线程创建的实例对象都存放在主内存中,不管该实例对象是成员变量还是方法中的本地变量(也称局部变量),当然也包括了共享的类信息、常量、静态变量。由于是共享数据区域,多条线程对同一个变量进行访问可能会发生线程安全问题。
工作内存
主要存储当前方法的所有本地变量信息(工作内存中存储着主内存中的变量副本拷贝),每个线程只能访问自己的工作内存,即线程中的本地变量对其它线程是不可见的,就算是两个线程执行的是同一段代码,它们也会各自在自己的工作内存中创建属于当前线程的本地变量,当然也包括了字节码行号指示器、相关Native方法的信息。注意由于工作内存是每个线程的私有数据,线程间无法相互访问工作内存,因此存储在工作内存的数据不存在线程安全问题。
弄清楚主内存和工作内存后,再来了解一下主内存与工作内存的数据存储类型以及操作方式,根据虚拟机规范,对于一个实例对象中的成员方法而言,如果方法中包含本地变量是基本数据类型。(boolean,byte,short,char,int,long,float,double),将直接存储在工作内存的帧栈结构中,但倘若本地变量是引用类型,那么该变量的引用会存储在功能内存的帧栈中,而对象实例将存储在主内存(共享数据区域,堆)中。
但对于实例对象的成员变量,不管它是基本数据类型或者包装类型(Integer、Double等)还是引用类型,都会被存储到堆区。至于static变量以及类本身相关信息将会存储在主内存中。需要注意的是,在主内存中的实例对象可以被多线程共享,倘若两个线程同时调用了同一个对象的同一个方法,那么两条线程会将要操作的数据拷贝一份到自己的工作内存中,执行完成操作后才刷新到主内存。

JMM的三大特性指的是什么?

JMM 体现在以下几个方面

  • 原子性 - 保证指令不会受 线程上下文切换的影响
  • 可见性 - 保证指令不会受 cpu 缓存的影响 (JIT对热点代码的缓存优化)
  • 有序性 - 保证指令不会受 cpu 指令并行优化的影响

什么是原子性?

原子性指的是一个操作是不可中断的,即使是在多线程环境下,一个操作一旦开始就不会被其他线程影响。比如对于一个静态变量int x,两条线程同时对他赋值,线程A赋值为1,而线程B赋值为2,不管线程如何运行,最终x的值要么是1,要么是2,线程A和线程B间的操作是没有干扰的,这就是原子性操作,不可被中断的特点。

什么是有序性?谈谈你对volatile的理解?

JVM会在不影响正确性的前提下,会调整语句的执行顺序, 是一种优化 。假如说CPU要执行两条指令:指令1需要去内存中读取数据,需要等待内存返回数据,因为CPU的运算速度比内存要快很多(CPU在1ms内发起读指令,剩下99ms都在等内存返回数据); 另外一条指令跟指令1没有任何依赖关系,这个时候就会进行优化。
这种特性就被称之为『指令重排』, 单线程下指令重排肯定没什么,但是在多线程下『指令重排』就可能会影响正确性。
解决的办法是添加上一个volatile关键字,禁止重排序;

什么是可见性问题?

可见性指的是当一个线程修改了某个共享变量的值,其他线程是否能够马上得知这个修改的值。对于串行程序来说,可见性是不存在的,因为我们在任何一个操作中修改了某个变量的值,后续的操作中都能读取这个变量值,并且是修改过的新值
但在多线程环境中可就不一定了,前面我们分析过,由于线程对共享变量的操作都是线程拷贝到各自的工作内存进行操作后才写回到主内存中的,这就可能存在一个线程A修改了共享变量x的值,还未写回主内存时,另外一个线程B又对主内存中同一个共享变量x进行操作,但此时A线程工作内存中共享变量x对线程B来说并不可见,这种工作内存与主内存同步延迟现象就造成了可见性问题,另外指令重排以及编译器优化也可能导致可见性问题,通过前面的分析,我们知道无论是编译器优化还是处理器优化的重排现象,在多线程环境下,确实会导致程序轮序执行的问题,从而也就导致可见性问题。
解决这个问题的办法是给变量添加一个volatile(窝力跳)关键字修饰。加了这个关键字修饰就是告诉线程,每次都要去主内存中去获取该值,不要去自己的工作内存中获取。虽然效率上有所损失但是保证了被**volatile**修饰的变量的对其他线程的可见性。

谈谈你对volatile的理解小结?

volatile 是java虚拟机轻量级的同步机制,它基本上遵守了JMM的规范,主要是保证了可见性,禁止指令重排序,但是他不保证原子性。

volatile底层的实现原理是什么?

volatile 的底层实现原理是内存屏障,Memory Barrier(Memory Fence)

  • 在 volatile 变量的写指令会加入写屏障。(保证写屏障之前的写操作, 都能同步到主存中)
  • 在volatile 变量的读指令会加入读屏障。(保证读屏障之后的读操作, 都能读到主存的数据)

    volatile是如何保证可见性 (重点)或者说读写屏障是如何保证可见性的?

    写屏障(sfence)是保证在该屏障之前的,对共享变量的改动(写操作),都同步到主存当中;
    读屏障(lfence)保证在该屏障之后,对共享变量的读取,加载的是主存中最新数据,有了对写屏障的了解,那么读屏障就好理解了,读屏障是加在对volatile 修饰变量的读取之前的,它的效果则是保证读屏障之后的读操作, 都能读到主存中的最新数据。

volatile是如何保证有序性(双重检查单例也有应用)

  • 写屏障会确保指令重排序时,不会将写屏障之前的代码 排 在写屏障之后
  • 读屏障会确保指令重排序时,不会将读屏障之后的代码排在读屏障之前


volatile不能解决指令交错 (不能解决原子性)

  • 写屏障仅仅是保证之后的读能够读到最新的结果,但不能保证其它线程的读, 跑到它将指令写入主存的前面去
  • 有序性的保证也只是保证了==本线程==内相关代码不被重排序,多个线程之间谁先谁后这可不一定;

无锁并发与CAS

你知道CAS吗?谈谈你对CAS的理解、

CAS全称是Compare-and-Swap,即比较并替换,在实现并发算法时常用到,是一条CPU并发原语,它的功能是判断内存某个位置的值是否为预期值,如果是则更改为新值,这个过程是原子的 。
比如Java并发包中的原子类都使用了CAS技术。CAS需要有3个操作数:内存地址V,旧的预期值A,即将要更新的目标值B。CAS指令执行时,当且仅当内存地址V的值与预期值A相等时,将内存地址V的值修改为B,否则就什么都不做。整个比较并替换的操作是一个原子操作。
CAS并发原语体现在JAVA语言中就是sun.misc.Unsafe类中的各个方法。调用UnSafe类中的CAS方法,JVM会帮我们实现出CAS汇编指令。这是一种完全依赖于硬件的功能,通过它实现了原子操作。再次强调,由于CAS是一种系统原语,原语属于操作系统用语范畴,是由若干条指令组成的,用于完成某个功能的一个过程,并且原语的执行必须是连续的,在执行过程中不允许被中断,也就是说CAS是一条CPU的原子指令,不会造成所谓的数据不一致问题。

为什么AtomicInteger不加synchronized能实现原子性?

假设线程A和线程B两个线程同时执行getAndAddlnt操作(分别跑在不同CPU上)
1 AtomicInteger里 面的value原始值为3,即主内存中AtomicInteger的value为3,根据JvM模型,线程A和线程B各自持有一 份值为3的value的副本分别到各自的工作内存。
2 线程A通过getlIntVolatile(var1, var2)拿到value值3, 这时线程A被挂起。
3 线程B也通过getlntVolatile(var1, var2)方法获取到value值3,此时刚好线程B没有被挂起并执行compareAndSwaplnt方法,比较内存值也为3,成功修改内存值为4,线程B打完收工,一切OK。 4 这时线程A恢复,执行compareAndSwapInt方法比较,发现自己手里的值数字3和主内存的值数字4不一致,说明该值已经被其它线程抢先一步修改过了,那A线程本次修改失败,只能重新读取重新来一遍了。
5 线程A重新获取value值, 因为变量value被volatile修饰, 所以其它线程对它的修改,线程A总是能够看到,线程A继续执行compareAndSwapInt进行比较替换,直到成功。

volatile 在 CAS中的作用

  • AtomicInteger类,保存值的value属性使用了volatile 修饰。获取共享变量时,为了保证该共享变量的可见性,需要使用 volatile 修饰。
  • volatile可以用来修饰 成员变量和静态成员变量,他可以避免线程从自己的工作缓存中查找变量的值,必须到主存中获取它的值,线程操作 volatile 变量都是直接操作主存。这就是可见性的体现,即一个线程对 volatile 变量的修改,对另一个线程可见。
  • 注意: volatile 仅仅保证了共享变量的可见性,让其它线程能够看到最新值,但不能解决指令交错问题(不能保证原子性)
  • **CAS 必须借助 volatile 才能读取到共享变量的最新值来实现【比较并交换】的效果**

    为什么AtomicInteger不加synchronized能实现原子性?答案:

    1.Unsafe是CAS的核心类,由于java方法无法直接访问底层系统,需要 通过本地(native)方法来访问,Unsafe相当于一个后门,基于该类可以直接操作特定内存的数据,Unsafe类存在于sun.misc包中,其内部方法操作可以像C的指针一样直接操作内存,因为java中CAS操作的执行依赖于Unsafe类的方法。注意,Unsafe类中的所有方法都是native修饰的,也就是说Unsafe类中的方法都直接调用操作系统底层资源执行相应任务。
    2.变量valueOffset表示该变量值在内存中偏移地址,因为Unsafe就是根据内存偏移地址获取数据的
    3.变量value用volatile修饰,保证了多线程之间的可见性

为什么CAS+重试(无锁)效率高

  • 使用CAS+重试—-也就是无锁模式的情况下,即使重试失败,线程始终在高速运行,没有停歇,而 synchronized会让线程在没有获得锁的时候,发生上下文切换,进入阻塞。
    • 打个比喻:线程就好像高速跑道上的赛车,高速运行时,速度超快,一旦发生上下文切换,就好比赛车要减速、熄火,等被唤醒又得重新打火、启动、加速… 恢复到高速运行状态,代价比较大。
  • 但无锁情况下,因为线程要保持运行,需要额外 CPU 的支持,CPU 在这里就好比高速跑道,没有额外的跑道,线程想高速运行也无从谈起,虽然不会进入阻塞,但由于没有分到时间片**,仍然会进入可运行状态,还是会导致上下文切换。**

CAS有什么缺点?

CAS虽然很高效的解决了原子操作问题,但是CAS仍然存在三大问题。

  1. 循环时间长开销很大。

CAS 通常是配合无限循环一起使用的,我们可以看到 getAndAddInt 方法执行时,如果 CAS 失败,会一直进行尝试。如果 CAS 长时间一直不成功,可能会给 CPU 带来很大的开销。

  1. 只能保证一个变量的原子操作。

当对一个变量执行操作时,我们可以使用循环 CAS 的方式来保证原子操作,但是对多个变量操作时,CAS 目前无法直接保证操作的原子性。但是我们可以通过以下两种办法来解决:
1)使用互斥锁来保证原子性;
2)将多个变量封装成对象,通过 AtomicReference 来保证原子性。

  1. ABA问题。

CAS 的使用流程通常如下:1)首先从地址 V 读取值 A;2)根据 A 计算目标值 B;3)通过 CAS 以原子的方式将地址 V 中的值从 A 修改为 B。
但是在第1步中读取的值是A,并且在第3步修改成功了,我们就能说它的值在第1步和第3步之间没有被其他线程改变过了吗?
如果在这段期间它的值曾经被改成了B,后来又被改回为A,那CAS操作就会误认为它从来没有被改变过。这个漏洞称为CAS操作的“ABA”问题。Java并发包为了解决这个问题,提供了一个带有标记的原子引用类“AtomicStampedReference”,它可以通过控制变量值的版本来保证CAS的正确性。因此,在使用CAS前要考虑清楚“ABA”问题是否会影响程序并发的正确性,如果需要解决ABA问题,改用传统的互斥同步可能会比原子类更高效。

ABA 问题及解决 (重点)

  • 如下程序所示,虽然 在other方法中存在两个线程对共享变量进行了修改,**但是修改之后又变成了原值main线程对`修改过共享变量的过程`是不可见的,这种操作这对业务代码并无影响。** ```java public class Test1 { //该变量的初始值是A,我们想在后面将其修改为C static AtomicReference ref = new AtomicReference<>(“A”);

    public static void main(String[] args) {

    1. new Thread(() -> {
    2. String pre = ref.get();
    3. System.out.println("change");
    4. try {
    5. other();
    6. } catch (InterruptedException e) {
    7. e.printStackTrace();
    8. }
    9. Sleeper.sleep(1);
    10. //把ref中的A改为C
    11. System.out.println("change A->C " + ref.compareAndSet(pre, "C"));
    12. }).start();

    }

    static void other() throws InterruptedException {

    1. new Thread(() -> {
    2. // 此时ref.get()为A,此时共享变量ref也是A,没有被改过, 此时CAS
    3. // 可以修改成功, B
    4. System.out.println("change A->B " + ref.compareAndSet(ref.get(), "B"));
    5. }).start();
    6. Thread.sleep(500);
    7. new Thread(() -> {
    8. // 同上, 修改为A
    9. System.out.println("change B->A " + ref.compareAndSet(ref.get(), "A"));
    10. }).start();

    } }

  1. ![image.png](https://cdn.nlark.com/yuque/0/2022/png/28814483/1652954967095-4a7020f3-1267-4865-a06c-c9f4dad111e8.png#clientId=u5326913a-fb1e-4&crop=0&crop=0&crop=1&crop=1&from=paste&height=130&id=ue1b3ccd6&margin=%5Bobject%20Object%5D&name=image.png&originHeight=162&originWidth=716&originalType=binary&ratio=1&rotation=0&showTitle=false&size=22202&status=done&style=none&taskId=uaa44b1de-8638-4599-9138-2307620a997&title=&width=572.8)
  2. - 主线程仅能判断出共享变量的值与最初值 A是否相同,不能感知到这种从 A 改为 B 又改回 A 的情况,也就是说它不能判断出其他线程将A修改成另外的值然后又修改回A值的情况,在A变为B但没变回A之前,其他线程能做很多事情了!!!
  3. - 如果主线程希望:**只要有其它线程【修改过】共享变量,那么自己的 cas 就算失败,这时,仅比较值是不够的,需要再加一个版本号。可以使用AtomicStampedReference来解决。**
  4. <a name="TrOYx"></a>
  5. #### AtomicStampedReference (加版本号解决ABA问题)
  6. - 谁修改过原来的值,就得将版本号进行更新,这样即可解决ABA问题
  7. ```java
  8. class Test1 {
  9. //指定版本号初始为0
  10. static AtomicStampedReference<String> ref = new AtomicStampedReference<>("A", 0);
  11. public static void main(String[] args) {
  12. new Thread(() -> {
  13. //获取的方法也有所变化
  14. String pre = ref.getReference();
  15. //获得版本号
  16. int stamp = ref.getStamp(); // 此时的版本号还是第一次获取的
  17. System.out.println("change");
  18. try {
  19. other();
  20. } catch (InterruptedException e) {
  21. e.printStackTrace();
  22. }
  23. try {
  24. Thread.sleep(1000);
  25. } catch (InterruptedException e) {
  26. e.printStackTrace();
  27. }
  28. //把ref中的A改为C,并比对版本号,如果版本号相同,就执行替换,并让版本号+1
  29. System.out.println("change A->C stamp " + stamp + ref.compareAndSet(pre, "C", stamp, stamp + 1));
  30. }).start();
  31. }
  32. static void other() throws InterruptedException {
  33. new Thread(() -> {
  34. int stamp = ref.getStamp();
  35. System.out.println("change A->B stamp " + stamp + ref.compareAndSet(ref.getReference(), "B", stamp, stamp + 1));
  36. }).start();
  37. Thread.sleep(500);
  38. new Thread(() -> {
  39. int stamp = ref.getStamp();
  40. System.out.println("change B->A stamp " + stamp + ref.compareAndSet(ref.getReference(), "A", stamp, stamp + 1));
  41. }).start();
  42. }
  43. }

image.png

AtomicMarkableReference (标记cas的共享变量是否被修改过)

  • AtomicStampedReference 可以给原子引用加上版本号,追踪原子引用整个的变化过程,如:A -> B -> A ->C,通过AtomicStampedReference,我们可以知道,引用变量中途被更改了几次。
  • 但是有时候,我们并不关心引用变量更改了几次,只是单纯的关心是否更改过,所以就有了AtomicMarkableReference ,用一个布尔值来记录是否被改动过

image.png

  1. @Slf4j(topic = "guizy.TestABAAtomicMarkableReference")
  2. class TestABAAtomicMarkableReference {
  3. public static void main(String[] args) throws InterruptedException {
  4. GarbageBag bag = new GarbageBag("装满了垃圾");
  5. // 参数2 mark 可以看作一个标记,true表示垃圾袋满了
  6. AtomicMarkableReference<GarbageBag> ref = new AtomicMarkableReference<>(bag, true);
  7. log.debug("主线程 start...");
  8. GarbageBag prev = ref.getReference();
  9. log.debug(prev.toString());
  10. new Thread(() -> {
  11. log.debug("打扫卫生的线程 start...");
  12. bag.setDesc("空垃圾袋");
  13. // 两个都是bag没有真正进行修改,只是将状态从true改成false
  14. while (!ref.compareAndSet(bag, bag, true, false)) {
  15. }
  16. log.debug(bag.toString());
  17. }).start();
  18. Thread.sleep(1000);
  19. log.debug("主线程想换一只新垃圾袋?");
  20. boolean success = ref.compareAndSet(prev, new GarbageBag("空垃圾袋"), true, false);
  21. log.debug("换了么?" + success);
  22. log.debug(ref.getReference().toString());
  23. }
  24. }
  25. class GarbageBag {
  26. //对垃圾的描述
  27. String desc;
  28. public GarbageBag(String desc) {
  29. this.desc = desc;
  30. }
  31. public void setDesc(String desc) {
  32. this.desc = desc;
  33. }
  34. @Override
  35. public String toString() {
  36. return super.toString() + " " + desc;
  37. }
  38. }

image.png

AtomicStampedReference和AtomicMarkableReference两者的区别

  • AtomicStampedReference 需要我们传入 整型变量 作为版本号,来判定是否被更改过
  • AtomicMarkableReference需要我们传入布尔变量 作为标记,来判断是否被更改过

不可变类

8、共享模型之不可变

线程池

9、共享模型之并发工具线程池

AQS原理

你知道AQS吗?谈谈你对AQS的理解?

首先AQS全名AbstractQueuedSynchronizer,是[并发]容器J.U.C(java.util.concurrent)下locks包内的一个 抽象的[队列]式同步器(一个抽象父类) ,是除了java自带的synchronized 关键字之外的锁机制。
AQS的核心思想是如果被请求的共享资源(锁)空闲,则将当前请求资源的线程设置为有效的工作线程,并将共享资源设置为锁定状态;
如果被请求的共享资源被占用,那么就需要一套线程阻塞等待以及被唤醒时锁分配的机制,这个机制AQS是用CLH队列锁实现的,即将暂时获取不到锁的线程加入到队列中。
通俗来说,
AQS的核心思想是基于CLH双向虚拟队列,维护一个用volatile修饰的共享变量state,尝试加锁的线程通过CAS操作去改变state状态符,修改成功则获取锁成功,修改失败则将请求共享资源(锁)的线程封装成一个CLH双向锁队列的一个结点(Node)添加进等待队列,等待被唤醒。

如何利用AQS思想来自定义一个不可重入锁?

AQS是一个 抽象的[队列]式同步器(一个抽象父类),他的设计是基于模板方法模式,可以参考ReentrantLock 来实现一个自定义的锁;

  1. 使用者继承AbstractQueuedSynchronizer并重写指定的方法。(这些重写方法很简单,无非是对于共享资源state的获取和释放)
  2. 将AQS组合在自定义同步组件的实现中,并调用其模板方法,而这些模板方法会调用使用者重写的方法。

自定义同步器在实现的时候只需要实现共享资源state的获取和释放方式即可,至于具体线程等待队列的维护,AQS已经在顶层实现好了。
自定义同步器实现的时候主要实现下面几种方法:
isHeldExclusively():该线程是否正在独占资源。只有用到condition才需要去实现它。
tryAcquire(int):独占方式。尝试获取资源,成功则返回true,失败则返回false。
tryRelease(int):独占方式。尝试释放资源,成功则返回true,失败则返回false。
tryAcquireShared(int):共享方式。尝试获取资源。负数表示失败;0表示成功,但没有剩余可用资源;正数表示成功,且有剩余资源。
tryReleaseShared(int):共享方式。尝试释放资源,如果释放后允许唤醒后续等待结点返回true,否则返回false。
ReentrantLock为例,(可重入独占式锁):state初始化为0,表示未锁定状态,A线程lock()时,会调用tryAcquire()独占锁并将state+1.之后其他线程再想tryAcquire的时候就会失败,直到A线程unlock()到state=0为止,其他线程才有机会获取该锁。A释放锁之前,自己也是可以重复获取此锁(state累加),这就是可重入的概念。
注意:获取多少次锁就要释放多少次锁,保证state是能回到零态的。
以CountDownLatch为例,任务分N个子线程去执行,state就初始化 为N,N个线程并行执行,每个线程执行完之后countDown()一次,state就会CAS减一。当N子线程全部执行完毕,state=0,会unpark()主调用线程,主调用线程就会从await()函数返回,继续之后的动作。
一般来说,自定义同步器要么是独占方法,要么是共享方式,他们也只需实现tryAcquire-tryRelease、tryAcquireShared-tryReleaseShared中的一种即可。但AQS也支持自定义同步器同时实现独占和共享两种方式,如ReentrantReadWriteLock。
在acquire() acquireShared()两种方式下,线程在等待队列中都是忽略中断的,acquireInterruptibly()/acquireSharedInterruptibly()是支持响应中断的。

读写锁

其他并发锁