多线程面试题

1.进程、线程、协程

  • 进程:本质上是一个独立执行的程序,进程是操作系统进行资源分配和调度的基本概念,操作系统进行资源分配和调度的一个独立单位

  • 线程:是操作系统能够进行运算调度的最小单位。它被包含在进程之中,是进程中的实际运作单位。一个进程中可以并发多个线程,每条线程执行不同的任务,切换受系统控制

  • 协程:又称为微线程,是一种用户态的轻量级线程,协程不像线程和进程需要进行系统内核的上下文切换,协程的上下文切换是由用户自己决定的,有自己的上下文,所以说是轻量级的线程,也称之为用户级别的线程就叫协程,一个线程可以有多个协程,线程进程都是同步机制,而协程则是异步。Java的原生语法中并没有实现协程,目前Python、Lua和GO等语言支持

  • 关系

    • 一个进程可以有多个线程,它允许计算机同时运行多个程序。线程是进程的最小执行单位,CPU调度切换的是进程和线程,进程和线程多了之后调度会消耗大量的CPU,CPU上真正运行的是线程,线程可以对应多个协程
    • 进程有自己的独立地址空间,每启动一个进程,系统就会为它分配地址空间,建立数据表来维护代码段、堆栈段和数据段,这种操作非常昂贵。而线程是共享进程中的数据的,使用相同的地址空间,因此CPU切换一个线程的花费远比进程要小很多,同时创建一个线程的开销也比进程要小很多。线程之间的通信更方便,同一进程下的线程共享全局变量、静态变量等数据,而进程之间的通信需要以通信的方式进行。多进程程序更健壮,进程有独立的地址空间,一个进程崩溃后,在保护模式下不会对其它进程产生影响,而线程只是一个进程中的不同执行路径。线程有自己的堆栈和局部变量,但线程之间没有单独的地址空间,所以可能一个线程出现问题,进而导致整个程序出现问题
  • 协程优缺点

    • 优点
      • 非常快速的上下文切换,不同系统内核的上下文切换,减小开销
      • 单线程即可实现高并发,单核CPU可以支持上万的协程
      • 由于只有一个线程,也不存在同时写变量的冲突,在协程中控制共享资源不需要加锁
    • 缺点
      • 协程无法利用多核资源,本质也是个单线程
      • 协程需要和进程配合才能运行在多CPU上
      • 目前Java没成熟的第三方库,存在风险
      • 调试debug存在难度,不利于发现问题

2.并发、并行

  • 并发concurrency:一台处理器上同时处理任务,这个同时实际上是交替处理多个任务,程序中可以同时拥有两个或者多个线程,当有多个线程在操作时,如果系统只有一个CPU,则它根本不可能真正同时进行一个以上的线程,它只能把CPU运行时间划分为若干个时间段,再将时间段分配给各个线程执行
  • 并行parallelism:多个CPU上同时处理多个任务,一个CPU执行一个进程时,另一个CPU可以执行另一个进程,两个进程互不抢占CPU资源,可以同时进行
  • 并发指在一段时间内宏观上去处理多个任务。并行指同一个时刻,多个任务确实真的同时运行
  • 例子:并发是一心多用,人听课和看电影,CPU大脑只有一个,所以轮着来。并行:分身出多个我做不同的事情

3.实现多线程的几种方式

继承Thread,重写run方法

  • 优点:代码编写简单,直接操作
  • 缺点:没返回值,继承一个类后,无法继承其他的类,扩展性差
public class Test {
    public static void main(String[] args) {
        ThreadDemo1 threadDemo1 = new ThreadDemo1();
        threadDemo1.setName("demo1");
        threadDemo1.start();
    }
}

class ThreadDemo1 extends Thread {
    @Override
    public void run() {
        System.out.println("继承Thread类实现多线程,名称:" + Thread.currentThread().getName());
    }
}

实现Runnable,实现run方法

  • 优点:线程类可以实现多个接口,可以再继承一个类
  • 缺点:没返回值,不能直接启动,需要通过构造一个Thread实例传递进去启动
public class Test {
    public static void main(String[] args) {
        Thread thread = new Thread(new ThreadDemo2());
        thread.setName("demo2");
        thread.start();
    }
}

class ThreadDemo2 implements Runnable {
    @Override
    public void run() {
        System.out.println("实现Runnable实现多线程,名称:" + Thread.currentThread().getName());
    }
}

通过Callable和FutureTask方式

  • 优点:有返回值,扩展性高

  • 缺点:JDK5以后才支持,需要重写call方法,结合多个类比如FutureTask和Thread类

public class Test {
    public static void main(String[] args) {
        FutureTask futureTask = new FutureTask<>(new ThreadDemo3());
        Thread thread = new Thread(futureTask);
        thread.setName("demo3");
        thread.start();
        try {
            Object obj = futureTask.get();
            System.out.println("返回值:" + obj);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        } catch (ExecutionException e) {
            throw new RuntimeException(e);
        }
    }
}

