JAVA并发常见面试题

1.Java中的线程实现方式?

最常见的三种方式:

1.继承Thread类,重写run方法

启动线程调用start方法创建并执行run。

public class test1 {
    public static void main(String[] args) {

        MyThread myThread = new MyThread();
        myThread.start();
    }
}

class MyThread extends Thread{

    @Override
    public void run() {
        System.out.println("继承Thread类");
    }
}

2.实现Runnable接口,重写run方法

public class test2 {
    public static void main(String[] args) {
        MyThread1 myThread1 = new MyThread1();
        Thread thread = new Thread(myThread1);
        thread.start();
    }
}
class MyThread1 implements Runnable{

    @Override
    public void run() {
        System.out.println("实现Runnable接口");
    }
}

这里一般最常用的是采用匿名内部类:

  • 普通匿名内部类:
public class test3 {
    public static void main(String[] args) {
        new Thread(new Runnable() {
            @Override
            public void run() {
                System.out.println("匿名内部类");
            }
        }).start();
    }
}
  • lambda方式:
public class test3 {
    public static void main(String[] args) {
        new Thread(()->{
            System.out.println("lambda表达式");
        }).start();
    }
}

3.实现Callable,重写call方法,配合FutureTask。

Callable可以拿到返回结果。同步非阻塞。

public class test4 {
    public static void main(String[] args) throws ExecutionException, InterruptedException {
        MyCallable myCallable = new MyCallable();
        FutureTask futureTask = new FutureTask(myCallable);
        Thread thread = new Thread(futureTask);
        thread.start();
        Object o = futureTask.get();
        System.out.println(o);
    }
}

class MyCallable implements Callable{

    @Override
    public Object call() throws Exception {
        return "使用Callable";
    }
}

Future 类是异步思想的典型运用,主要用在一些需要执行耗时任务的场景,避免程序一直原地等待耗时任务执行完成,执行效率太低。具体来说是这样的:当我们执行某一耗时的任务时,可以将这个耗时任务交给一个子线程去异步执行,同时我们可以干点其他事情,不用傻傻等待耗时任务执行完成。等我们的事情干完后,我们再通过 Future 类获取到耗时任务的执行结果。

首先是FutureTask它实现了一个接口RunnableFuture。

public class FutureTask<V> implements RunnableFuture<V> 

接着RunnableFuture继承了Runnable接口的。

public interface RunnableFuture<V> extends Runnable, Future<V> 

其实,FutureTask它在重写run方法时,将传进来的callable拿到进行处理,并且重写了callable的call方法。
在这里插入图片描述
4.基于线程池构建线程

它的工作线程也是实现了Runnable。

2.Java中线程的状态?

Java中线程有6种状态:分别是NEW,RUNNABLE,BLOCKED,WAITING,TIMED_WAITING,TERMINATED。
在这里插入图片描述

  • NEW: 初始状态,线程被创建出来但没有被调用 start()
  • RUNNABLE: 运行状态,线程被调用了 start()等待运行的状态。
  • BLOCKED :阻塞状态,需要等待锁释放。
  • WAITING:等待状态,表示该线程 需要等待其他线程做出一些特定动作(通知或中断)。
  • TIME_WAITING:超时等待状态,可以在指定的时间后自行返回而不是像 WAITING 那样一直等待。
  • TERMINATED:终止状态,表示该线程已经运行完毕。

3.Java中如何停止线程?

1.stop方法(不用)

强制让线程结束,无论线程在干什么。过时了。

2.使用共享变量(用的也不多)

通过修改共享变量破坏死循环,让线程退出循环,结束run方法。

public class test5 {
    static volatile boolean flag = true;
    public static void main(String[] args) throws InterruptedException {
        Thread thread = new Thread(()->{
            while (flag){
                System.out.println("执行任务");
            }
            System.out.println("结束");
        });
        thread.start();
        Thread.sleep(1000);
        flag = false;
    }
}

3.interrupt方式

