JAVAEE初阶01

个人主页

JavaSE专栏

JAVAEE初阶01

操作系统

1.对下(硬件)管理各种计算机设备

2.对上(软件)为各种软件提供一个稳定的运行环境

线程

运行的程序在操作系统中以进程的形式存在

进程是系统分配资源的最小单位

进程与线程的关系:

每当创建一个进程,就会包含一个线程,这个线程叫做主线程

进程相当于一个工厂,线程相当于工厂里干活的工人

进程和线程的区别和联系

  1. 一个进程中至少包含一个线程,这个线程就是主线程

  2. 进程是申请系统资源的最小单位

  3. 线程是CPU调度的最小单位

  4. 线程共享进程申请来的所有资源

  5. 如果一个线程崩溃,就会影响整个进程

线程的优势

  1. 线程的创建速度比进程快
  2. 线程的销毁速度比进程快
  3. 线程的CPU调度的速度比进程快

+++

  • 进程和进程之间不会相互影响,一个进程出了问题不会影响其他的进程,进程之间是相互独立的

  • 一个进程中的线程会相互影响

  • 如果一个线程崩溃,就会影响整个进程

+++

并发和并行

  1. 并发(Concurrency)
  • 定义:
    多个任务在逻辑上交替执行,看起来像是"同时进行",但实际是通过时间片轮转(如单核 CPU 切换任务)实现的
  1. 并行(Parallelism)
  • 定义:
    多个任务真正同时执行,需要依赖多核 CPU、多处理器或分布式系统
  • 关键点
    • 必须有多核/多处理器硬件支持
    • 目标是提升计算吞吐量(如拆分任务到多个核)
    • 任务是物理上的同时执行

利用Java代码创建线程

new一个Thread类的对象,调用这个类的start()方法,start() 方法会调用一个start0()方法,观察start0() 方法的源码,这个start0()方法使用了native修饰,说明这个方法是用c/c++代码写的,功能是JVM通过操作系统的API去创建一个真正的系统线程,这个线程也对应着一个PCB,然后操作系统会把这个PCB加入到一个PCB链表中,等待被CPU执行

线程的创建方式

  1. 继承Thread类,重写run方法
  2. 实现Runnable接口,重写run方法

面试题:Thread类的start()方法和run()方法之间的区别?

  1. start()方法,真实的申请系统线程PCB,从而启动一个线程,参与CPU调度
  2. run() 方法,定义线程时指定线程要执行的任务,如果调用了run()方法,只是Java对象的一个普通方法而已

++++

什么是函数式接口?

接口中只有一个方法的接口

函数式接口是指只有一个抽象方法,但接口中也可以存在默认方法(default修饰)和静态方法、object类的方法,如果一个接口中覆盖了Object类中的方法 例如(equals方法),那么这个覆盖的方法不视为抽象方法

+++

Thread是java中的类

创建的Thread对象 ---> 调用start方法 ---> JVM调用系统API生成一个PCB ---> PCB与java对象一一对应

进程分为前台进程和后台进程

如果一个线程是前台进程,那么他不受main方法的影响,main方法结束后,该前台进程依旧执行

如果一个线程是后台进程,那么他会受到main方法的影响,main方法结束后,该后台进程也会自动结束

  • 设置成后台进程之后,main方法执行完成之后,整个程序就退出了,子线程也就自动结束了

  • 前台进程不受main方法的影响,会一直运行下去

  • 前台进程可以阻止进程的退出

  • 后台进程不阻止进程的退出

  • 创建线程时默认是前台线程


前台线程 vs 后台线程

  • 区别标准 :是否会阻止进程退出
    • ✅ 前台线程:进程会等待所有前台线程执行完毕后才会退出
    • ⏳ 后台线程:进程不等待后台线程,直接退出时会自动终止所有后台线程
  • 设置方式 :通过线程属性控制setDaemon()方法

+++

CPU上的指令是抢占式执行的,都是随机调度的;main方法作为主线程会和其他子线程进行抢占

Theard.currentTheard 是获取当前线程的对象

Theard.currentTheard.getName是获取当前线程对象的名称

在线程创建时,如果要表示当前线程的对象,要使用 Theard.currentTheard

+++

线程的状态

  • New:表示创建好了一个Java线程对象,安排好了任务,但是还没有启动;在没有调用start()方法之前是不会创建PCB的,和PCB没有任何关系
  • Runnable:运行+就绪的状态,在执行任务时最常见的一种状态之一,在系统有对应的PCB
  • Blocked:加入 synchronized 关键字之后,其他线程在等待锁资源的时候出现的状态,阻塞中的一种Wait():没有等待时间,一直死等,直到被唤醒 ------ wait()join()
  • Time_Waiting:指定了等待时间的阻塞状态,过时不候-----wait(time)sleep(time)join(time)
  • Terminated:结束,完成状态,PCB已经销毁,但是java线程对象还在

线程等待时需不需要设置等待时间?

不一定,要根据具体的业务需求来确定

补充函数式接口的知识

函数式接口(Functional Interface)概念:

函数式接口 是 Java 8 引入的核心概念,指有且仅有一个抽象方法 的接口(可以包含默认方法或静态方法)。它的核心作用是为 Lambda 表达式方法引用 提供类型支持,简化函数式编程。

核心规则
  1. 必须包含且仅包含 一个抽象方法defaultstatic 方法不计数)。
  2. 推荐使用 @FunctionalInterface 注解显式标记,编译器会强制检查是否符合规则。
  3. 常见的函数式接口:RunnableComparatorSupplierConsumerFunction 等。

创建接口实例的几种方式

1. 传统方式:实现类
java 复制代码
// 定义接口
interface MyInterface {
    void doSomething();
}

// 实现类
class MyImpl implements MyInterface {
    @Override
    public void doSomething() {
        System.out.println("传统实现类");
    }
}

// 实例化
MyInterface obj = new MyImpl();
obj.doSomething(); // 输出:传统实现类
2. 匿名内部类
复制代码
MyInterface obj = new MyInterface() {
    @Override
    public void doSomething() {
        System.out.println("匿名内部类");
    }
};
obj.doSomething(); // 输出:匿名内部类
3. Lambda 表达式(最常用)
java 复制代码
@FunctionalInterface
interface MathOperation {
    int calculate(int a, int b);
}

// Lambda 实例化
MathOperation add = (a, b) -> a + b;
System.out.println(add.calculate(2, 3)); // 输出:5

线程安全

在多线程的环境中,程序运行结果不及预期,称为线程不安全现象

造成线程不安全的原因:

  1. 线程是抢占式执行的(指令的执行顺序随机的)
  2. 多个线程修改了同一个变量(共享变量)

多个线程修改同一个变量,会出现线程安全问题

多个线程修改不同的变量,不会出现线程安全问题

一个线程修改变量,不会出现线程安全问题(无论这个变量是否为同一个变量)

  1. 指令执行过程中不能保证原子性

指令要么全部执行,要么指令全都不执行

由于CPU执行指令不是原子性的,导致某个线程中的指令没有被全部执行(没有进行store存储)就被CPU调度走了,另外的线程加载到的值就是原始值;当两个线程分别完成自增操作之后把值写回内存时发生了覆盖现象

  • **工作内存与主内存不同步:**线程修改共享变量后未及时刷回主内存,其他线程读取旧值
  • **JMM内存隔离:**默认情况下线程操作是基于工作内存的

JMM (Java Memory Model) Java内存模型

  1. 每一个线程都有自己的工作内存,这些工作内存相互之间不可见

  2. Java线程首先从主内存中读取变量的值到自己的工作内存

3.线程在自己的工作内存中把值修改好之后再把修改之后的值刷回到主内存

4.工作内存与线程之间是一一对应的

以上执行的count++操作,由于实在两个线程上执行,每个线程都有自己的工作内存,且相互之间不可见,最终导致了线程安全问题

  • 线程对共享变量的修改,线程之间相互感知不到
  • 工作内存是Java层面对物理层面的关于程序所使用到了寄存器的抽象
  • 如果通过某种方式 让线程之间可以相互通信,称之为内存可见性

关于JMM的面试题:JMM的规定

  1. 保证原子性
  2. 内存可见性
  3. 指令重排序(有序性)
  4. 所有的线程不能直接修改内存中的共享变量
  5. 如果要修改共享变量,需要把这个变量从主内存中复制到自己的工作内存中,修改完成之后再刷回到主内
  6. 各个线程之间不能相互通信,做到了内存级别的线程隔离

+++

  1. 程序在编译执行时可能会出现指令重排序

我们写的代码在编译之后可能会与代码的指令顺序不同,这个过程就是指令重排序

JVM层面可能发生(编译时)重排,CPU执行指令时也可能发生重排

发生重排的是某个方法或者整个程序的指令,而不是某一条语句

指令重排序必须要保证程序的运行结果一定是正确的

+++

解决线程安全问题

关键字 synchronized