class ThreadDemo3 implements Callable {
    @Override
    public Object call() throws Exception {
        System.out.println("实现Callable实现多线程,名称:" + Thread.currentThread().getName());
        return "返回值";
    }
}

通过线程池创建线程

  • 自定义Runnable接口,实现run方法,创建线程池,调用执行方法并传入对象
  • 优点:安全高性能,复用线程
  • 缺点:JDK5后才支持,需要结合Runnable进行使用
public class Test {
    public static void main(String[] args) {
        ExecutorService executorService = Executors.newFixedThreadPool(3);
        executorService.execute(new ThreadDemo2());

        // 关闭线程池
        executorService.shutdown();
    }
}

class ThreadDemo2 implements Runnable {
    @Override
    public void run() {
        System.out.println("实现Runnable实现多线程,名称:" + Thread.currentThread().getName());
    }
}

一般常用Runnable和线程池+Runnable,简单方便扩展和高性能(池化的思想)

4.Java线程常见的基本状态

  • 创建(NEW):生成线程对象,但是并没有调用该对象start(),new Thread()
  • 就绪(RUNNABLE):当调用线程对象的start()方法,线程就进入就绪状态,但是此刻线程调度还没把该线程设置为当前线程,就是没获得CPU使用权。如果线程运行后,从等待或者睡眠中回来之后也会进入就绪状态
  • 运行(Running):程序将处于就绪状态的线程设置为当前线程,即获得CPU使用权,这个时候线程进入运行状态,开始运行run里面的逻辑
  • 阻塞(BLOCKED)
    • 等待阻塞:进入该状态的线程需要等待其他线程作出一定动作(通知或中断),这种状态的话CPU不会分配过来,他们需要被唤醒,可能也会无限等待下去。比如调用wait(状态就会变成WAITING状态),也可能通过调用sleep(状态就会变成TIMED_WAITING),join或者发出IO请求,阻塞结束后线程重新进入就绪状态
      • 等待(WAITING):进入该状态的线程需要等待其他线程做出一些特定动作(通知或中断)
      • 超时等待(TIMED_WAITING):在指定时间后自行返回
    • 同步阻塞:线程在获取synchronized同步锁失败,即锁被其他线程占用,它就会进入同步阻塞状态
  • 死亡(TERMINATED):一个线程run方法体执行结束,该线程就死亡了,不能进入就绪状态

5.多线程常用方法

  • sleep
    • 属于线程Thread的方法
    • 让线程暂缓执行,等待预计时间之后再恢复
    • 交出CPU使用权,不会释放锁
    • 进入阻塞状态TIMED_WAITING,睡眠结束变为就绪RUNNABLE
  • yield
    • 属于线程Thread的方法
    • 暂停当前线程的对象,去执行其他线程;让相同优先级的线程轮流执行,但是不保证一定轮流
    • 交出CPU使用权,不会释放锁
    • 不会让线程进入阻塞状态,直接变为就绪RUNNABLE,只需要重新获得CPU使用权
  • join
    • 属于线程Thread的方法
    • 在主线程上运行调用该方法,会让主线程休眠,不会释放已经持有的对象锁
    • 让调用join方法的线程先执行完毕,在执行其他线程
  • wait
    • 属于Object的方法
    • 当前线程调用对象的wait方法,会释放锁,进入线程的等待队列
    • 需要依靠notify或notifyAll唤醒,或者wait(timeout)时间自动唤醒
  • notify
    • 属于Object的方法
    • 唤醒在对象监视器上等待的单个线程,选择是任意的
  • notifyAll
    • 属于Object的方法
    • 唤醒在对象监视器上等待的全部线程

6.线程状态转换流程图

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-63Eli7jA-1681461238670)(C:\Users\Administrator\AppData\Roaming\Typora\typora-user-images\image-20230412155143422.png)]

7.多线程业务场景

异步任务:用户注册、记录日志

定时任务:定期备份日志、备份数据库

分布式计算:Hadoop处理任务MapReduce,Master-Worker(单机单进程)

服务器编程:Socket网络编程,一个连接一个线程

8.Java中哪些方法保证线程安全

加锁,比如synchronized/ReentrantLock

使用volatile声明变量,轻量级同步,不能保证原子性

使用线程安全类(原子类AtomicXXX),并发容器,同步容器

CopyOnWriteArrayList/ConcurrentHashMap等

ThreadLocal本地私有变量/信号量Semaphore等

9.volatile和synchronized区别

volatile是轻量级的synchronized,保证了共享变量的可见性,被volatile关键字修饰的变量,如果值发生了变化,其他线程立刻可见,避免出现脏读现象

volatile:保证可见性,但是不能保证原子性;能且仅能修饰变量

synchronized:保证可见性,也保证原子性;可以修饰普通方法、静态方法、代码块

使用场景

1.不能修饰写入操作依赖当前值的变量,比如num++、num = num + 1,不是原子操作,JVM字节码层面不止一步

2.由于禁止了指令重排,所以JVM相关的优化没了,效率会偏弱