//线程默认情况下 interrupt标记为:false
System.out.println(Thread.currentThread().isInterrupted());
//执行了一次interrupt()方法后,变为true
Thread.currentThread().interrupt();
System.out.println(Thread.currentThread().isInterrupted());
//这里返回的是true,但是后面再用就恢复到默认false了
System.out.println(Thread.interrupted());
//false
System.out.println(Thread.interrupted());

正常状态的线程,在调用interrupt方法时,并不会抛出InterruptedException,而是设置一个中断状态,这个中断状态会在何时的时候起作用,也就是中断线程,具体什么时候,如果线程处于wait,sleep,join三个方法时候,则会抛出InterruptedException。

1、如果此线程处于阻塞状态(比如调用了wait方法),则会立马退出阻塞,并抛出InterruptedException异常,线程就可以通过捕获InterruptedException来做一定的处理,然后让线程退出。

2、如果此线程正处于运行之中,则线程不受任何影响,继续运行,仅仅是线程的中断标记被设置为true。所以线程要在适当的位置通过调用isInterrupted方法来查看自己是否被中断,并做退出操作。
举例:

public class test5 {
    public static void main(String[] args) throws InterruptedException {

        Thread thread = new Thread(()->{
            try {
                System.out.println("执行任务");
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                System.out.println("抛出异常");
                System.out.println(Thread.currentThread().isInterrupted());//false
            }
        });

        Thread thread1 = new Thread(()->{
            thread.interrupt();
        });
        System.out.println(thread.isInterrupted());//false
        thread.start();
        thread1.start();
    }
}

可见:线程抛出了InterruptedException,但是线程的中断状态并没有变成true

4.Java中sleep和wait方法的区别?

相同点:一旦执行,都会使得当前线程结束执行状态,进入阻塞状态。

不同点:

① 定义方法所属的类:sleep():Thread中定义的static方法。 wait():Object中定义

② 使用范围的不同:sleep()可以在任何需要使用的位置被调用; wait():必须使用在同步代码块或同步方法中

③ 都在同步结构中使用的时候,是否释放锁的操作不同:sleep():不会释放锁 ;wait():会释放锁

④ 结束等待的方式不同:sleep():指定时间一到就结束阻塞。 wait():可以指定时间也可以无限等待直到notify或notifyAll。

企业中一般使用TimeUnit睡眠。

wait() 必须是在用在持有锁的情况下:

wait() 是让获得对象锁的线程实现等待,会自动释放当前线程占有的对象锁。每个对象(Object)都拥有对象锁,既然要释放当前线程占有的对象锁并让其进入 WAITING 状态,自然是要操作对应的对象(Object)而非当前的线程(Thread)。

5.并发编程的三大特性

1.原子性

一次操作或者多次操作,要么所有的操作全部都得到执行并且不会受到任何因素的干扰而中断要么都不执行

在 Java 中,可以借助synchronized、各种 Lock 以及各种原子类实现原子性。

synchronized 和各种 Lock 可以保证任一时刻只有一个线程访问该代码块,因此可以保障原子性。

各种原子类是利用 CAS (compare and swap) 操作(可能也会用到 volatile或者final关键字)来保证原子操作。

2.可见性

当一个线程对共享变量进行了修改,那么另外的线程都是立即可以看到修改后的最新值

在 Java 中,可以借助synchronized、volatile 以及各种 Lock实现可见性。

synchronized只在加锁这一时刻同步数据,如果在内部做一些额外操作无法保证可见性。

Lock锁是基于volatile实现的。Lock锁内部在进行加锁和释放锁时,会对一个由volatile修饰的state属性进行加减操作。不过也和synchronized一样,只在加锁这一时刻同步数据。

final修饰的属性在运行期间也不允许修改,间接保证了可见性。

如果我们将变量声明为 volatile ,这就指示 JVM,这个变量是共享且不稳定的,每次使用它都到主存中进行读取。

3.有序性

由于指令重排序问题,代码的执行顺序未必就是编写代码时候的顺序。

指令重排:简单来说就是你写的程序,计算机并不是按照你写的那样去执行的。