使用synchronized关键字修饰方法或者代码块进行加锁 ;假设线程t1 先拿到了锁,如果其他线程想要执行被锁住的代码,就必须要等待线程t1先执行完被锁住的代码并释放锁,如果线程t1没有释放锁,那么其他线程只能阻塞等待 ,这个线程状态就是**BLOCK**

线程安全代码示例:

对同一个变量进行累加10000次,创建线程 t1 和 t2 分别对同一个变量进行累加操作

java 复制代码
public class Demo_302 {

        public static void main(String[] args) throws InterruptedException {
            //实例化counter对象
            thread_safe.Counter302 counter = new thread_safe.Counter302();

            //创建线程t1
            Thread t1 = new Thread(() -> {
                for (int i = 0; i < 50000; i++) {
                    counter.counter();
                }
            });

            //创建线程t2
            Thread t2 = new Thread(() -> {
                for (int i = 0; i < 50000; i++) {
                    counter.counter();
                }
            });

            //启动线程t1,t2
            t1.start();
            t2.start();

            //等待线程执行完毕
            t1.join();
            t2.join();

            //打印最终结果
            System.out.println("count = " + counter.count);
        }
    }

    class Counter302 {
        public int count = 0;
        /**
         * 对count进行累加操作
         */
        public void counter() {
            count++;
        }
    }

关键字 synchronized既可以修饰方法,也可以修饰代码块

  1. synchronized修饰方法
java 复制代码
class Counter302 {
    public int count = 0;
    /**
     * 对count进行累加操作
     */
    public synchronized void counter() {
        //真实业务中,在执行加锁的代码块之前有很多的数据获取或者其他的可         //以并行执行的逻辑
        //1、从数据库中查询数据 selectAll();
        //2、对数据进行处理 build();
        //3、对其他的非修改共享变量的方法
        //.............

        //当执行到修改共享变量
        count++;
    }
}

通过使用synchronized关键字进行修饰方法

t1先获取了锁,然后执行方法,方法执行完成后,释放锁。然后其他线程再获取锁,这样就由多线程变成了一个单线程运行的状态,其实就是把多线程转成了单线程,从而解决了线程安全问题

  1. synchronized修饰代码块
java 复制代码

使用sychronized修饰代码块会解决 方法单线程执行的问题 ,从而提升效率

所以建议使用synchronized修饰那些修改共享变量的代码,这样就可以对那些只操作共享变量的代码进行上锁,而那些非操作共享变量的代码或方法就能以多线程的方式执行

+++

synchronized特性

  1. 实现了原子性
  2. 保证了内存可见性
  3. 不保证有序性(不会禁止指令重排序)
  • **关于原子性和CPU调度的关系:**某一个方法或代码块实现原子性并不影响CPU调度,在加锁的代码块中,当执行这些指令的时候,比如 LOCK LOAD ADD STORE UNLOCK 这些指令的时候,虽然在执行前已经拿到了锁,但是在执行过程中 也可能出现该线程被调出CPU的情况,这时其他线程想要执行该指令就必须拿到锁,但是该锁已经被上一个线程所持有,因此现在的线程不能执行该指令,只能等待,状态是 BLOCK

  • 保证了内存可见性后一个线程读到的数据永远是前一个线程执行完所有指令后刷回到主内存的值,这时主内存就相当于一个交换空间,线程一次写入和读取,而且是串行(顺序执行)的过程,通过这样的方式实现了内存可见性。但这并不是实际上的内存可见性,没有进行任何技术上的操作

+++

关于synchronized

  1. synchronized修饰的代码会变成串行执行
  2. synchronized可以修饰方法,也可以修饰代码块
  3. synchronized修饰的代码并不是一次性在CPU上执行完,而是中途可能被CPU调度走,当所有指令执行完成之后才会释放锁
  4. 只给一个线程加锁,也会出现线程安全问题

关键字 volatile

  • 只要在多线程环境中修改了共享变量,只管给共享变量加valotile
  • volatile修饰的变量,由于前后有内存屏障,保证了指令的执行顺序;也可以理解为 告诉编译器,不要进行指令重排序
  • valotile不保证原子性

volatile

  1. 解决了内存可见性
  2. 解决了有序性
  3. 不保证原子性

面试题:JMM如何实现原子性,内存可见性,有序性?

  1. synchronized实现了 原子性,由于是串行执行的从而也实现了可见性

  2. volatile真正的实现了内存可见性,有序性

wait() 和 notify() 、wait() 和 sleep()

  • wait() notify() 必须搭配 synchronized 使用(即配合锁一起使用)wait()notify()必须在synchronized代码块中或在其包裹的方法中使用
  • wait() join() 的比较:
    1. join() 是等待一个线程执行完毕,而wait() 是等待资源准备完成
    2. wait()Object类中的方法,join() Thread类中的方法
1.wait() vs join()

共同点

  • 都会让当前线程进入 等待状态
  • 都可以被 InterruptedException 中断。

核心区别

特性 wait()(来自 Object 类) join()(来自 Thread 类)
作用 让当前线程释放锁,并等待其他线程唤醒 等待目标线程执行完毕
锁的释放 释放锁(必须在同步代码块中调用) 不直接释放锁 ,但内部通过 wait() 释放锁(依赖目标线程的锁)
调用方式 obj.wait() thread.join()
唤醒条件 需要其他线程调用 notify()/notifyAll() 目标线程执行完毕
使用场景 线程间的协调(如生产者-消费者模型) 等待子线程结束后再继续执行主线程

+++

2. wait() vs sleep()

共同点

  • 都会让当前线程进入 等待/阻塞状态

核心区别

特性 wait()(来自 Object 类) sleep()(来自 Thread 类)
锁的释放 释放锁 (必须搭配synchronized使用,并在同步代码块中调用) 不释放锁(即使当前线程持有锁)
所属类 Object 类的方法 Thread 类的静态方法
唤醒条件 需要其他线程调用 notify()/notifyAll() 或者 过了等待时间后 时间到期后自动恢复
使用场景 线程间协调(依赖锁的释放与获取) 单纯让线程暂停一段时间(不涉及锁协调)
调用方式 obj.wait()一般是锁对象.wait() Thread.sleep(ms)