10.为什么会出现脏读

  • Java内存模型简称JMM。JMM规定所有的变量存在主内存,每个线程有自己的工作内存,线程对变量的操作都在工作内存中进行,不能直接对主内存进行操作

  • 使用volatile修饰变量,每次读取前必须从主内存获取最新的值,每次写入需要立刻写到主内存中

  • volatile关键字修饰的变量随时看到自己的最新值,假如线程1对变量进行修改,那么线程2可以马上看见

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-dxyPkXEa-1681461238672)(C:\Users\Administrator\AppData\Roaming\Typora\typora-user-images\image-20230412162238422.png)]

11.volatile可以避免指令重排,什么是指令重排

  • 指令重排序分两类:编译器重排序和运行时重排序
  • JVM在编译Java代码或者CPU执行JVM字节码时,对现有的指令进行重新排序,主要目的是优化运行效率(不改变程序结果的前提)
  • 解决多线程指令重排发生问题的方案是内存屏障
  • 内存屏障是屏障指令,使CPU对屏障指令之前和之后的内存操作执行结果的一种约束

12.并发编程三要素

  • 原子性:一个不可再分的颗粒,原子性指的是一个或多个操作要么全部执行成功要么全部执行失败,期间不能被中断,也不存在上下文切换,线程切换会带来原子性问题

    int num = 1; // 原子操作
    num++; // 非原子操作,从主内存读取num到线程工作内存,进行+1,再把num写到主内存,除非用原子类,即java.util.concurrent.atomic里的原子变量类
    

    解决办法:使用synchronized或Lock(比如ReentrantLock)来把这个多步操作“变成”原子操作,但是volatile前面有说不能修饰依赖值的情况

  • 有序性:程序执行的顺序按照代码的先后顺序执行,因为处理器可能会对指令进行重排序,JVM在编译java代码或者CPU执行JVM字节码时,对现有的指令进行重排序,主要目的是优化运行效率(不改变程序结果的前提)

  • 可见性:一个线程A对共享变量的修改,另一个线程B能够立刻看到;synchronized、lock和volatile能够保证线程可见性

13.进程调度算法

  • 先来先服务调度算法
    • 按照作业/进程到达的先后顺序进行调度,即:优先考虑在系统中等待时间最长的作业
    • 排在长进程后的短进程的等待时间长,不利于短作业/进程
  • 短作业优先调度算法
    • 短进程/作业(要求服务时间最短)在实际情况中占有很大比例,为了使得它们优先执行
    • 对长作业不友好
  • 高响应比优先调度算法
    • 在每次调度时,先计算各个作业的优先权:优先权=响应比=响应时间 / 要求服务时间,选择优先权高的
    • 进行服务需要计算优先权信息,增加了系统的开销
  • 时间片轮转调度算法
    • 轮流的为各个进程服务,让每个进程在一定时间间隔内都可以得到响应
    • 由于高频率的进程切换,会增加开销,且不区分任务的紧急程度
  • 优先级调度算法
    • 根据任务的紧急程度进行调度,高优先级的先处理,低优先级的慢处理
    • 如果高优先级任务很多且持续产生,那低优先级的就可能很慢才被处理

14.常见的线程间的调度算法

线程调度是指系统为线程分配CPU使用权的过程,主要分两种

  • 协同式线程调度(分时调度模式):线程执行时间由线程本身来控制,线程把自己的工作执行完之后,要主动通知系统切换到另外一个线程上。最大好处是实现简单,且切换操作对线程自己是可知的,没有线程同步问题。坏处是线程执行时间不可控制,如果一个线程有问题,可能一直阻塞在那里
  • 抢占式线程调度:每个线程将由系统来分配执行时间,线程的切换不由线程本身来决定(Java中,Thread.yield()可以让出执行时间,但无法获取执行时间)线程执行时间系统可控,也不会有一个线程导致整个进程阻塞

Java线程调度时抢占式调度,优先让可运行池中优先级高的线程占用CPU,如果可运行池中的线程优先级相同,那就随机选择一个线程。可以通过设置线程优先级让某些线程更容易被系统选择,Java线程的优先级,以1到10的整数指定。当多个线程可以运行时,VM一般会运行最高优先级的线程(Thread.MIN_PRIORITY到Thread.MAX_PRIORITY);在两个线程同时处于就绪running状态时,优先级越高的线程越容易被系统选择执行,但并不是100%可以获得,只是机会更大而已

wait、notify是线程本身控制吗?

不是,wait是可以让出执行时间,notify后无法获取执行时间,随机等待队列里面获取

15.Java锁分类

  • 悲观锁:当线程去操作数据时,总认为别的线程会去修改数据,所以每次拿数据的时候都会上锁,别的线程去拿数据的时候就会阻塞,比如synchronized
  • 乐观锁:每次去拿数据的时候都认为别人不会修改,更新的时候会判断别人是否更新数据,通过版本来判断,如果数据被修改了就拒绝更新;比如CAS是乐观锁,但严格来说并不是锁,通过原子性来保证数据的同步,比如数据库的乐观锁通过版本控制来实现,CAS不会保证线程同步,乐观的认为在数据更新期间没有其他线程影响