指令重排序可以保证串行语义一致,但是没有义务保证多线程间的语义也一致 ,所以在多线程下,指令重排序可能会导致一些问题。

源代码—>编译器优化的重排—>指令并行也可能会重排—>内存系统也会重排 —>执行

处理器在进行指令重排的时候,考虑:数据之间的依赖性!

**在 Java 中,volatile关键字可以禁止指令进行重排序优化。**如果我们将变量声明为 volatile ,在对这个变量进行读写操作的时候,会通过插入特定的 内存屏障 的方式来禁止指令重排序。
在这里插入图片描述
举例说明:双重校验锁实现对象单例懒汉创建(线程安全)

public class Singleton{
    private volatile static Singleton instance;
    
    private Singleton(){}
    
    public static Singleton getInstance(){
        if(instance == null){//可以去掉,但是有问题,每个线程都来抢锁,效率低
            synchronized(Singleton.class){
                if(instance == null){
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

instance 采用 volatile 关键字修饰也是很有必要的, instance = new Singleton(); 这段代码其实是分为三步执行:

  1. instance 分配内存空间,实例化
  2. 初始化 instance
  3. instance 指向分配的内存地址

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

6.什么是CAS?有什么优缺点?

CAS 的全称是 Compare And Swap(比较与交换) ,用于实现乐观锁,被广泛应用于各大框架中。

CAS 是一个原子操作,底层依赖于一条 CPU 的原子指令。所以Java在调用CAS的时候一般是基于Native实现的。即基于Unsafe类提供的对CAS的操作方法,JVM会帮助我们将方法实现CAS汇编指令。
在这里插入图片描述
它在替换内存的某个位置的值时,首先查看内存中的值与预期值是否一致,如果一致,执行替换操作。这个操作是一个原子性操作。

像原子类中就采用了CAS。用do while循环不停调用CAS,直到成功。

AtomicInteger等原子类没有使用synchronized锁,而是通过volatile和CAS(Compare And Swap)解决资源的线程安全问题
在这里插入图片描述
优点:CAS线程不会阻塞,线程一致自旋。使得线程不会被挂起,避免了用户态和内核态的一个切换,消耗资源。

缺点:循环时间长开销会比较大

CAS 经常会用到自旋操作来进行重试,也就是不成功就一直循环执行直到成功。如果长时间不成功,会给 CPU 带来非常大的执行开销。

这里可以指定CAS一共循环多少次,超过了就直接失败/或者挂起。(自旋锁)

或者有一个类LongAdder,它类似于一个分段锁,将要处理的值分成若干份,这样可以有多个线程去处理。比如操作一个value的时候,有1w个线程操作,但是CAS只允许一个线程成功,如果把value分为10小v,则在同一时段有10个线程成功了。极大的避免了CAS失败的问题。当你需要这个值的时候,把这些内存的值加到一起返回。

ABA问题(狸猫换太子)
在这里插入图片描述
三个线程操作,线程1是和线程2一起执行的,线程1把A改为了B,接着线程3又把B改为了A。虽然最后是A,恰巧符合了线程2要操作的情况。但是这就不符合原子性了。

解决方式:带版本号的原子引用。

ABA 问题的解决思路是在变量前面追加上版本号或者时间戳。**AtomicStampedReference在CAS的时候,不但会判断原值,还会比较版本信息。**即使值一样,也需要比较版本是否一样,不一样则就修改失败。

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

CAS 只对单个共享变量有效,当操作涉及跨多个共享变量时 CAS 无效。

7.@Contended注解有什么用?

@Contended这个注解是为了解决伪共享的问题(解决缓存行同步带来的性能问题)
在这里插入图片描述
CPU在操作主内存变量之前,会将主内存数据缓存到CPU缓存(L1,L2,L3)中,先L3,再L2,再L1,最后到CPU线程中操作。

CPU中的缓存L1,是以缓存行为单位存储数据的,一般默认的大小为64字节。

缓存行操作会影响一定的性能。

比如,当L1中存了一个k数据,占8字节,然后又存了另外一个j,再对k进行操作之前,把j修改了;因为CPU缓存是以缓存行为最小单位的,j变了于是会怀疑k有没有变化,所以要去主内存里对缓存行进行同步,时间成本比较高。

而@Contended会将当前类中的属性,独占一行,从而避免缓存行失效造成的性能问题。

具体就是填充一些没有意义的long类型的数据。long:8字节

ConCurrentHashMap中,addCount方法里用到的CounterCell[ ]数组就使用@Contended。
在这里插入图片描述

8.Java中的四种引用类型

1.强引用(Strong Reference)——不回收

最常见的引用类型是强引用(普通系统99%以上都是强引用),也就是我们最常见的普通对象引用,也是默认的引用类型。把一个对象赋给一个引用变量,这个引用变量就是一个强引用。

只要强引用的对象是可触及的,垃圾收集器就永远不会回收掉被引用的对象。如果超过了引用的作用域或者显式地将相应(强)引用赋值为null,就是可以当做垃圾被收集了

强引用是造成Java内存泄漏的主要原因之一

2.软引用(Soft Reference)——内存不足即回收

只被软引用关联着的对象,在系统将要发生内存溢出异常前,会把这些对象列进回收范围之中进行第二次回收,如果这次回收还没有足够的内存,才会抛出内存溢出异常。可以在系统发生OOM之前就被回收。

软引用通常用来实现内存敏感的缓存。比如:高速缓存就有用到软引用。

3.弱引用(Weak Reference)——发现即回收

在系统GC时,只要发现弱引用,不管系统堆空间使用是否充足,都会回收掉只被弱引用关联的对象。它比软引用的生命周期更短。

可以解决内存泄露问题,ThreadLocal就是基于弱引用解决内存泄漏问题的。

WeakHashMap使用弱引用来存储图片信息,可以在内存不足的时候,及时回收,避免了OOM

4.虚引用(Phantom Reference)——对象回收跟踪

不能单独使用,也无法通过虚引用来获取被引用的对象。必须和引用队列联合使用。主要作用是跟踪对象被垃圾回收的状态。

9.ThreadLocal的内存泄露问题?

ThreadLocal实现原理:

  • 每个Thread中都存储着一个成员变量,ThreadLocalMap
  • ThreadLocal本身不存数据,像是一个工具类,基于ThreadLocal去操作ThreadLocalMap
  • ThreadLocalMap本身就是基于Entry[]实现的,因为一个线程可以绑定多个ThreadLocal,这样一来,可能需要存储多个数据,所以采用Entry[]的形式实现。
  • 每一个线程都自己独立的ThreadLocalMap,再基于ThreadLocal对象本身作为key,对value进行存取。
  • Map里面存储ThreadLocal对象(key)和线程的变量副本(value)。
  • 对于不同的线程,每次获取副本值时,别的线程并不能获取到当前线程的副本值,形成了副本的隔离,互不干扰
  • ThreadLocalMap的key(ThreadLocal)是一个弱引用,这是为了再ThreadLocal对象失去引用后,如果key的引用是强引用,会导致ThreadLocal对象无法被回收。

在这里插入图片描述
不难看出,ThreadLocalMap是ThreadLocal的内部类,同时ThreadLocal是弱引用。
在这里插入图片描述
如图
在这里插入图片描述
ThreadLocal内存泄露问题:

ThreadLocalMap 中使用的 key 为 ThreadLocal 的弱引用,而 value 是强引用。

  • 如果ThreadLocal引用丢失key因为弱引用会被GC回收掉,这样一来,ThreadLocalMap 中就会出现 key 为 null 的 Entry。如果同时线程还没有被回收,就会导致内存泄露,内存中的value无法被回收,同时也无法被获取到。
  • 只需要在使用完毕ThreadLocal对象之后,及时调用remove方法,移除Entry即可

比如使用的线程池,线程池里的线程对象还在,找不到具体的value。

ThreadLocal应用场景:使用一个拦截器拦截请求,从cookie中获取凭证字符串与redis中的凭证进行匹配,获取用户信息,将用户信息存储到ThreadLocal中,在本次请求中持有用户信息,即可在后续操作中使用到用户信息

尽管只造了一个ThreadLocal,但是main线程,t1线程,t2线程在使用ThreadLocal对象时,互相不影响。**这是因为ThreadLocal实际上是存在每个线程的一个map中的。要使用多个变量就造多个ThreadLocal对象就行,然后多个线程可以共用这些ThreadLocal对象,但实际上多个线程之间是完全隔离开的。**自始至终每个线程通过threadlocal set的对象都存在线程内部的map中!!!!

10.Java中锁的分类?

1.可重入锁和不可重入锁

可重入锁:也叫递归锁,指的是线程可以再次获取自己的内部锁。比如一个线程获得了某个对象的锁,此时这个对象锁还没有释放,当其再次想要获取这个对象的锁的时候还是可以获取的。

Sychronized,ReentrantLock,ReentrantReadWriteLock都是可重入锁。

不可重入锁:一个线程获得了某个对象的锁,此时这个对象锁还没有释放,当其再次想要获取这个对象的锁的时候无法获取,需要等待自己释放,这会造成死锁。

线程池中的Worker是不可重入锁。

2.乐观锁和悲观锁

悲观锁:获取不到锁资源时,会将当前线程挂起(进入BLOCKING、WAITING),也就是说,共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程

线程挂起会涉及到用户态和内核态的切换,是比较耗资源的。

  • 用户态:JVM可以自行执行指令,不需要借助操作系统。
  • 内核态:JVM不可以自行执行指令,需要借助操作系统。

像 Java 中Sychronized,ReentrantLock,ReentrantReadWriteLock等独占锁就是悲观锁思想的实现。

悲观锁通常多用于写比较多的情况下(多写场景),避免频繁失败和重试影响性能。

乐观锁:获取不到锁资源,可以让CPU再次调度,重新尝试获取锁资源。

乐观锁一般会使用版本号机制CAS 算法实现,CAS 算法相对来说更多一些,这里需要格外注意。

在 Java 中java.util.concurrent.atomic包下面的原子变量类就是使用了 CAS 乐观锁实现的。

乐观锁通常多于写比较少的情况下(多读场景),避免频繁加锁影响性能,大大提升了系统的吞吐量。

3.公平锁和非公平锁

公平锁:锁被释放之后,先申请的线程先得到锁。性能较差一些,因为公平锁为了保证时间上的绝对顺序,上下文切换更频繁。

非公平锁 :锁被释放之后,后申请的线程可能会先获取到锁,是随机或者按照其他优先级排序的。性能更好,但可能会导致某些线程永远无法获取到锁。

Sychronized只能是非公平锁
ReentrantLock,ReentrantReadWriteLock可以实现公平锁和非公平锁

4.互斥锁和共享锁

互斥锁:同一时间点,只会有一个线程持有当前互斥锁。比如写锁

共享锁:同一时间点,当前共享锁可以被多个线程同时持有。比如读锁。

Sychronized,ReentrantLock都是互斥锁。

ReentrantReadWriteLock有互斥也有共享

11.sychronized在JDK1.6中的优化?

锁消除:在sychronized修饰的代码中,如果不存在操作临界资源的情况下,会触发锁消除,即便写了sychronized,也不会触发。

public synchronized void method(){
    //没有操作临界资源
}

锁膨胀:如果在一个循环中,频繁的获取和释放资源,这样带来的消耗很大,锁膨胀就是将锁的范围扩大,避免频繁的竞争和获取锁资源带来不必要的消耗。

public void method(){
    for (int i = 0; i < 1000000; i++) {
        synchronized (test5.class){
            
        }
    }
}
//触发锁碰撞
synchronized (test5.class){
            for (int i = 0; i < 1000000; i++) {
                
            }
        }

锁升级: ReentrantLock是先基于乐观锁的CAS尝试获取锁资源,如果拿不到锁资源,才会挂起线程。

sychronized在1.6之前获取不到锁就会立即挂起当前线程。

在1.6做的升级优化:
在这里插入图片描述

  • 无锁,匿名偏向:当前对象没有作为锁存在,没有线程持有。
  • 偏向锁:如果当前锁资源,只有一个线程在频繁的获取和释放,那么这个线程过来,只需要判断锁指向的线程是否是当前线程。
    • 如果是,直接拿着锁资源走。
    • 如果当前线程不是它,基于CAS的方式,尝试将偏向锁指向当前线程。如果获取不到,触发锁升级,升级为轻量级锁。(偏向锁状态出现了锁竞争资源
  • 轻量级锁:会采用自旋的方式去频繁的以CAS的形式获取锁资源。(采用自适应自旋锁
    • 成功,拿着锁资源走
    • 如果自旋了一定次数,没拿到锁资源,锁升级。
  • 重量级锁:最传统的sychronized方式,拿不到锁资源,就挂起当前线程。

自适应锁可以理解为:当上一个线程经过多次CAS后成功拿到锁,那么当前线程就会多执行几次CAS。如果上一个线程失败了,就少几次CAS。

12.sychronized实现原理?

先要对Java对象在堆内存的存储有一个了解。
在这里插入图片描述
展开MarkWord:以HotSpot虚拟机为例
在这里插入图片描述
MarkWord中标记着四种锁的信息:无锁,偏向锁,轻量级锁,重量级锁。

在无锁状态下,最低位置、会用三个比特位标记着当前锁状态。001

如果是一个偏向锁,则会用54个比特位指向当前线程,同时末尾三个比特位会存储101代表偏向锁。

如果升级为轻量级锁,末尾比特位存放00代表轻量级锁,同时当前线程的信息都压在Lock Record栈中。

如果升级为重量级锁,末尾比特位存放10代表轻量级锁,同时当前线程的信息存在ObjectMonitor中,会把里面的owner设置为持有锁的线程,拿锁失败的都会放在_cxq这个单向链表中,以及EntryList中。当释放以后,EntryList中的线程就会竞争资源了。

ObjectMonitor() {
    _header       = NULL;
    _count        = 0;  //锁计数器
    _waiters      = 0,
    _recursions   = 0;
    _object       = NULL;
    _owner        = NULL;
    _WaitSet      = NULL; //处于wait状态的线程,会被加入到_WaitSet
    _WaitSetLock  = 0 ;
    _Responsible  = NULL ;
    _succ         = NULL ;
    _cxq          = NULL ;
    FreeNext      = NULL ;
    _EntryList    = NULL ; //处于等待锁block状态的线程,会被加入到该列表
    _SpinFreq     = 0 ;
    _SpinClock    = 0 ;
    OwnerIsThread = 0 ;
  }

**所以sychronized基本上是通过两个队列WaitSet和EntryList实现的,处于wait状态的线程,会被加入到WaitSet,处于等待锁block状态的线程,会被加入到EntryList。**用来保存ObjectWaiter对象列表(每个等待锁的线程都会被封装ObjectWaiter对象),owner指向持有ObjectMonitor对象的线程。

当多个线程同时访问一段同步代码时,首先会进入EntryList 集合,当线程获取到对象的monitor 后进入 Owner 区域并把monitor中的owner变量设置为当前线程同时monitor中的计数器count加1,若线程调用 wait() 方法,将释放当前持有的monitor,owner变量恢复为null,count自减1,同时该线程进入 WaitSet集合中等待被唤醒。若当前线程执行完毕也将释放monitor(锁)并复位变量的值,以便其他线程进入获取monitor(锁)。

13.什么是AQS?

AQS 的全称为 AbstractQueuedSynchronizer抽象类 ,翻译过来的意思就是抽象队列同步器。这个类在 java.util.concurrent.locks包下面。AQS 就是一个抽象类,主要用来构建锁和同步器

基于AQS实现的类有很多,比如ReentrantLock,ThreadPoolExecutor,阻塞队列,CountDownLatch,Semaphere,CyclicBarrier等等都是基于AQS实现的。

AQS 核心思想是,如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并且将共享资源设置为锁定状态。如果被请求的共享资源被占用,那么就需要一套线程阻塞等待以及被唤醒时锁分配的机制,这个机制 AQS 是用 CLH 队列锁 实现的,即将暂时获取不到锁的线程加入到队列中

首先 AQS中提供了一个由volatile修饰(保证线程可见性),并且采用CAS方式修改的int类型的state变量。

AQS 的状态state是32位(int 类型)的,切分成2份读锁用高16位,表示持有读锁的线程数(sharedCount),写锁低16位,表示写锁的重入次数 (exclusiveCount)。
在这里插入图片描述
ReentrantLock 为例,state 初始值为 0,表示未锁定状态。A 线程 lock() 时,会调用 tryAcquire() 独占该锁并将 state+1 。此后,其他线程再 tryAcquire() 时就会失败,直到 A 线程 unlock()state=0(即释放锁)为止,其它线程才有机会获取该锁。**当然,释放锁之前,A 线程自己是可以重复获取此锁的(state 会累加),这就是可重入的概念。**但要注意,获取多少次就要释放多少次,这样才能保证 state 是能回到零态的

再比如ThreadPoolExecutor里面的worker是基于AQS的,CountDownLatch将state 作为自己的计数器;Semaphere将state去记录现在所剩的资源等。

然后,AQS维护了一个双向链表(CLH 队列锁 ),有head,有tail,并且每个节点都是Node对象。

它保存着线程的引用(thread)、 当前节点在队列中的状态(waitStatus)、前驱节点(prev)、后继节点(next)。

static final class Node {
    //设置是共享的还是独占的资源
    static final Node SHARED = new Node();//共享
    static final Node EXCLUSIVE = null;//独占

    static final int CANCELLED =  1;//当前结点取消调度
    static final int SIGNAL    = -1;//后面的结点在等待当前结点唤醒
    static final int CONDITION = -2;//表示结点等待在Condition上
    static final int PROPAGATE = -3;//共享模式下,前继结点不仅会唤醒其后继结点,同时也可能会唤醒后继的后继结点。
    volatile int waitStatus;
    volatile Node prev;
    volatile Node next;
    volatile Thread thread;
    
    }

在这里插入图片描述
以独占资源获取为例

调用tryAcquire函数尝试获取,如果成功直接返回;如果不成功,调用addWaiter函数进入阻塞队列。

public final void acquire(int arg) {
	if (!tryAcquire(arg) && acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
		selfInterrupt();
}

链表添加操作:addWaiter方法,即没有拿到锁资源的线程扔到AQS队列中去排队。

private Node addWaiter(Node mode) {//mode代表互斥锁
    //将当前线程封装为node
    Node node = new Node(Thread.currentThread(), mode);
    //拿到尾节点
    Node pred = tail;
    //如果尾节点不为空
    if (pred != null) {
        //当前节点的prev指向尾节点
        node.prev = pred;
        //以CAS的方式,将当前线程设置为tail节点
        if (compareAndSetTail(pred, node)) {
            //将之前的尾节点的next指向当前节点
            pred.next = node;
            return node;
        }
    }
    //如果CAS失败,以死循环的方式,保证当前线程的Node一定可以放到AQS队列的末尾
    enq(node);
    return node;
}

另外还有一个ConditionObject方法。
在这里插入图片描述
当某一个线程持有锁之后,执行一个await方法时,线程会挂起,这种线程挂起的操作在sychronized里面会把挂起的线程扔到waitSet的等待池里,等到被唤醒的时候就会放到EntryList一个双向链表里。

所以对应的AQS这里也提供了相似的功能,当线程执行一个await方法时,就会把它从Node里拿到ConditionObject里的Waiter里,当唤醒的时候把它从ConditionObject链表里放到AQS双向链表里。

14. AQS唤醒节点时,为何从后往前找?

首先看一下加锁流程:以ReentrantLock为例:
在这里插入图片描述
AQS提供了一个唤醒节点的方法,unparkSuccessor方法:
在这里插入图片描述
原因是在上一题中的插入结点里,有这么几行代码:

    //拿到尾节点
    Node pred = tail;
    //如果尾节点不为空
    if (pred != null) {
        //当前节点的prev指向尾节点
        node.prev = pred;
        //以CAS的方式,将当前线程设置为tail节点
        if (compareAndSetTail(pred, node)) {
            //将之前的尾节点的next指向当前节点
            pred.next = node;
            return node;
        }
    }

就是说要插入结点的前驱指向了尾结点,然后又让tail结点指向了当前插入的结点。这个时刻尾结点的后继结点还没指向当前插入的结点,导致如果从前往后找的话会有丢失,错过某一个结点。
在这里插入图片描述
在此时,B的下一个结点还是null,所以如果从前往后找的话会有丢失,错过某一个结点。

另外,在取消结点调度的时候,采用的cancelAcquire方法也是采用先调整前驱指向,再调整后继指向。

Node pred = node.prev;
while (pred.waitStatus > 0)
    node.prev = pred = pred.prev;

Node predNext = pred.next;

15. ReentrantLock和Sychronized的区别?

1.两者都是可重入锁。

2.Synchronized内置的关键字,ReentrantLock是一个java类;

3.底层实现的原理不一样,ReentrantLock基于java层面实现的,基于AQS实现的;而Sychronized是基于对象实现的,底层是c++实现的,基于ObjectMonitor实现的;

4.Synchronized无法判断获取锁的状态,ReentrantLock可以判断是否获取到了锁。

5.Synchronized会自动释放锁,ReentrantLock必须手动释放锁!需要 lock() 和 unlock() 方法配合 try/finally 语句块来完成,如果不释放,则死锁!

6.ReentrantLock实现了一些更全面的功能:

  • 等待可中断 :指定等待锁资源的时间。调用lock.trylock(),也就是说正在等待的线程可以选择放弃等待,改为处理其他事情。
  • 支持公平锁和非公平锁。而Synchronized只支持非公平锁。
  • 可实现选择性通知(锁可以绑定多个条件): synchronized关键字与wait()和notify()/notifyAll()方法相结合可以实现等待/通知机制。用ReentrantLock类结合Condition实例可以实现“选择性通知”,Condition实例的signalAll()方法,只会唤醒注册在该Condition实例中的所有等待线程。

7.如果竞争比较激烈的话,推荐使用ReentrantLock去实现,不存在锁升级概念。而Synchronized是存在锁升级概念的,如果升级到重量级锁,是不存在锁降级的

16.ReentrantReadWriteLock的实现原理?

ReentrantReadWriteLock是基于AQS实现的,对state进行操作,拿到锁资源就去干活,如果没有拿到,依然去AQS队列中排队。

读锁操作:基于state的高16位进行操作。共享

写锁操作:基于state的低16位进行操作。互斥
在这里插入图片描述

static ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
//写锁
static ReentrantReadWriteLock.WriteLock writeLock = lock.writeLock();
//读锁
static ReentrantReadWriteLock.ReadLock readLock = lock.readLock();

ReentrantReadWriteLock依然是可重入锁

写锁重入:基本和ReentrantLock一致,对state进行+1操作。

读锁重入:因为读锁是共享锁,是对state的高16位进行操作。会存在一个问题,同一时间可能会有多个读线程来持有锁,无法确定state即重入次数。为了记录重入次数,每个读操作线程,都会有一个ThreadLocal记录锁重入的次数。

写锁饥饿问题

如果在读锁先被占用的情况下,来了一个写锁资源;此时,又来了许多获取读锁的线程请求资源,则会导致写锁长期获取不到写锁资源。

解决方式:

读锁在拿到锁资源后,如果再有读线程获取读锁资源,需要去AQS队列排队。如果队列的前面需要写锁资源的线程,那么后续读线程是无法拿到锁资源的,只会先让写线程之前的读线程拿到锁资源。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值