线程抛出 InterruptedException 的常见场景

  1. join()等待线程终止

    java 复制代码
    Thread thread = new Thread(() -> {
        // 耗时操作
    });
    thread.start();
    try {
        thread.join(); // 当前线程阻塞,等待 thread 终止
    } catch (InterruptedException e) {
        // 当前线程在等待过程中被其他线程中断
        System.out.println("等待 thread 时被中断!");
    }
    1. sleep()线程休眠
    java 复制代码
    try {
        Thread.sleep(1000); // 线程休眠 1 秒
    } catch (InterruptedException e) {
        // 休眠期间被中断
        System.out.println("休眠被中断!");
    }
    1. wait等待对象锁
    java 复制代码
    synchronized (lock) {
        try {
            lock.wait(); // 线程释放锁并进入等待状态
        } catch (InterruptedException e) {
            // 等待期间被中断
            System.out.println("等待锁时被中断!");
        }
    }
  2. 阻塞队列操作(如 BlockingQueue

java 复制代码
BlockingQueue<String> queue = new LinkedBlockingQueue<>();
try {
    queue.take(); // 从队列中取元素(队列为空时阻塞)
} catch (InterruptedException e) {
    // 阻塞期间被中断
    System.out.println("队列操作被中断!");
}
  1. Future.get()等待异步结果
java 复制代码
try {
            //获取任务的返回值
            Integer ret = futureTask.get();
            //打印返回值
            System.out.println("运算结果为:" + ret);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        } catch (ExecutionException e) {
            throw new RuntimeException(e);
        }

总结

  • 抛出 InterruptedException 的场景
    所有会阻塞线程的方法(如 join()sleep()wait()BlockingQueue 操作等)
  • 正确处理中断
    捕获异常后,通常需要重新设置中断标志或终止线程

类加载的时候是指 .class 文件从磁盘加载到JVM中的时候,同时生成一个类对象

单例模式(单例类的创建)

饿汉模式

饿汉模式是指在类加载的时候就初始化成员变量

首先定义一个单例类的成员变量

  1. static修饰,保证全局唯一

  2. 使用private关键字修饰,确保这个成员变量只能在当前类中使用,禁止外部类调用或更改此成员变量

  3. 由于使用了private关键字修饰,所以另外要为变量的获取定义一个专门的方法 getInstance()

  4. 既然是单例模式,就不想出现 new 这个对象,因此利用语法 使构造方法私有化,也就是用private修饰构造方法

new 关键字的作用

new 关键字在创建对象时主要完成以下操作:

  • 分配内存:为对象在堆内存中开辟空间。
  • 初始化默认值 :将对象的成员变量赋予默认值(如 int 默认是 0,引用类型默认是 null)。
  • 调用构造方法:执行类的构造方法(用于进一步初始化成员变量或执行其他逻辑)。
  • 返回对象引用:将内存地址赋值给引用变量

在外部类中无法调用private修饰的构造方法,因此就new不出来对象,利用这样的语法就避免了出现new出对象的现象。结果就是使用** 类名+ .** 的方式创建单例类,使用** 类名+ .**的方式获取单例类对象

具体代码实现:

java 复制代码
public class SingletonHungry {
    //定义成员变量,用static修饰,保证全局唯一
    //使用private修饰,确保外部类不能调用或更改此变量
    private static SingletonHungry instance = new SingletonHungry();

    //构造方法私有化,避免外部类中出现 new 一个对象的现象
    private SingletonHungry() {

    }
    //提供一个公开的方法 返回instance对象
    public static SingletonHungry getInstance() {
        return instance;
    }
}

懒汉模式

懒汉模式是指等需要的时候再实例化单例类对象,懒加载:不会随类的加载而创建,而是先赋值为null

+++

不加锁

不加锁的懒汉模式,此时在单线程的场景下运行不会出错,但在多线程的环境下会出错

java 复制代码
public class SingletonLazy {
    //定义一个全局唯一变量,先赋值为null,需要时再实例化单例类
    private static SingletonLazy instance = null;

    //构造方法私有化
    private SingletonLazy() {

    }

    //对外提供一个获取对象的方法
    public static SingletonLazy getInstance() {
        //判断这个对象是否已经被创建过
            if (instance == null) {
                //创建对象
                instance = new SingletonLazy();
            }
        //返回对象
        return instance;
    }
}

+++

加锁

加锁的懒汉模式,应该在整个if判断语句外加锁

java 复制代码
public class SingletonLazy {
    //定义一个全局唯一变量,先赋值为null,需要时再实例化单例类
    private static SingletonLazy instance = null;

    //构造方法私有化
    private SingletonLazy() {

    }

    //对外提供一个获取对象的方法
    public static SingletonLazy getInstance() {
        //对整个判断语句加锁
        synchronized (SingletonLazy.class) {
            //判断这个对象是否已经被创建过
            if (instance == null) {
                //创建对象
                instance = new SingletonLazy();
            }
        }
        //返回对象
        return instance;
    }
}

+++

双重检查锁DCL

此时解决了多线程的线程安全问题,但在上面的代码设计上还是有一些问题

当第一个线程执行完所有指令后,此时instance不再为null,剩余的线程以后也永远不会再执行new对象的操作了,因此剩余的线程也就没有必要再加锁了,剩余的线程只需要返回instance对象就可以了。

java层面的synchronized关键字对应了CPU上的指令,即LOCKUNLOCK指令,而这两个指令是互斥锁,比较消耗系统资源;也就是从第二个线程开始这个加锁操作都是无效的操作,消耗了大量的系统资源

为了解决这个问题,我们在加锁之前判断 instance是否为null,为null就执行加锁操作,否则直接返回instance对象,这样的设计称之为双重检查锁 DCL

java 复制代码
public class SingletonLazy {
    //定义一个全局唯一变量,先赋值为null,需要时再实例化单例类
    private static SingletonLazy instance = null;

    //构造方法私有化
    private SingletonLazy() {

    }

    //对外提供一个获取对象的方法
    public static SingletonLazy getInstance() {
        //判断instance是否为null
        if (instance == null) {
            //对整个判断语句加锁
            synchronized (SingletonLazy.class) {
                //判断这个对象是否已经被创建过
                if (instance == null) {
                    //创建对象
                    instance = new SingletonLazy();
                }
            }
        }

        //返回对象
        return instance;
    }
}

+++

解决指令重排序问题

new 关键字在创建对象时主要完成以下操作:

  1. 分配内存:为对象在堆内存中开辟空间

  2. 初始化默认值 :将对象的成员变量赋予默认值(如 int 默认是 0,引用类型默认是 null

  3. 返回对象引用:将对象在内存中的首地址赋值给引用变量

1 和 3 是强相关的关系,2并不强相关

1 必须在 3 之前执行,先分配内存才能把对象的内存地址返回给对象的引用

正常:1 2 3

重排序:1 3 2

如果出现以上的重排序,那么 instance就是一个没有初始化完成的对象,使用这个对象的时候,就容易出现问题,比如调用这个对象中的属性

只要多线程中修改共享变量的问题,就加volatile修饰,进而解决指令重排序问题

对于内存可见性问题,synchronized关键字就已经通过原子性解决了

DCL最终版代码:
java 复制代码
public class SingletonDCL {
    //定义一个全局唯一变量,先赋值为null,需要时再实例化单例类
    private static volatile SingletonDCL instance = null;

    //构造方法私有化
    private SingletonDCL() {

    }

    //对外提供一个获取对象的方法
    public static SingletonDCL getInstance() {
        //第一次 判断是否需要加锁
        if (instance == null) {
            //对整个判断语句加锁
            synchronized (SingletonDCL.class) {
                //判断这个对象是否已经被创建过
                if (instance == null) {
                    //创建对象
                    instance = new SingletonDCL();
                }
            }
        }

        //返回对象
        return instance;
    }
}

+++

多线程中, 从主内存加载数据 和 从寄存器加载数据 的两种情况

  • 在多线程环境中,当两个线程同时争夺锁资源时,没有获取到锁的线程会处于阻塞状态BLOCK,在这个阻塞过程中会发生很多事,当拿到锁的线程执行完所有的指令并释放锁后,另外一个线程拿到锁后会重新从主内存LOAD加载所需的数据,类似实现了可见性

  • 使用Thread.sleep()或者.wait() 对线程进行休眠,也会使线程进入阻塞状态,当再次进入CPU时会重新加载所需的数据

  • 当一个线程从 就绪/运行队列 转到 阻塞队列后,当该线程再次回到 就绪/运行队列 时会重新从主内存加载数据

  • 如果一个线程执行的任务在一直循环,那么这个线程状态一直在 就绪 、运行两个状态中来回转换,就绪和运行是属于同一种PCB状态;所以不会从主内存中加载数据,只是从寄存器中加载数据

+++

阻塞队列

性质

  1. 插入数据时,如果队列已经满了,那么就阻塞等待,等到队列中有空余容量时再插入
  2. 取出数据时,如果队列为空,那么就阻塞等待,直到队列中有数据时再取出

+++

消息队列的特性

利用阻塞队列的性质实现的消息队列

  1. 解耦

​ 生产消息的应用程序把消息写入消息队列 -->(生产者)

​ 使用消息的应用程序从消息队列中取出消息

  1. 削峰填谷(流量)

​ 针对流量暴增的时候使用消息队列来进行缓冲

  1. 异步操作

同步: 发出请求后,死等,等到有响应的时候再进行下一步操作

异步:发出请求后,就去干别的事了,当响应之后主动通知请求方

+++

阻塞队列的一些常用方法

抛出异常 特殊值 阻塞 超时
插入 add(e) offer(e) put(e) offer(e, time, unit)
移除 remove() poll() take() poll(time,unit)
检查 element() peek() 不可用 不可用
  1. addadd方法在添加元素的时候,若超出了度列的长度会直接抛出异常

  2. offer方法在添加元素时,如果发现队列已满无法添加,会直接返回false,不会抛异常

  3. put:对于put方法,若向队尾添加元素的时候发现队列已经满了会发生阻塞一直等待空间,以加入元素

  4. offer(E e, long timeout, TimeUnit unit):如果在队列满的时候,可以等待设定的时间,超时则返回falseremove:删除队首元素,若队列为空,抛出NoSuchElementException异常

  5. poll:获取并删除队首元素,如果队列为空执行poll,返回null

  6. take:获取并删除队首元素,如果队列为空,阻塞等待直到队列中有数据

  7. poll(long timeout, TimeUnit unit):如果队列为空,可以等待设定的时间,返回队首元素或null

+++

自定义实现阻塞队列

遵循先进先出的规则

  1. 插入数据时,如果队列已经满了,那么就阻塞等待,等到队列中有空余容量时再插入
  2. 取出数据时,如果队列为空,那么就阻塞等待,直到队列中有数据时再取出

需要注意的一些问题

  1. 在多线程环境中修改共享变量,对共享变量加 volatile修饰
  2. 使用while() 循环判断队列是否为空或者已满,防止出现**虚假唤醒**。原因:当大量线程积压在wait()时,一旦锁对象notifyAll()唤醒所有阻塞等待的线程后,此时积压在wait()的所有线程就会执行队列中添加元素的操作,这样就会造成head以及以后的下标的值被覆盖。添加while()循环判断条件后,各个线程在唤醒之后会先判断while循环中的条件是否成立(队列已满还是队列为空),如果已满或者为空,则线程继续等待,否则继续执行往下的代码.

+++

代码示例

java 复制代码
package blocking_queue;

public class MyBlockingQueue {
    //定义一个数组来实现队列,具体容量由构造方法确定
    private Integer elementDate[];
    //定义队首下标
    private volatile int head = 0;
    //定义队尾下标
    private volatile int tail = 0;
    //定义队列中的有效元素个数
    private volatile int size = 0;

    //构造方法来初始化队列容量
    public MyBlockingQueue(int capacity) {
        //保证队列容量必须大于0
        if (capacity <= 0) {
            throw new RuntimeException("capacity必须大于0");
        }
        elementDate = new Integer[capacity];
    }

    //定义一个插入数据的方法
    public void put(int val) throws InterruptedException {
        synchronized (this) {
            //判断队列是否已经满了
            while (size >= elementDate.length) {
                //阻塞等待
                this.wait();
            }

            //tail下标位置插入数据
            elementDate[tail] = val;

            //tail下标向后移动
            tail++;

            //调整tail下标位置
            while (tail >= elementDate.length) {
                tail = 0;
            }

            //更新size
            size++;

            //唤醒阻塞等待的线程
            this.notifyAll();
        }

    }

    //定义一个取出数据的方法
    public synchronized int take() throws InterruptedException {
        //判断队列是否为空
        while (size == 0) {
            //阻塞等待
            this.wait();
        }

        //取出队首元素
        int ret = elementDate[head];

        //head下标向后移动
        head++;

        //调整head下标的值
        while (head >= elementDate.length) {
            head = 0;
        }

        //更新size的值
        size--;

        //唤醒阻塞等待的线程
        this.notifyAll();

        //返回取出的队首元素的值
        return ret;
    }
}

+++

定时器

自定义实现定时器

核心步骤:

  1. 创建一个描述任务和执行任务时间的类 MyTask
  2. 定义添加任务的方法schedule()
  3. 定义一个优先级阻塞队列用来组织任务
  4. 创建扫描线程,不断扫描队列中任务

Lambda表达式中的this引用的是他所在对象的实例

复习 匿名内部类 和 Lamdba表达式,锁对象,Lamdba表达式中的this引用的是哪个实例?

定时器代码示例

java 复制代码
package timer;

import java.lang.management.MemoryUsage;
import java.util.Comparator;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.PriorityBlockingQueue;
import java.util.concurrent.TimeUnit;

//自定义实现定时器
public class MyTimer {
    //使用阻塞队列组织任务
    private BlockingQueue<MyTask> queue = new PriorityBlockingQueue<>();
    Object locker = new Object();

    public MyTimer() {
        //创建一个线程不断扫描线程
        Thread thread = new Thread(() -> {
            while (true) {
                try {

                    //从队列中取出任务
                    MyTask task = queue.take();
                    //判断是否到了任务的执行时间
                    long curTime = System.currentTimeMillis();
                    if (curTime < task.getTime()) {
                        //时间未到,将任务重新放回到队列中
                        queue.put(task);
                        //计算等待时间
                        long waitTime = task.getTime() - curTime;
                        synchronized (locker) {
                            locker.wait(waitTime);
                        }
                    } else {
                        //时间到了,执行任务
                        task.getRunnable().run();
                    }

                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
        });
        //启动线程
        thread.start();
        //创建 一个后台线程
        Thread daemonThread = new Thread(() -> {
            while (true) {
                synchronized (locker) {
                    //唤醒线程
                    locker.notifyAll();
                }
                //休眠一会
                try {
                    TimeUnit.MILLISECONDS.sleep(100);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
        });
        //设置为后台线程
        daemonThread.setDaemon(true);
        //启动后台线程
        daemonThread.start();
    }

    //创建schedule方法,把任务放入阻塞队列中
    public void schedule(Runnable runnable, long delay) throws InterruptedException {
        MyTask task = new MyTask(runnable, delay);
        queue.put(task);
        synchronized (locker) {
            locker.notifyAll();
        }
    }

}


//创建描述任务和任务执行时间的类
//在阻塞队列中每个元素的数据类型是MyTask,在队列中加入任务对象时都会进行排序,这里希望是按照小根堆排序
//所以MyTask这个类要实现Comparable接口来明确具体的比较方法
class MyTask implements Comparable<MyTask> {
    public MyTask(Runnable runnable, long delay) {
        //校验任务不能为空
        if (runnable == null) {
            throw new RuntimeException("任务不能为空");
        }
        //校验延时时间不能为负数
        if (delay < 0) {
            throw new RuntimeException("延时时间不能为负");
        }
        this.runnable = runnable;
        //计算出任务执行的时间
        this.time = delay + System.currentTimeMillis();
    }

    //任务
    private Runnable runnable;
    //任务的执行时间
    private long time;

    public long getTime() {
        return this.time;
    }

    public Runnable getRunnable() {
        return runnable;
    }

    @Override
    public int compareTo(MyTask o) {
        //return (int) (this.getTime() - o.getTime());
        //任务执行时间为long类型,避免long类型溢出(Long.MAX_VALUE - (-1) 会溢出)
        if (this.getTime() < o.getTime()) {
            return -1;
        } else if (this.getTime() > o.getTime()) {
            return 1;
        } else {
            return 0;
        }
    }
}

线程池

使用线程池的优势及作用

线程池是什么:字面意思就是,一次创建很多个线程,用的时候从池子里面拿出一个,用完之后换回

优势 :避免了频繁的创建销毁线程的开销,提高了程序的性能

作用 :用少量的线程执行大量的任务

++++

JDK提供的几种线程池

java 复制代码
    public static void main(String[] args) {
        // 1. 用来处理大量短时间工作任务的线程池,如果池中没有可用的线程将创建新的线程,如果线程空闲60秒将收回并移出缓存
        ExecutorService cachedThreadPool = Executors.newCachedThreadPool();
        // 2. 创建一个操作无界队列且固定大小线程池
        ExecutorService fixedThreadPool = Executors.newFixedThreadPool(3);
        // 3. 创建一个操作无界队列且只有一个工作线程的线程池
        ExecutorService singleThreadExecutor = Executors.newSingleThreadExecutor();
        // 4. 创建一个单线程执行器,可以在给定时间后执行或定期执行。
        ScheduledExecutorService singleThreadScheduledExecutor = Executors.newSingleThreadScheduledExecutor();
        // 5. 创建一个指定大小的线程池,可以在给定时间后执行或定期执行。
        ScheduledExecutorService scheduledThreadPool = Executors.newScheduledThreadPool(3);
        // 6. 创建一个指定大小(不传入参数,为当前机器CPU核心数)的线程池,并行地处理任务,不保证处理顺序
        Executors.newWorkStealingPool();
    }
}

这里使用类名加**.**的方式来获取对象的模式称之为 工厂方法模式,即根据不同的业务需求定义不同的方法来获取对象

+++

创建线程池的七个参数

++++

用现实场景解释线程池原理

银行排号系统

场景:

  1. 银行中平时只开两个核心窗口为客服提供服务,相当于线程池中的核心线程数
  2. 当有新客户来时,如果这两个核心窗口空闲,可以直接办理业务
  3. 如果两个核心窗口都在处理业务时,客户就去等待区(最多容纳20人)等待,这个等待区就相当于阻塞队列,阻塞队列的容量是20
  4. 随着等待的人数越来越多,等待区已经满了,那么银行就会开放其他的窗口一起办理业务,就是创建临时线程
  5. 再进入银行的客户(阻塞队列已满),就会执行拒绝策略

++++

分析创建线程池的源码

  1. new ThreadPoolExecutornew一个线程池对象
  1. 调用submit方法向线程池中提交任务

  2. 点进execute的源码

+++

线程池流程图

  1. 添加任务,判断当前线程数是否达到核心线程数,没有就创建线程执行这个任务;
  2. 如果达到了核心线程数,再判断队列是否已满,如果队列没满,就将任务加入队列等待;
  3. 如果队列满了,再判断当前线程总数是否达到最大线程数,如果没有达到,就创建临时线程执行任务;
  4. 如果达到了最大线程数,就执行拒绝策略

++++

自定义实现线程池

  1. Runnable描述任务
  2. 使用阻塞队列组织管理任务

这里使用阻塞队列的好处:当队列中没有任务的时候就等待,节省了系统资源

  1. 提供一个向队列中添加任务的方法

  2. 创建多个线程,扫描队列中的任务,有任务的时候就取出来执行即可

    +++

    代码示例:

java 复制代码
package thread_pool;

import java.util.concurrent.*;

/**
 * @Description:
 * @Auther: pdq
 * @Date: 2025/4/8 19:39
 */
public class MyThreadPool {

   private BlockingQueue<Runnable> queue = new LinkedBlockingQueue<>(10);

   //创建扫描线程
   public MyThreadPool(int threadNum) {
      if(threadNum < 1) {
         throw new IllegalArgumentException("线程数量不能小于1");
      }
      for (int i = 0; i < threadNum; i++) {
         //创建线程,并不断扫描
         Thread thread = new Thread(() -> {
            while (true) {
               try {
                  //从队列中取出任务
                  Runnable task = queue.take();
                  task.run();
               } catch (InterruptedException e) {
                  throw new RuntimeException(e);
               }
            }
         });
         //启动线程
         thread.start();
      }
   }


   /**
   * @Description:
   * 执行提交任务的方法
    * @param runnable
   * @return: void
   */
   public void submit(Runnable runnable) throws InterruptedException {
      //避免任务是否为null
      if(runnable == null) {
         throw new IllegalArgumentException("任务不能为空");
      }
      //将任务提交到队列
      queue.put(runnable);
   }

}

++++

线程池中的拒绝策略直接拒绝

  1. 直接拒绝 new ThreadPoolExecutor.AbortPolicy()
  2. 返回给调用者 new ThreadPoolExecutor.CallerRunsPolicy()
  3. 放弃目前最早等待的任务 new ThreadPoolExecutor.DiscardOldestPolicy()
  4. 放弃最新提交的任务 new ThreadPoolExecutor.DiscardPolicy()

其中只有 直接拒绝 的策略是会抛出异常的,其他拒绝策略不会抛出异常

++++

锁策略

乐观锁 VS 悲观锁

  1. **乐观锁:**在执行任务之前预期竞争不激烈,那就可以先不加锁,等后面如果真实发生了锁竞争再加锁

  2. **悲观锁:**在执行任务之前预期竞争非常激烈,必须先加锁再执行任务

在竞争非常激烈时,会发生锁冲突

乐观锁和悲观锁主要是从加锁的态度上去考虑问题

  • 乐观锁一旦发生了锁冲突就会加锁

+++

轻量级锁 VS 重量级锁

  1. **轻量级锁:**加锁的过程比较简单,用到的资源比较少,典型的就是用户态的一些操作(JVM层面就可以完成加锁)

  2. **重量级锁:**加锁的过程比较复杂,用到的资源比较多,典型的就是内核态的一些操作

轻量级锁和重量级锁主要是从加锁的过程上去考虑问题

  • 乐观锁是能不加锁就不加锁,从而导致他干活少,消耗的资源也少,所以可以说乐观锁就是一种轻量级锁
  • 悲观锁是任何时候都加锁,从而导致他干活多,消耗的资源也多,所以可以说悲观锁就是一种重量级锁
  • 轻量级锁:一会问一下锁释放了没,在不停地自旋,可以第一时间知道锁是否被释放
  • 重量级锁:不会第一时间知道锁是否被释放,一直等到其他线程来唤醒

++++

自旋锁 VS 挂起等待锁

  1. **自旋锁:**不停地检查锁是否被释放,如果一旦锁被释放就可以直接获取到锁资源

  2. **挂起等待锁:**阻塞等待,等待到被唤醒

这里的自旋锁和挂起等待锁是锁的真正的实现,可以获取到真正的对象,而乐观锁和悲观锁、轻量级锁和重量级锁 只是实现的模板

这两种锁的优缺点:

  1. 自旋锁: 纯用户态的操作,可以第一时间获取到锁;有自旋次数和时间的限制,通过这个限制可以控制对系统的消耗
  2. 挂起等待锁:内核态的操作,会生成对应的加锁指令,要等待唤醒,在等待的过程中会释放CPU资源

+++

读写锁 VS 普通互斥锁

  1. 读写锁:

​ 分为 读锁 和 写锁

  • 读锁:读操作的时候加读锁(共享锁),多个读锁可以共存,同时加多个读锁互不影响
  • 写锁:写操作的时候加写锁(排他锁),只允许有一个写锁执行任务,写锁和其他锁是冲突的

写锁写锁不能共存

写锁和读锁也不能共存

读锁和读锁可以共存

为什么要使用读写锁?

在程序运行的过程中,并不是所有的操作都需要修改数据,但又希望在读数据的时候其他线程不要来修改数据,读与写不能同时加锁

当执行写操作的时候,不希望其他任何线程来读数据,当写完之后才可以继续对数据进行读取,就可以加写锁(排他锁)

  1. 普通互斥锁:

有竞争关系,只能一个线程释放了锁资源之后,其他线程才可以来抢,之前用到的锁基本上都是互斥锁,写锁也是一个互斥锁

+++

公平锁 VS 非公平锁

  1. 公平锁: 先来后到,先排队的线程先拿到锁,后排队的线程后拿到锁

  2. 非公平锁: 大家去争抢,谁抢到就是谁的

一般情况下,大多数的锁都是非公平锁

这样的情况就类似于:

现实生活中如果想要真正的公平:在 立法、执法、教育、环境等各个方面都要发挥作用;因此这样会消耗更大的资源,实现公平锁的过程也是一样的,需要用额外的逻辑去管理线程,做到先来后到

Java中的JUC有一个类专门实现了公平锁

+++

可重入锁 VS 不可重入锁

  1. 可重入锁:对一把锁可以连续加多次,不造成死锁(多次加锁也要多次解锁)

  2. 不可重入锁:对一把锁可以连续加多次,造成死锁

+++

synchronized是什么锁?

  1. 既是乐观锁也是悲观锁
  2. 既是轻量级锁也是重量级锁
  3. 既是自旋锁也是挂起等待锁
  4. 是互斥锁
  5. 是非公平锁
  6. 是可重入锁

synchronized在竞争不激烈的时候,是自旋锁、轻量级锁、乐观锁

在竞争激烈的时候,是挂起等待锁、重量级锁、悲观锁

程序员不需要关注竞争是否激烈,因为synchronized内部已经帮我们实现好了,我们只需要关注自己的业务即可

++++

CAS(Compare And Swap)

什么是CAS

CAS:全称 compare and swap,字面意思:"比较并交换",一个CAS涉及以下操作:

CAS伪代码

java 复制代码
boolean CAS(address, expectValue, swapValue) {
	if (&address == expectedValue) {
		&address = swapValue;
		return true;
	}
	return false;
}

CAS参数列表中的参数含义:

  1. address:表示要修改值的内存地址,即需要修改的共享变量的地址
  2. expectValue:表示预期值,执行CAS之前读取的预期值,即线程认为变量应该是什么值
  3. swapValue:表示要设置的新值,希望将变量更新为的值

CAS执行流程

  1. 先加载LOAD出预期值,用这个预期值和内存中的做比较
  2. 如果预期值和内存中的值相等,就用新的值更新内存中的值
  3. 如果预期值和内存中的值不相等,就进入下一次CAS

在执行CAS指令时

  1. 先加载预期值LOAD,这个LOAD对应是JAVA层面
  2. 再执行具体的操作(ADD)
  3. 最后执行CAS指令(在CAS指令中也会有一条LOAD操作,这个LOAD操作是读取主内存中的值,将该值与预期值进行比较,相等就会把要更新的值写入主内存)

整个过程是原子性的(通过CPU指令cmpxchg实现)

+++

CAS的应用

实现自旋锁

伪代码

java 复制代码
public class SpinLock {
	private Thread owner = null;
	public void lock(){
	// 通过 CAS 看当前锁是否被某个线程持有.
	// 如果这个锁已经被别的线程持有, 那么就⾃旋等待.
	// 如果这个锁没有被别的线程持有, 那么就把 owner 设为当前尝试加锁的线程.
        
		while(!CAS(this.owner, null, Thread.currentThread())){
        
		}
        
	}
    //释放锁
	public void unlock (){
		this.owner = null;
	}
    
}
  • 自旋是通过while把CAS进行包裹,让CAS没有成功的时候不停的执行,直到成功执行为止

  • 自旋是在用户态实现的锁,是轻量级锁

  • **我们看到的CAS执行的这么多的操作(去读取,去比较,去修改),其实对应的是一条指令, cmpxchg**指令

  • cmpxchg是指令级别的操作,从CPU层面做了原子性支持,CPU层面即是硬件层面的支持

+++

实现原子类

​ 标准库中提供了java.util.concurrent.atomic包,里面的类都是基于这种方式实现的,典型的就是AtomicInteger类。其中的getAndIncrement相当于i++操作

代码示例:

java 复制代码
    public static void main(String[] args) {
        //原子类
        AtomicInteger atomicInteger = new AtomicInteger();
        //获取当前值             0
        System.out.println(atomicInteger.get());
        //自增      i++         1
        atomicInteger.getAndIncrement();
        System.out.println(atomicInteger.get());
        //先自增     ++i        2
        atomicInteger.incrementAndGet();
        System.out.println(atomicInteger.get());
        //自减      i--         1
        atomicInteger.getAndDecrement();
        System.out.println(atomicInteger.get());
        //先自减    --i          0
        atomicInteger.decrementAndGet();
        System.out.println(atomicInteger.get());
        //自增100  i+100         100
        atomicInteger.getAndAdd(100);
        System.out.println(atomicInteger.get());

    }

CAS的ABA问题

什么是ABA问题

ABA 问题发生在以下场景:

  1. 线程 1 读取共享变量 V 的值为 A
  2. 线程 1 被挂起,线程 2 开始执行:
    • V 的值从 A 修改为 B
    • 再将 V 的值从 B 修改回 A
  3. 线程 1 恢复执行,发现 V 的值仍为 A,于是 CAS 操作成功,将 V 更新为新值 B

问题本质

虽然最终 V 的值从 A 变为 B,但中间的 A → B → A 修改可能破坏了程序逻辑的正确性

ABA问题的危害
  1. 车主 A 打开 App
    • 看到剩余车位为 1,点击"预订"按钮。
    • 系统读取当前剩余车位值为 1(预期值 A)。
  2. 车主 B 抢先预订
    • 车主 B 同时预订,成功将车位从 1 改为 0,并入场停车。
  3. 车主 B 临时离开
    • 车主 B 在 5 分钟后驾车离开,系统将剩余车位从 0 恢复为 1(值从 B 变回 A)。
  4. 车主 A 的预订操作继续执行
    • 系统执行 CAS 操作:检查剩余车位是否仍为 1(预期值 A)。
    • 由于值已恢复,CAS 成功,车位被改为 0,车主 A 收到"预订成功"。

问题后果

  • 车位超售:实际仅 1 个车位,但系统允许多个车主预订。
  • 现场冲突:车主 A 到达后发现车位已被占用,引发纠纷。

+++

解决方案:加入版本号/标记
  • 通过为共享变量附加一个 版本号(Version)标记(Stamp),每次修改递增版本号,确保值的修改历史唯一性

  • 在每次修改时同时更新值和版本号

java 复制代码
初始状态:value = A, version = 0
第一次修改:value = B, version = 1
第二次修改:value = A, version = 2
  • CAS操作需同时检查比较值和版本号

只有预期值和内存中的值相等,并且对应的版本号也相等,才能更新内存中的值

+++

锁升级

锁升级的过程: 无锁 ----> 偏向锁 ----> 轻量级锁(自旋锁) ----> 重量级锁

**锁升级的目的:**根据线程竞争情况动态调整锁的级别来平衡性能与线程安全;根据线程竞争情况动态选择最优锁策略,减小性能开销

各阶段锁的对比:

锁状态 适用场景 实现方式 性能开销 同步策略
无锁 无竞争 无同步操作 -
偏向锁 单线程重复访问 CAS记录线程ID 极低 无竞争时直接访问
轻量级锁 多线程交替执行(低竞争) CAS + 自旋 自旋尝试,避免阻塞
重量级锁 高并发竞争 操作系统互斥量(Mutex) 线程阻塞,依赖CPU调度

锁升级的四个阶段:

  1. 无锁:
  • 场景:未被任何线程访问
  • 触发升级:首次被线程访问时,会根据竞争情况升级为偏向锁或轻量级锁
  1. 偏向锁:
  • 场景:单线程重复进入同步块(单线程独占访问同步块)

  • 特点:对象头的MarkWord会记录偏向线程ID,该偏向线程再次访问同步块时无需CAS操作

  • 触发升级:当其他线程尝试进入同步块时(尝试获取锁),偏向锁被撤销,升级为轻量级锁

  1. 轻量级锁(自旋锁):
  • 场景:多线程顺序交替执行同步块,线程间无激烈竞争,适合短时间锁占用
  • 自旋优化:在升级为重量级锁之前会进行短暂自旋(循环尝试CAS),避免立即阻塞
  • 触发升级:发轻量级锁自旋失败(竞争激烈)或 检测到多线程竞争
  1. 重量级锁:
  • 场景:长时间持有锁或高并发竞争

+++

  • 锁升级是单向不可逆的,一旦升级为重量级锁,即使后序竞争消失,也不会降级

  • 偏向锁的延迟启用:JVM默认在启动4秒后才会启用偏向锁,避免启动阶段大量加载类和初始化时的锁竞争 导致频繁撤销偏向锁

  • 偏向锁的撤销条件:

    复制代码
      		1. <font color = red>当有其他线程尝试进入同步块时,撤销偏向锁升级为轻量级锁或重量级锁</font>
      		1. <font color = red>原偏向线程不再存活,撤销偏向锁</font>

+++

synchronized锁消除

​ 编译器和JVM可以判断锁是否可以消除,如果可以,就直接消除

程序员在写代码的时候,可以自由决定加synchronized的时间,也就是什么时候加,什么时候不加,完全由程序员决定;但是代码在编译运行的时候JVM就可以知道加了synchronized的代码块中,对共享变量是执行读操作还是写操作,还知道当前线程是多线程状态还是单线程状态

如果所有加了synchronized的代码块,并没有对共享变量执行写操作,那么synchronized对应的锁就不会生效(不会编译成LOCK指令)

线程安全问题只有多个线程对共享变量进行写操作时才会发生,如果没有写操作,那么加synchronized就没有必要,所以JVM不会真的去加锁,这个现象叫做锁消除

+++

synchronized锁粗化

程序员写代码的时候,什么时候加synchronized,什么时候不加,JVM管不了

业务流程如下:

众所周知加锁与释放锁的过程是很消耗CPU资源的,所以JVM认为这种加锁的方式是很低效的,会进行优化,也就是从方法1开始加锁,到方法4执行完释放锁,整个过程只有一个加锁操作

把方法级别的细粒度锁,优化成业务级别的粗粒度锁

+++

JUC

​ JUC(java.util.concurrent)是多线程环境中用的比较多的一个包,复杂度也比较高,包中提供了一些API(应用程序接口),整个多线程的处理都是java层面实现的

Callable接口

Callable 和 Runnable接口一样都是函数式接口,都可以使用lambda表达式简化创建写法

Callable中的call()方法 和 Runnable接口中的run()方法相同,都是定义线程任务的方法

Callable中的call()方法有返回值V,同时可以向外部抛出业务异常,而Runnable方法中的run()方法即没有返回值也不可以向外抛出异常,业务异常只能在run()方法内部处理

通过Callable 定义任务

java 复制代码
//创建Callable接口实例,并定义任务
        Callable<Integer> callable = new Callable<Integer>() {
            @Override
            public Integer call() throws Exception {
                System.out.println("正在运算过程中....");
                int sum = 0;
                for (int i = 1; i <= 1000; i++) {
                    sum += i;
                }
                //休眠3秒,模拟真实业务处理的时间
                TimeUnit.SECONDS.sleep(3);
                //throw new Exception("执行过程中出现了异常....");
                System.out.println("运算成功");
                return sum;
            }
        };

定义完任务,如何将任务添加进Thread

java 复制代码
        //Callable要配合FutureTask一起使用,FutureTask用来获取Callable的执行结果
        FutureTask<Integer> futureTask = new FutureTask<>(callable);
java 复制代码
        //FutureTask当做构造参数传入到Thread构造方法中
        Thread thread = new Thread(futureTask);
        //启动线程
        thread.start();

如何获取call()方法的返回值

java 复制代码
//获取任务的返回值,等待结果的时候可能会被中断,会抛出InterruptionException异常
try {
    //调用futureTask.get()方法获取call()方法的返回值
    //调用futureTask.get()方法时,当前线程会阻塞等待(后面的代码不会执行),一直等到call()方法有一个运行结果
    Integer ret = futureTask.get();
    //打印返回值
    System.out.println("运算结果为:" + ret);
} catch (InterruptedException e) {
    e.printStackTrace();
} catch (ExecutionException e) {
    e.printStackTrace();
    //打印异常信息
    System.out.println("打印日志: " + e.getMessage());
}

+++

Runnable 和 Callable的区别

  1. Callable接口中的call()方法有返回值,Runnable接口中的run()方法没有返回值
  2. Callable中的call()方法可以抛出异常,Runnable接口中的run()方法不能抛出异常
  3. Callable要配合FutureTask一起使用,通过futureTask.get()方法获取call()方法的返回值
  4. 两者都是描述线程任务的接口

+++

创建线程的几种方式

  1. 继承Thread类,实现run()方法
  2. 实现Runnable接口,并实现run()方法
  3. 实现Callable接口,并实现call()方法
  4. 通过创建线程池,并提交任务

++++

ReentrantLock( 可重入锁)

ReentrantLock是java中的一个类,使用时要创建一个对象

java 复制代码
        //初始化一个锁
        ReentrantLock lock = new ReentrantLock();

        try {
            //加锁
            lock.lock();
            //执行加锁的代码
        } finally {
            //释放锁
            lock.unlock();
        }

        //尝试加锁
        lock.tryLock();

        //尝试加锁,并设置等待时间
        lock.tryLock(1, TimeUnit.SECONDS);

使用 try...finallylock.unlock释放锁的代码放入到finally中,确保释放锁的代码能够执行

正确的使用

java 复制代码
        // 初始化一个锁
        ReentrantLock lock = new ReentrantLock();

        try {
            // 开始执行业务代码之前先上锁
            lock.lock();
            System.out.println("业务代码执行中....");
            TimeUnit.SECONDS.sleep(3);
            throw new Exception("执行出现异常");
        } finally {
            // 无论任何时候都可以释放锁
            lock.unlock();
            System.out.println("锁已释放");
        }

+++

公平锁 和 非公平锁的创建

java 复制代码
        //创建一个公平锁
        ReentrantLock lock = new ReentrantLock(true);
        //创建一个非公平锁
        ReentrantLock lock1 = new ReentrantLock(false);

+++

读写锁的创建 和 使用

java 复制代码
        //创建一个读写锁
        ReentrantReadWriteLock readWriteLock = new ReentrantReadWriteLock();

        //获取读锁
        ReentrantReadWriteLock.ReadLock readLock = readWriteLock.readLock();

        //获取写锁
        ReentrantReadWriteLock.WriteLock writeLock = readWriteLock.writeLock();

        //读锁加锁,共享锁,多个读锁可以共存
        writeLock.lock();
        //读锁解锁
        writeLock.unlock();

        //写锁加锁,排他锁,多个锁不能共存
        readLock.lock();
        //写锁解锁
        readLock.unlock();

休眠和唤醒

​ 不同于使用synchronized包裹的休眠和唤醒( 锁对象.wait() 和 锁对象.notify() ),这里使用

Condition可以绑定到多个锁,可以实现对一部分符合相应条件的线程进行唤醒

java 复制代码
    public static void main(String[] args) throws InterruptedException {
        ReentrantLock lock = new ReentrantLock();
        //定义很多个休眠与唤醒条件
        //条件1
        Condition male = lock.newCondition();
        //条件2
        Condition female = lock.newCondition();

        //根据不同的条件进行阻塞等待
        male.await();
        //根据不同的条件进行唤醒
        male.signal();      //唤醒相应队列中的一个线程
        male.signalAll();   //唤醒相应队列中的所有线程

        //根据不同的条件进行阻塞等待
        female.await();
        //根据不同的条件进行唤醒
        female.signal();    //唤醒相应队列中的一个线程
        female.signalAll(); //唤醒相应队列中的所有线程

    }

+++

ReentrantLocksynchronized 的区别

+++

信号量 (Semaphore)

复制代码
信号量,用来表示"可用资源的个数". 本质上就是一个计数器

理解信号量:

停车场:

  1. 停车场外面有一个显示牌,牌子上会显示当前停车场的可用空位个数
  2. 每进入一辆车,显示牌上的可用个数就减1; 每出去一辆车,可用个数就加1
  3. 如果停车场的车位都占满了,那么显示牌上就显示车位已满,这时在停车场外的车就要阻塞等待

阻塞之后,每出去一辆车,个数减1,意味着释放了资源,外面等待的车就可以进入

停车场模拟代码示例:

java 复制代码
public static void main(String[] args) {
        //创建信号量,初始化可用资源 5 个,相当于有5个停车位
        Semaphore semaphore = new Semaphore(5);

        //定义线程的任务
        Runnable runnable = new Runnable() {
            @Override
            public void run() {
                System.out.println(Thread.currentThread().getName() + "开始申请资源...");
                try {
                    //申请资源,相当于进入停车场,可用车位减1
                    semaphore.acquire();
                    System.out.println(Thread.currentThread().getName() +"======已经申请到资源=======");
                    //处理业务逻辑,用休眠模拟,相当于停车时间
                    TimeUnit.SECONDS.sleep(1);
                    //释放资源,相当于出停车场,可用车位加1
                    semaphore.release();
                    System.out.println(Thread.currentThread().getName() + "-------释放资源...");
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
        };

        //创建15个线程来执行任务,相当于有15辆车要进入停车场
        for (int i = 0; i < 15; i++) {
            //创建线程并指定任务
            Thread thread = new Thread(runnable);
            //启动线程
            thread.start();
        }
    }

+++

通过信号量可以限制系统中并发执行的线程个数

++++

CountDownLatch

场景:100米跑步比赛

  1. 选手各就各位,预备
  2. 开跑,选手有快有慢
  3. 最后一位选手过线,比赛结束
  4. 颁奖

countDownLatch可以实现 所有线程都完成某个任务之后,再去执行其他的任务

跑步比赛代码示例

java 复制代码
public static void main(String[] args) throws InterruptedException {
          //指定参赛选手的个数(线程数)
        CountDownLatch countDownLatch = new CountDownLatch(10);

        //创建10个线程
        for (int i = 0; i < 10; i++) {
            Thread thread = new Thread(() -> {

                try {
                    System.out.println("开跑....");
                    //模拟业务执行时间,即比赛过程,休眠2秒
                    TimeUnit.SECONDS.sleep(2);
                    System.out.println("选手过线,到达终点...");
                    //标记选手已经到达终点,当countDownLatch的计数到0时,表示所有选手都已到达终点,比赛结束
                    countDownLatch.countDown();
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            },"player" + i);
            //启动线程
            thread.start();
        }
        TimeUnit.MILLISECONDS.sleep(10);
        System.out.println("比赛进行中....");
        //等待线程执行完毕,即等待比赛结束
        countDownLatch.await();//一直阻塞等待到计数器归零,即所有选手都已经到达终点
        //颁奖
        System.out.println("比赛结束,开始颁奖");
    }

应用场景

线程安全的集合类

大部分的集合类都不是线程安全的

Vector,Stack,HashTable,是线程安全的(不建议用),其他的集合类不是线程安全的

例如:在多线程环境下使用 ArrayList集合类就会造成线程安全问题,代码示例如下:

java 复制代码
    public static void main(String[] args) {
        //定义一个线程不安全的集合对象
        List<Integer> arrayList = new ArrayList<>();

        //创建10个线程,同时执行写入和读取操作
        for (int i = 0; i < 10; i++) {
            int num = i + 1;
            Thread thread = new Thread(() -> {
                 //写
                arrayList.add(num);
                 //读
                System.out.println(arrayList);
            });

            //启动线程
            thread.start();
        }
    }

执行该代码会出现异常:

针对这种线程不安全的集合类,我们使用工具类Collections.synchronizedList(new ArrayList)把普通集合对象,转换为线程安全的集合对象

Collections.synchronizedList(new ArrayList)

java 复制代码
public static void main(String[] args) {
        //定义一个普通集合类对象
        List<Integer> arrayList = new ArrayList<>();
        //使用Collection工具类将普通集合类对象转换为线程安全的集合类
        List<Integer> list = Collections.synchronizedList(arrayList);

        //创建10个线程,同时进行读和写的操作
        for (int i = 0; i < 10; i++) {
            int num = i + 1;
            Thread thread = new Thread(() -> {
                //读
                list.add(num);
                //写
                System.out.println(list);
            });
            //启动线程
            thread.start();
        }
    }

源码中,将普通集合类转换成 线程安全的集合类后,就是在相关的修改数据的方法前面加了synchronized关键字

CopyOnWriteArrayList(写时复制)

写时复制是指在

进行写操作的时候,先复制一份新的集合,在新复制的集合中进行写操作,写操作完成后会将这个新复制的副本替换原来的旧集合,替换的过程需要加锁;

写时复制流程:

  • 写操作流程
    1. 复制原集合 → 2. 修改副本→ 3. 加锁替换原集合
  • 读操作流程:写操作完成替换前,读操作访问旧集合;

​ 写操作完成替换后,后续读操作访问新集合

读操作是否总是访问旧集合?

  • 不完全正确
    • 在写操作完成替换前,读操作访问旧集合。
    • 在写操作完成替换后,后续读操作访问新集合。
    • 关键点 :读操作不感知替换过程,直接访问当前 volatile 数组引用。

在写操作完成后,副本会替换原来的旧集合,即原引用指向副本,副本成为原始集合

底层使用 volatile 修饰的数组存储数据,确保修改后的引用对其他线程立即可见,确保了内存可见性

写时复制适用于 读多写少 的场景

适用场景与注意事项

场景 推荐使用 不推荐使用
高频读、低频写(如监听器列表) ❌ 高频写(如计数器)
数据一致性要求宽松 ❌ 强一致性需求(如银行转账)
内存充足,可容忍临时内存翻倍 ❌ 内存敏感场景

多线程环境下使用哈希表

HashMap本身是线程不安全的,如果在多线程环境下使用哈希表应该使用:

  1. Hashtable
  2. ConcurrentHashMap

+++

  • Hashtable:对所有方法都加了锁,对性能有较大影响,会导致严重的效率问题
  • ConcurrentHashMap:并没有对整个方法加锁,而是对要操作的hash桶加锁,其他的桶不加锁,理论上有多少个桶就可以支持多少个线程进行并发读写
  • 相较于HashtableConcurrentHashmap锁的粒度更小,并发更高

+++

Hashtable把所有的桶整体加了锁,在put操作时,把整个Hashtable都给锁住了,但真正操作的只有一个hash

ConcurrentHashMap只是对某一个桶进行加锁,在put操作时,修改哪个桶的数据,就对哪个桶加锁

+++

在Java中,HashtableConcurrentHashMap都是线程安全的哈希表实现,但它们在设计、性能和应用场景上有显著差异。以下是它们的核心区别:

1. 线程安全实现方式

特性 Hashtable ConcurrentHashMap
锁机制 使用全局锁(所有方法用synchronized修饰) 桶锁 + CAS(Java 8)
锁粒度 粗粒度(整个表被锁) 细粒度(仅锁部分数据,如哈希桶)
并发度 低(所有操作串行) 高(多线程可并行操作不同桶)

2. 性能对比

场景 Hashtable ConcurrentHashMap
读操作 所有读操作竞争同一把锁,性能差 无锁或细粒度锁,读性能高
写操作 写操作完全串行,高并发下性能差 多线程可同时写不同桶,性能高
高并发场景 不适用(易成瓶颈) 适用(设计为高并发优化)

补充知识:HashMap的实现原理

  1. put一个对象进来时,先根据对象的HashCode和数组长度进行求余,通过余数来确定对象放在数组的哪个下标中
  2. 每个hash桶存的是具体对象的链表
  3. 初始化数组长度是16,中间还可能发生扩容,扩容的时候会对当前hash表中的所有元素重新hash到新的hash表中
  4. 发生扩容的条件:负载因子 > 0.75 时,默认负载因子是0.75
  5. 当数组长度大于8,同时链表长度大于64时,链表就会转化为红黑树

+++

ConcurrentHashMap的优化扩容机制

  • 触发条件:负载因子超过阈值0.75时触发扩容

  • 在扩容过程中,多个线程会协助扩容(协助迁移),多个线程协助把旧表中的数据迁移到新表中

  • 在扩容过程中,任何正在执行插入、更新或删除的线程检测到扩容状态,会主动参与迁移

  • 迁移过程中,若线程执行查询操作,可同时访问旧表和新表;先查旧表,若未找到,则查新表

    +++

1. 协作迁移机制

  • 当线程执行写操作时,若检测到当前表正在扩容(table 被标记为 MOVED):

    1. 暂停当前写操作,优先协助迁移数据
    2. 领取迁移任务 :通过全局变量 transferIndex 分配迁移的桶区间
    3. 迁移完成:继续执行原写操作(写入新表)

    +++

2.读操作的执行逻辑

  • 无锁设计 :读操作完全无锁,通过 volatile 变量和内存屏障保证可见性
  • 直接访问数据
    • 若当前桶未迁移,直接从旧表读取数据
    • 若当前桶已迁移(被标记为 ForwardingNode),则跳转到新表读取数据
    • 若正在迁移中,可能同时访问旧表和新表的已迁移部分

+++

多线程环境下,用哪个类来保证MAP的线程安全?

使用JUC包下的ConcurrentHashMap

介绍下ConcurrentHashMap, HashMapHashTable的区别?

  1. HashMap是线程不安全的

  2. HashTable是线程安全的,对所有的操作都加了锁,效率不高,不推荐使用HashTable是使用synchronized对所有操作加的锁

  3. ConcruuentHashMap的锁粒度比较小,并不是对整个HashMap加锁,而是对每一个数组的下标进行加锁,也就意味着可以支持更大的并发量,从而提升性能

  • ConcureentHashMap只对put进行加锁(对修改进行加锁),对get不进行加锁ConcurrentHashMap对扩容也进行了优化

ConcureentHashMap的扩容机制:

1.扩容时把数组的容量增大到原来的2倍,并不是一次性能把MAP中的数据复制到新MAP中,而是只复制当前访问的下标中的元素

2.这种操作会使两个MAP同时存在一段时间

3.当查询时同时在两个MAP中查

4.删除时在两个MAP中同时删

5.写入时,只往新的MAP中写

典型的以空间换时间的方法

每次调用get put方法时把旧MAP中对应下标中的元素搬运到新MAP

++++

死锁

  1. 一个线程,获取一把锁
  • 在单线程环境中,如果使用不可重入锁(Non-Reentrant Lock),同一线程多次尝试获取同一把锁时,会导致线程阻塞自身,形成死锁
  • 在单线程环境中,使用可重入锁(ReentrantLock),当同一个线程多次获取同一把锁时,不会造成死锁现象

+++

  1. 两个线程,获取两把锁
  • 两个锁对象 lock1 和 lock2 ,两个线程A 和 B;
  • 线程A先申请拿到lock1的锁,再申请拿lock2的锁;
  • 线程B先申请拿到lock2的锁,再申请拿lock1的锁;
  • 当两个线程分别持有外层拿到的锁,并尝试获取对方已经持有的锁时,就会造成死锁状态

++++

  1. 多个线程,获取多把锁

​ 场景:哲学家就餐问题

  1. 哲学家之间放一根筷子
  2. 先拿左手边的筷子,再拿右手边的筷子
  3. 吃完后放下两根筷子,放回原位,等待下一次吃
  4. 哲学家就干两件事,一个是吃,一个是等

这个模型在大多数的情况下运行良好,不会发生死锁问题

但有一个极端情况,会发生死锁状态

面试题:

造成死锁的原因:

  1. 互斥访问线程1拿到了 锁A,那么线程2就不能同时得到该锁(互斥锁)

  2. 不可抢占获取到锁的线程,除非自己主动释放锁,别的线程不能从他手里抢过来

  3. 保持与请求线程1已经获取到了锁A,还要在这个基础上再去获取锁B

  4. 循环等待线程 1等待线程2释放锁,线程2等待线程3释放锁,线程3等待线程1释放锁...

以上四条是造成死锁的必要条件,必须同时满足,也就是说只要打破一条, 死锁就不会形成

分析如何解决死锁问题:

  1. 互斥访问锁的基本特性, 不能打破

  2. 不可抢占锁的基本特性,不能打破

  3. 保持与请求和代码的设计和实现相关,是可以打破的,只要规定一- 下获取锁的顺序个

  4. 循环等待也可以被打破, 也是从设计的角度去合理制定获取锁的策略

策略:

  1. 给每一个筷子都编一 个号

  2. 让每个哲学家都先拿编号小的筷子,再去拿编号大的筷子

  3. 吃一口面之后把筷子再放回去,让别的哲学家再去获取筷子

过程:

  1. 从1号到4号哲学家都拿到了最小编号的筷子

  2. 当5号哲学家拿编号小的筷子时,发现筷子已经被1号哲学家拿走了,那么他就要阻塞等待

  3. 由于5号哲学家不能拿编号小的筷子,也就意味着无法获取到编号大的筷子

  4. 4号哲学家就可以拿到5号筷子,进行就餐,就完餐之后需要把所有的筷子放回原位

  5. 3 - 1号哲学家就可以拿起上-一个哲学家放下的编号大的筷子进行就餐

  6. 随着1号哲学家就完餐放下了5号哲学家需要的编号小的1号筷子,这时就可以先拿编号小的筷子再拿编号:大的筷子进行就餐 解决了死锁问题

+++

面试题:

  1. 你知道线程与进程的区别吗?

  2. 线程的创建方式有几种?

  3. Runnable与Callable的区别?

  4. JDK提供的线程池有几种?

  5. 手动创建线程池时ThreadPoolExcutor有多少个参数,以及各参数的含义?

  6. 线程池的拒绝策略有哪些?

  7. 请你描述一下线程池的 工作流程?

  8. 说一下什么是线程安全问题?

  9. 怎么解决线程安全问题?

  10. Synchronizedvolatile的作用与区别?

  11. JMM的特性?

  12. Synchronized锁升级的过程?

  13. 什么是偏向锁,轻量级锁,重量级锁?

  14. 介绍一个CAS, 以及ABA问题?

  15. ReentranLock特性?和synchronized的区别?

  16. JUC包下的工具类知道哪些?

  17. 线程安全的集合类有哪些?

  18. 用过ConcurrnetHashMap吗?介绍一下?

  19. 造成死锁的原因?以及解决办法?

++++

相关推荐
Lxinccode1 小时前
Java查询数据库表信息导出Word-获取数据库实现[1]:KingbaseES
java·数据库·word·获取数据库信息·获取kingbasees信息
元亓亓亓2 小时前
Java后端开发day36--源码解析:HashMap
java·开发语言·数据结构
sd21315122 小时前
RabbitMQ 复习总结
java·rabbitmq
豆沙沙包?2 小时前
5.学习笔记-SpringMVC(P61-P70)
数据库·笔记·学习
每次的天空4 小时前
Android学习总结之Room篇
android·学习·oracle
码银4 小时前
Java 集合:泛型、Set 集合及其实现类详解
java·开发语言
东阳马生架构4 小时前
Nacos简介—4.Nacos架构和原理
java
一只叫煤球的猫5 小时前
你真的会用 return 吗?—— 11个值得借鉴的 return 写法
java·后端·代码规范
Nuyoah.5 小时前
《Vue3学习手记5》
前端·javascript·学习
颇有几分姿色5 小时前
Spring Boot 读取配置文件的几种方式
java·spring boot·后端