小结:悲观锁适合写操作多的场景,乐观锁适合读操作多的场景,乐观锁的吞吐量会比悲观锁多

  • 公平锁:指多个线程按照申请锁的顺序来获取锁,简单来说,如果一个线程组里,能保证每个线程都能拿到锁,比如ReentrantLock(底层是同步队列FIFO:First Input Fist Output来实现)
  • 非公平锁:获取锁的方式是随机的,保证不了每个线程都能拿到锁,也就是存在有线程饿死一直拿不到锁,比如ReentrantLock(构造函数传参)

小结:非公平锁性能高于公平锁,更能重复利用CPU时间

  • 可重入锁:也叫递归锁,在外层使用锁之后,在内层仍然可以使用,并且不发生死锁
  • 不可重入锁:若当前线程执行某个方法已经获取了该锁,那么在方法中尝试再次获取锁时,就会获取不到被阻塞

小结:可重入锁能一定程度的避免死锁,synchronized、ReentrantLock都是可重入锁

  • 自旋锁:一个线程在获取锁的时候,如果锁已经被其它线程获取,那么该线程将循环等待,然后不断的判断是否能够被成功获取,直到获取到锁才会退出循环,任何时刻最多只能有一个执行单元获得锁

小结:不会发生线程状态的切换,一直处于用户态,减少了线程上下文切换的消耗,缺点是循环会消耗CPU,常见的自旋锁:TicketLock、CLHLock、MSCLock

  • 独占锁:独占锁模式下,每次只能有一个线程能持有锁
  • 共享锁:也叫S锁/读锁,能查看但无法修改和删除的一种数据锁,加锁后其它用户可以并发读取查询数据,但不能修改、增加、删除数据,该锁可被多个线程所持有,用于资源数据共享
  • 互斥锁:也叫X锁/排它锁/写锁/独占锁/独享锁,该锁每一次只能被一个线程所持有,加锁后任何线程视图再次加锁会被阻塞,直到当前线程解锁。例子:如果线程A对data加上排它锁后,则其他线程不能再对data加任何类型的锁,获得互斥锁的线程即能读数据也能修改数据
  • 死锁:两个或两个以上的线程在执行过程中,由于竞争资源或者由于彼此通信而造成的一种阻塞的现象,若无外力作用,它们都将无法让程序进行下去

下面三种是JVM为了提高锁的获取与释放效率而做的优化,针对synchronized的锁升级,锁的状态是通过对象监视器在对象头中的字段来表明,是不可逆的过程

  • 偏向锁:一段同步代码一直被一个线程所访问,那么该线程会自动获取锁,获取锁的代价更低。偏向锁使用了一种等到竞争出现才释放锁的机制,当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁
  • 轻量级锁:当锁是偏向锁时,被其他线程访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,但不会阻塞且性能会高点
  • 重量级锁:当锁为轻量级锁的时候,其他线程虽然是自旋,但自旋不会一直循环下去,当自旋一定次数的时候且还没有获取到锁,就会进入阻塞,该锁升级为重量锁,重量级锁会让其他申请的线程进入阻塞,性能也会降低

分段锁、行锁、表锁

16.手写死锁

线程在获得了锁A并且没有释放的情况下去申请锁B,这时另一个线程已经获得了锁B,在释放锁B之前又要先获得锁A,因此闭环发生,陷入死锁循环

public class Test {

    private static String locka = "locka";
    private static String lockb = "lockb";

    public void methodA() {
        synchronized (locka) {
            System.out.println("A方法获取了锁A" + Thread.currentThread().getName());

            try {
                // 让出CPU执行权,不释放锁
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }

            synchronized (lockb) {
                System.out.println("A方法获取了锁B" + Thread.currentThread().getName());
            }
        }
    }

    public void methodB() {
        synchronized (lockb) {
            System.out.println("B方法获取了锁B" + Thread.currentThread().getName());

            try {
                // 让出CPU执行权,不释放锁
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }

            synchronized (locka) {
                System.out.println("B方法获取了锁A" + Thread.currentThread().getName());
            }
        }
    }

    public static void main(String[] args) {
        Test test = new Test();
        for (int i = 0; i < 10; i++) {
            new Thread(() -> {
                test.methodA();
            }).start();
            new Thread(() -> {
                test.methodB();
            }).start();
        }
    }
}

死锁的4个必要条件

  • 互斥条件:资源不能共享,只能由一个线程使用
  • 请求与保持条件:线程已经获得一些资源,但因请求其他资源发生阻塞,对已经获得的资源保持不释放
  • 不可抢占:有些资源是不可抢占的,当某个线程获得这个资源后,系统不能强行回收,只能由线程使用完自己释放
  • 循环等待条件:多个线程形成环形链,每个都占用对方申请的下个资源

解决上面的死锁,代码优化

  • 调整申请锁的范围
  • 调整申请锁的顺序
public class Test {

    private static String locka = "locka";
    private static String lockb = "lockb";

    public void methodA() {
        synchronized (locka) {
            System.out.println("A方法获取了锁A" + Thread.currentThread().getName());

            try {
                // 让出CPU执行权,不释放锁
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }

        synchronized (lockb) {
            System.out.println("A方法获取了锁B" + Thread.currentThread().getName());
        }
    }

    public void methodB() {
        synchronized (lockb) {
            System.out.println("B方法获取了锁B" + Thread.currentThread().getName());

            try {
                // 让出CPU执行权,不释放锁
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }

        synchronized (locka) {
            System.out.println("B方法获取了锁A" + Thread.currentThread().getName());
        }
    }

    public static void main(String[] args) {
        Test test = new Test();
        for (int i = 0; i < 10; i++) {
            new Thread(() -> {
                test.methodA();
            }).start();
            new Thread(() -> {
                test.methodB();
            }).start();
        }
    }
}

17.手写不可重入锁

不可重入锁:若当前线程执行某个方法已经获取了该锁,那么在方法中尝试再次获取锁时,就会获取不到被阻塞

class UnReentrantLock {
    private boolean isLocked = false;

    public synchronized void lock() throws InterruptedException {
        System.out.println("进入加锁方法");
        // 判断是否已经被锁,如果被锁则当前请求的线程进行等待
        while (isLocked) {
            System.out.println("进入wait等待");
            wait();
        }
        // 进行加锁
        isLocked = true;
    }

    public synchronized void unlock() {
        System.out.println("解锁");
        isLocked = false;
        // 唤醒对象锁池里面的一个线程
        notify();
    }
}

public class Test {

    private UnReentrantLock lock = new UnReentrantLock();

    public void methodA() {
        try {
            lock.lock();
            System.out.println("methodA方法被调用");
            methodB();
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            lock.unlock();
        }
    }

    public void methodB() {
        try {
            lock.lock();
            System.out.println("methodB方法被调用");
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            lock.unlock();
        }
    }

    public static void main(String[] args) {
        // 演示的是同个线程
        new Test().methodA();
    }
}

18.手写可重入锁

可重入锁:也叫递归锁,在外层使用锁之后,在内层仍然可以使用,并且不发生死锁

class ReentrantLock {

    // 锁状态
    private boolean isLocked = false;
    // 用于记录是不是重入的线程
    private Thread lockedOwner = null;
    // 累计加锁次数
    private int lockedCount = 0;

    public synchronized void lock() throws InterruptedException {
        System.out.println("进入加锁方法");
        // 判断是否是同个线程获取锁
        while (isLocked && lockedOwner != Thread.currentThread()) {
            System.out.println("进入wait等待");
            wait();
        }
        // 进行加锁
        isLocked = true;
        lockedOwner = Thread.currentThread();
        lockedCount++;
    }

    public synchronized void unlock() {
        System.out.println("解锁");
        // 线程A加的锁,只能由线程A解锁,其他线程不能解锁
        if (lockedOwner == Thread.currentThread()) {
            lockedCount--;
            if (lockedCount <= 0) {
                isLocked = false;
                lockedOwner = null;
                // 唤醒对象锁池里面的一个线程
                notify();
            }
        }
    }
}

public class Test {

    private ReentrantLock lock = new ReentrantLock();

    public void methodA() {
        try {
            lock.lock();
            System.out.println("methodA方法被调用");
            methodB();
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            lock.unlock();
        }
    }

    public void methodB() {
        try {
            lock.lock();
            System.out.println("methodB方法被调用");
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            lock.unlock();
        }
    }

    public static void main(String[] args) {
        // 演示的是同个线程
        new Test().methodA();
    }
}

19.synchronized理解

  • synchronized是解决线程安全的问题,常用在同步普通方法、静态方法、代码块中

  • 修饰普通方法:锁住对象的实例;修饰静态方法:锁住整个类;修饰代码块:锁住一个对象

  • 非公平、可重入

  • 每个对象有一个锁和一个等待队列,锁只能被一个线程持有,其他需要锁的线程需要阻塞等待。锁被释放后,对象会从队列中取出一个并唤醒,唤醒哪个线程是不确定的,不保证公平性

两种形式

  • 方法:生成的字节码文件中会多一个ACC_SYNCHRONIZED标志位,当一个线程访问方法时,会去检查是否存在ACC_SYNCHRONIZED标识,如果存在,执行线程将会获取monitor,获取成功之后才能执行方法体,方法执行完后再释放monitor。在方法执行期间,其他任何线程都无法再获得同一个monitor对象,也叫隐式同步

  • 代码块:加了synchronized关键字的代码块,生成的字节码文件会多出monitorenter和monitorexit两条指令,每个monitor维护着一个记录着拥有次数的计数器,未被拥有的monitor的该计数器为0,当一个线程执行monitorenter后,该计数器自增1;当同一个线程执行monitorexit指令时,计数器自减1.当计数器为0的时候,monitor将被释放,也叫显示同步

  • 两种本质上没有区别,底层都是通过monitor来实现同步,只是方法的同步是一种隐式的方式来实现,无需通过字节码来完成

查看字节码

javac .\Test.java    
javap -v .\Test.class

JDK6优化内容

得到锁的资源进入Block状态,涉及到操作系统用户模式和内核模式的切换,代价比较高,JDK6进行了优化,增加了从偏向锁到轻量级锁再到重量级锁的过渡,但是在最终转变为重量级锁之后,性能仍然比较低

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-blBTtVXy-1681461238673)(C:\Users\Administrator\AppData\Roaming\Typora\typora-user-images\image-20230414160509795.png)]

20.什么是CAS

  • 全称是Compare And Swap,即比较再交换,是实现并发应用的一种技术

  • 底层通过Unsafe类实现原子性操作,操作包含三个操作数:内存地址(V)、预期原值(A)和新值(B)

  • 如果内存位置的值与预期原值相匹配,那么处理器会自动将该位置值更新为新值,如果在第一轮循环中,A线程获取地址里面的值被B线程修改了,那么A线程需要自旋,到下次循环才有可能执行

  • CAS属于乐观锁,性能较悲观锁有很大的提高

  • AtomicXXX等原子类底层就是CAS实现,一定程度比synchronized好,因为后者是悲观锁

缺点:

  1. 自旋时间长CPU利用率增加,CAS里面是一个循环判断的过程,如果线程一直没有获取到状态,CPU资源会一直被占用
  2. 存在ABA问题

21.ABA问题及解决

一个变量初次读取是A值,最终准备赋值的时候也是A值,但是中间可能被其他线程从A改成B然后又改回A

解决方案:给变量加一个版本号即可,约束版本号每次修改都增加,在比较的时候不仅要比较预期原值,还需要比较当前变量的版本号。

在JDK5中,已经提供了AtomicStampedReference来解决问题,检查当前引用是否等于预期引用,其次检查当前标志是否等于预期标志,如果都相等就会以原子的方式将引用和标志都设置为新值

22.介绍AQS

AQS的全称为AbstractQueuedSynchronizer,这个类在java.util.concurrent.locks包下,它是一个Java提高的底层同步工具类,比如CountDownLatch、ReentrantLock、Semaphore、ReentrantReadWriteLock、SynchronousQueue、FutureTask等等都是基于AQS的

简单来说,是用一个int类型的变量表示同步状态,并提供了一系列的CAS操作来管理这个同步状态对象
一个是state(用于计数器,类似gc的回收计数器)
一个是线程标记(当前线程是谁加锁的)
一个是阻塞队列(用于存放其他未拿到锁的线程)

例子:线程A调用了lock()方法,通过CAS将state赋值为1,然后将该锁标记为线程A加锁。如果线程A还未释放锁时,线程B来请求,会查询锁标记的状态,因为当前的锁标记为线程A,线程B未能匹配上,所以线程B会加入阻塞队列,直到线程A触发了unlock()方法,这时线程B才有机会去拿到锁,但是不一定百分百拿到

acquire(int arg):好比加锁lock操作
	tryAcquire(arg)尝试直接去获取资源,如果成功则直接返回,AQS里面未实现但没有定义成abstract,因为独占模式下只用实现tryAcquireShared-tryReleaseShared,类似设计模式里面的适配器模式

addWaiter(Node mode)
	根据不同模式将线程加入等待队列的尾部,有Node.EXCLUSIVE互斥模式、Node.SHARED共享模式;如果队列不为空,则以通过compareAndSetTail方法以CAS将当前线程节点加入到等待队列的末尾。否则通过enq(node)方法初始化一个等待队列
	
acquireQueued(final Node node, int arg)
	使线程在等待队列中获取资源,一直获取到资源才返回,如果在等待过程中被中断,则返回true,否则返回false
	
release(int arg):好比解锁unlock
	独占模式下线程释放指定量的资源,里面是根据tryRelease(arg)的返回值来判断该线程是否已经完成释放掉资源了;在自定义同步器在实现时,如果已经彻底释放资源(state=0)返回true,否则返回false
	unparkSuccessor方法用于唤醒等待队列中下一个线程

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-nixnVaDo-1681800085316)(C:\Users\Administrator\AppData\Roaming\Typora\typora-user-images\image-20230415103508985.png)]

23.AQS同步方式

独占式:比如ReentrantLock

共享式:比如Semaphore

存在组合:组合式的如ReentrantReadWriteLock,AQS为使用提供了底层支持,使用者可以自由组装实现

  1. boolean tryAcquire(int arg)
  2. boolean tryRelease(int arg)
  3. int tryAcquireShared(int arg)
  4. boolean tryReleaseShared(int arg)
  5. boolean isHeldExclusively()

不需要全部实现,根据获取的锁的种类可以选择实现不同的方法,比如实现支持独占锁的同步器应该实现tryAcquire、tryRelease、isHeldExclusively,实现支持共享的同步器应该实现tryAcquireShared、tryReleaseShared、isHeldExclusively

24.ReentrantLock和synchronized区别

ReentrantLock和synchronized都是独占锁

synchronized:

  1. 是悲观锁会引起其他线程阻塞,Java内置关键字
  2. 无法判断是否获取锁的状态,锁可重入、不可中断、只能是非公平
  3. 加锁解锁的过程是隐式的,用户不用手动操作,优点是操作简单但显得不够灵活
  4. 一般并发场景使用足够,可以放在被递归执行的方法上,且不用担心线程最后能否正确释放锁
  5. synchronized操作的应该是对象头中mark work

ReentrantLock:

  1. 是个Lock接口的实现类,是悲观锁
  2. 可以判断是否获取到锁,可重入、可判断、可公平可不公平
  3. 需要手动加锁和解锁,且解锁的操作尽量放在finally代码块中,保证线程正确释放锁
  4. 在复杂的并发场景中使用在重入时要确保重复获得锁的次数必须和重复释放锁的次数一样,否则可能导致其他线程无法获得该锁
  5. 创建的时候通过传参true创建公平锁,如果传入的是false或没传参数则创建的是非公平锁
  6. 底层是AQS的state和FIFO队列来控制加锁

25.ReentrantReadWriteLock

  1. 读写锁接口ReadWriteLock接口的一个具体实现,实现了读写锁的分离
  2. 支持公平和非公平,底层也是基于AQS实现
  3. 允许从写锁降级为读锁。流程:先获取写锁,然后获取读锁,最后释放写锁;但不能从读锁升级到写锁
  4. 重入:读锁后还可以获取读锁,获取了写锁之后既可以再次获取写锁又可以获取读锁

核心:读锁是共享的,写锁是独占的。读和读之间不会互斥,读和写、写和读、写与写之间才会互斥,主要是提升了读写的性能

ReentrantLock是独占锁且可重入的,相比synchronized而言功能更加丰富也更适合复杂的并发场景,但是也有弊端,假如有两个线程访问数据,加锁是为了防止线程A写数据,线程B读数据造成的数据不一致;但两个线程都在读数据的情况下不会改变数据,就没有必要加锁,但是ReentrantLock加锁了就降低了程序的性能,所以就有了ReadWriteLock读写锁接口

ReentrantReadWriteLock适用场景:读多写少

26.并发编程里面解决生产消费模型的几种方式

核心:保证生产者不会在缓冲区满时放入数据,消费者也不会在缓冲区空时消耗数据;常用的同步方法是采用信号或加锁机制

  1. wait()/notify()方法

  2. awit()/signal()方法:用ReentrantLock和Condition实现等待/通知模型

  3. Semaphore信号量

  4. BlockingQueue阻塞队列

    • ArrayBlockingQueue

    • LinkedBlockingQueue

      put方法用来向队尾存入元素,如果队列满,则阻塞

      take方法用来从队首取元素,如果队列为空,则阻塞

27.阻塞队列BlockingQueue介绍

  • juc包下的提供了线程安全的队列访问的接口,并发包下很多高级同步类的实现都是基于阻塞队列实现的
  • 当阻塞队列进行插入数据时,如果队列已满,线程将会阻塞等待直到队列非满
  • 从阻塞队列读数据时,如果队列为空,线程将会阻塞等待直到队列里面是非空

常见的阻塞队列

  • ArrayBlockingQueue:基于数组实现的一个阻塞队列,需要指定容量大小,FIFO先进先出顺序
  • LinkedBlockingQueue:基于链表实现的一个阻塞队列,如果不指定容量大小,默认Integer.MAX_VALUE,FIFO先进先出顺序
  • PriorityBlockingQueue:一个支持优先级的无界阻塞队列,默认情况下元素采用自然顺序升序排序,也可以自定义排序,实现java.lang.Comparable接口
  • DelayQueue:延迟队列,在指定时间才能获取队列元素的功能,队列头元素是最接近过期的元素,里面的对象必须实现java.util.concurrent.Delayed接口并实现getDelay和compareTo方法

28.非阻塞队列ConcurrentLinkedQueue怎么实现线程安全

线程安全原因:ConcurrentLinkedQueue是基于链表实现的无界线程安全队列,采用FIFO进行排序保证线程安全的三要素:原子、有序、可见性

  1. 底层结构是Node,链表头部和尾部节点是head和tail,节点变量和内部类属性使用volatile声明保证了有序和可见性
  2. 插入、移除、更新操作使用CAS无锁操作,保证了原子性
  3. 假如多线程并发修改导致CAS更新失败,采用for循环插入保证更新操作成功

29.多线程使用规范

  • 给不同模块的线程起名称,方便后续排查问题
  • 使用同步代码块或者同步方法的时候,尽量减少同步范围
  • 多用并发集合少用同步集合
    • 支持线程安全的同步集合:Hashtable/Vector,同步工具类包Collections.synchronizedXXX
    • 支持线程安全的并发集合:ConcurrentHashMap、CopyOnWriteArrayList
  • 线上业务需要使用多线程,优先考虑线程池是否更加合适,然后判断哪种线程池比较好,最后才是自己创建单一线程

30.常见线程池

好处:重用存在的线程,减少对象创建销毁的开销,有效的控制最大并发线程数,提高系统资源的使用率,同时避免过多资源竞争,避免阻塞,且可以定时定期执行、单线程、并发数控制,配置任务过多后的拒绝策略等功能

类别:

  • newFixedThreadPool:一个定长线程池,可控制线程最大并发数
  • newCachedThreadPool:一个可缓存线程池
  • newSingleThreadExecutor:一个单线程化的线程池,用唯一的工作线程来执行任务
  • newScheduledThreadPool:一个定长线程池,支持定时/周期性任务执行

31.【阿里巴巴编码规范】线程池不允许使用Executors创建,要通过ThreadPoolExecutor方式原因

Executors创建线程池底层也是调用ThreadPoolExecutor,只不过使用不同的参数、队列、拒绝策略等,如果使用不当,会造成资源耗尽问题;直接使用ThreadPoolExecutor让使用者更加清楚线程池规则、常见参数的使用,避免风险

常见的线程池问题:

  • newFixedThreadPool和newSingleThreadExecutor:队列使用LinkedBlockingQueue,队列长度为Integer.MAX_VALUE,可能造成堆积导致OOM
  • newScheduledThreadPool和newCachedThreadPool:线程池里面允许最大的线程数是Integer.MAX_VALUE,可能会创建多线程导致OOM

32.ThreadPoolExecutor构造函数参数作用

public ThreadPoolExecutor(int corePoolSize,
                              int maximumPoolSize,
                              long keepAliveTime,
                              TimeUnit unit,
                              BlockingQueue<Runnable> workQueue,
                              ThreadFactory threadFactory,
                              RejectedExecutionHandler handler)
                           
corePoolSize:核心线程数,线程池会维护线程的最少数量,默认情况下核心线程会一直存活,即使没有任务也不会受keepAliveTime控制
	坑:在刚创建线程池时线程不会立刻启动,到有任务提交时才开始创建线程并逐步线程数目达到corePoolSize
	
maximumPoolSize:线程池维护线程的最大数量,超过将被阻塞
	坑:当核心线程满,且阻塞队列也满时,才会判断当前线程数是否小于最大线程数,才决定是否创建新线程
	
keepAliveTime:非核心线程的闲置超时时间,超过这个时间就会被回收,直到线程数量等于corePoolSize

unit:指定keepAliveTime的单位,如TimeUnit.SECONDS、TimeUnit.MILLISECONDS

workQueue:线程池中的任务队列,常用的是ArrayBlockingQueue、LinkedBlockingQueue、SynchronousQueue

threadFactory:创建新线程时使用的工厂

handler:RejectedExecutionHandler是一个接口且只有一个方法,线程池中的数量大于maximumPoolSize,对拒绝任务的处理策略,默认有4种策略:CallerRunsPolicy、AbortPolicy、DiscardPolicy、DiscardOldestPolicy

33.lock与synchronized区别

  • lock获取锁与释放锁的过程都需要程序员手动控制,lock用的是乐观锁方式。所谓乐观锁就是每次不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止。乐观锁实现的机制就是CAS操作
  • synchronized托管给JVM执行,原始采用的是CPU悲观锁机制,即线程获得的是独占锁。独占锁意味着其他线程只能依靠阻塞来等待线程释放锁

34.AQS如何用单一int值表示读写两种状态

  • int是32位,将其拆分为两个无符号short,高位表示读锁,低位表示写锁

  • 两种锁的最大次数均为65535即2的16次方-1

  • 读锁:每次都从当前的状态加上65536即0000000000000001 0000000000000000‬;获取读锁个数,将state整个无符号右移16位就可得出读锁的个数

  • 写锁:每次都直接加1;获取锁&上65535得到写锁个数

35.wait与sleep区别

  • wait会释放持有的锁,而sleep不会,sleep只是让线程在指定的时间内不去抢占CPU的资源
  • wait、notify必须放在同步代码块中,且必须拥有当前对象的锁,即不能取得A对象的锁而调用B对象的wait,哪个对象wait就得调哪个对象的notify

36.ThreadLocal简介

  • 线程变量,是一个以ThreadLocal对象为键、任意对象为值的存储结构
  • 为每个线程单独存放一份变量副本,也就是说一个线程可以根据一个ThreadLocal对象查询到绑定在这个线程上的一个值。只要线程处于活动状态并且ThreadLocal实例可访问,那么每个线程都拥有对其本地线程副本的隐式引用变量,一个线程消失后,它的所有副本线程局部实例受垃圾回收(除非其他存在对这些副本的引用)
  • 方法
    • ThreadLocal.get():获取ThreadLocal中当前线程共享变量的值
    • ThreadLocal.set():设置ThreadLocal中当前线程共享变量的值
    • ThreadLocal.remove():移除ThreadLocal中当前线程共享变量的值
    • ThreadLocal.initialValue:ThreadLocal没有被当前线程赋值时或当前线程刚调用remove方法后调用get方法,返回此方法值
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

水宝的滚动歌词

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值