跳至主要內容

①JAVA 并发理论基础

holic-x...大约 51 分钟JAVA基础

①JAVA 并发理论基础

学习核心

  • 多线程编程及常见问题
    • 1)为什么要使用并发编程?
    • 2)并发编程有什么缺点?
    • 3)并发的根源有什么区别?
    • 4)并行和并发的区别?
  • 并发&并行
  • Java内存模型(JMM)
    • 什么是Java内存模型
  • 关键字:synchronized、volatile、final
    • synchronized
      • 1)项目中如何使用Synchronized关键字(Synchronized最常见的三种使用方式)
      • 2)Synchronized的底层实现原理
      • 3)Synchronized可重入的原理
      • 4)多线程中Synchronized锁升级的原理
    • volatile
      • 1)Volatile关键字的作用
      • 2)Volatile能使得一个非原子操作变成原子操作吗?
      • 3)Volatile变量和atomic原子类变量有什么不同?
    • final
      • 1)final关键字有哪些用法?
      • 2)所有的final修饰的字段都是编译期常量吗?

学习资料

Java内存模型(JMM)

1.理解线程之间的通信和同步

​ 并发编程主要就是在处理两个问题:线程之间的通信和线程之间的同步

线程之间的通信

​ 通信是指线程之间应该如何交换信息,主要有两种机制:共享内存和消息传递

  • 共享内存通信:
    • 指线程A和B有共享的公共数据区,线程A写数据,线程B读数据,这样就完成了一次隐式通信

(PS:==如何理解此处的"隐式"概念?==显式就是一发一接,发送方可以知道接收方是什么时候接收到数据(同步概念);隐式更像是一种异步概念,例如快递员将快递放在菜鸟驿站,用户什么时候取快递对快递员来说不知道也不需要关心)

  • 消息传递通信:
    • 指线程之间没有公共数据,需要线程间显示的直接发送消息来进行通信

​ **Java主要采用的是第一种共享内存的方式,**所以线程之间的通信对于开发人员来说都是隐式的,如果不理解这套工作机制,可能会碰到各种奇奇怪怪的内存可见性问题。针对消息传递通信方式,Java中的BlockingQueue、SynchronousQueue、Exchanger等可以看作是消息传递通信方式

线程之间的同步

​ 同步是指一种用来控制不同的线程之间操作发生相对顺序的机制,同步需要程序员显式的定义,主要是指定一个方法或者一段代码需要在线程之间互斥执行。(Java提供了很多用来做同步的工具,比如Synchronized、Lock等)

2.Java内存模型的抽象结构

理解JMM如何通过控制主内存和每个线程的本地内存之间的交互来提供内存可见性保证

​ 在 java 中,所有实例域、静态域和数组元素存储在堆内存中,堆内存在线程之间共享,共享变量才会有内存可见性问题,所以会受到内存模型的影响。而局部变量,方法定义参数和异常处理器参数不会在线程之间共享,不会有类似问题。 ​ Java 线程之间的通信由 Java 内存模型(JMM,可以理解为一套规则)控制,JMM 决定一个线程对共享变量的写入何时对另一个线程可见

​ 从抽象的角度来看,JMM 定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存(main memory)中,每个线程都有一个私有的本地内存,本地内存中存储了该线程以读/写共享变量的副本。本地内存是 JMM 的一个抽象概念,并不真实存在。它涵盖了高速缓存,写缓冲区,寄存器以及其他的硬件和编译器优化。

​ Java 内存模型的抽象示意图如下:

image-20240526075445714

​ 基于共享内存的通信:如果线程A和线程B之间要通信的话,需要经历下述步骤

  • 线程A将本地内存A中更新后的共享变量刷新到主内存中
  • 线程B从主内存中读取线程A之前已经更新过的共享变量

image-20240526082750512

​ 结合图示分析,线程A和B都有主内存中共享变量x分副本,假设初始时三个内存中的x值都为0,希望线程A更新的值对线程B可见,则通过JMM控制

  • 线程A更新x值(设置为1),将其更新后的x值存放在本地从内存A
  • 当线程A、线程B需要通信时,线程A会将本地内存A的值更新到主内存,此时主内存x值为1
  • 随后线程B再从主内存中读取x值(此时的x值为线程A更新后的x值),然后再将x值同步到本地内存B,此线程B的本地内存B中的x值也被更新为1

​ 从整体上看,这个过程实际上就是线程A向线程B发送消息,而这个通信过程必须要经过主内存。而JMM则是通过控制主内存和每个线程本地内存的交互来提供内存可见性的保证。

​ 但也是因为这个机制的存在,可能导致并发场景下因为"本地内存"的存在导致的可见性问题,例如并发场景下线程A随后更新了本地内存但还没刷新到主内存,此时线程B去读取主内存的数据是没有被更新的数据,这就导致数据和预期的不一致产生的可见性问题。

3.重排序

​ 在程序执行时,为了提高性能,编译器和处理器常常会对指令做重排序。主要有三种:编译器优化的重排序,指令级并行重排序,内存系统重排序。

image-20240526084155252

​ 上述1为编译器重排序,2、3为指令重排序,这些重排序都可能会导致多线程程序出现内存可见性问题

​ 对于编译器,JMM 的编译器重排序规则会禁止特定类型的编译器重排序(不是所有的编译器重排序都要禁止)。

​ 对于处理器重排序,JMM 的处理器重排序规则会要求 java 编译器在生成指令序列时,插入特定类型的内存屏障指令,通过内存屏障指令来禁止特定类型的处理器重排序(不是所有的处理器重排序都要禁止)

重排序问题出现概率不高很难通过案例观察,但可以从一些实际案例中理解这个概念:例如单例模式的懒汉式优化(双检锁)案例中引入volatile关键字对实例进行修饰,就是为了避免重排序的影响

案例分析:结合单例模式中【双检锁】案例中的重排序问题

// 结合单例模式中的【双检锁】
class Singleton{

    // 定义静态、私有的自己的对象
    private static Singleton instance = null;

    // 定义额外的变量
    private String descr;

    // 构造函数私有化
    private Singleton(){}

    // 对外提供公共方法获取实例(懒汉式)
    public static Singleton getInstance(){
        if(instance == null){
            synchronized (Singleton.class){
                if(instance == null){
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

public class ReorderDemo {
    public static void main(String[] args) {
        // 模拟多线程获取单例对象
        final int threadSize = 500;
        for(int i = 0; i < threadSize; i++){
            new Thread(new Runnable(){
                @Override
                public void run(){
                    String threadName = Thread.currentThread().getName();
                    Singleton instance = Singleton.getInstance();
                    System.out.println("线程 " + threadName + "\t => " + instance.hashCode());
                }
            }).start();
        }
    }
}

如何理解此处重排序的影响?则需要进一步拆解 instance = new Singleton();,如果类中还有有其它的属性也需要实例化,则除了要实例化单例类本身,还需要对其它属性也进行实例化

  • 步骤1:给 Singleton分配内存
  • 步骤2:调用 Singleton的构造函数来初始化成员变量
  • 步骤3:将 Singleton对象指向分配的内存空间(执行完这步 instance 就为非 null )

​ 如果虚拟机发生了重排序优化,这个时候 3 可能发生在步骤 2 之前。此时设想一种情况,线程A完成步骤3但是步骤2还没有进行的时候(也就是说这个时候成员变量还没有被初始化),此时另一个线程B进行了第一次判断(因为线程A已经完成步骤3,此时对象判断为非null)的结果是非null(直接返回对象使用),但这个时候属性的构造并没有完成,如果线程B又进一步使用了某个属性就会导致异常。此处Synchronized 只能保证可见性、原子性,无法保证执行的顺序。

​ 基于这种情况,就体现出 Happens-Before 规则的重要性了。通过字面意思,可能会误以为是前一个操作发生在后一个操作之前,但其真正的含义是:前一个操作的结果可以被后续的操作获取Happens-Before 规则规范了编译器对程序的重排序优化

​ volatile 关键字可以保证线程间变量的可见性,简单地说就是当线程 A 对变量 X 进行修改后,在线程 A 后面执行的其它线程就能看到变量 X 的变动。除此之外,volatile 在 JDK1.5 之后还有一个作用就是阻止局部重排序的发生,也就是说,volatile 变量的操作指令都不会被重排序

​ 即在原有的版本基础上,将instance用volatile关键字修饰,确保其在JVM加载的时候其操作指令不会被重排序,进而规避了前面“JVM重排序导致的异常问题”

补充说明:只有在很低版本的Java才会出现这个重排序问题。在Java的高版本中,已经不需要增加volatile来禁止类重排序。因为高版本的 Java 已经在 JDK 内部实现中解决了这个问题(解决的方法很简单,只要把对象 new 操作和初始化操作设计为原子操作,就自然能禁止重排序)。

4.happen-before规则

理解happen-before规则的作用

​ 基于上面对重排序概念,会发现它是一个双刃剑:

  • 重排序一方面是会提升程序性能,应该支持
  • 有的重排序会导致程序出问题,应该禁止

​ 那到底哪些应该禁止,哪些应该支持呢?这个事情如果让Java程序员自己来分析,明显是很困难的。所以这个工作就由JMM承担了。JMM定义了一套规则,叫做happen-before规则。这套规则一方面给程序员的承诺,一方面是对编译器和处理器的约束。JMM承诺程序员基于这套规则编程,即便不理解重排序,程序也不会因为发生了重排序出问题,也不会出现内存可见性问题。而另外一方面,Java平台在具体实现的时候,有了这套规则的也就知道了禁止重排序应该禁止到什么程度,比如有些重排序并不会打破这套规则,也并不会改变程序的执行结果,那就应该支持。

​ 例如上述【双检锁】单例模式的案例中则是引入happen-before机制来规避JVM重排序导致的异常问题,其解决方案就是通过volatile关键字来约束实例的定义

happen-before规则

  • 单一线程顺序规则:在一个线程内,在程序前面的操作happen-before于后面的操作
  • 监视器锁规则:对一个锁的解锁操作happen-before于后面对同一个锁的加锁操作
  • volatile 变量规则:对一个 volatile 变量的写操作happen-before于后面对这个变量的读操作
  • 传递性规则:如果A happen-beforeB,Bhappen-beforeC,则Ahappen-before C
  • 线程start()规则:如果线程A执行操作ThreadB.start(),那么A线程的ThreadB.start()操作happen-before于线程B中的任意操作
  • join规则:如果线程A执行操作ThreadB.join()并成功返回,那么B线程内的任意操作happen-before于线程A从ThreadB.join()操作成功返回
  • 线程中断规则:对线程 interrupt()方法的调用happen-before于被中断线程的代码检测到中断事件的发生,可以通过interrupted()方法检测到是否有中断发生
  • 对象终结规则:一个对象的初始化完成(构造函数执行结束)happen-before于它的 finalize()方法的开始。

理论基础

1.为什么需要多线程?

更多的处理器核心引入多线程充分利用多核优势,提升程序执效率

​ 现在的处理器上普遍都有多个处理核心,比以往更加擅长并行计算。线程是一个处理器的最小调度单元,而一个线程同一时间也只能运行在一个处理器核心上

​ 场景分析:如果是单线程,那同一时间只会有一个处理核心被占用,其他的都是浪费掉的,处理器有再多的核心也没办法提升程序执行效率。相反如果线程使用多线程技术,那并行的执行这些线程就可以充分利用多核优势,让程序执行更快。

更快的响应速度:一个任务可能会拆分为多个子任务,引多线程异步执行,让多个子任务可以异步执行,提升响应效率

​ 场景分析:例如一笔订单的创建,用户点击订购按钮之后,后端需要完成包括”插入订单数据”,“生成订单快照”,“发送邮件通知卖家”和“记录订单货品销售数量”等,如果这些操作在一个单线程完成,那么用户需要等待这些操作串行执行完之后,才能获得返回响应,即”订购“成功。 但其中部分操作是一致性要求不高,例如生成订单快照,发送邮件等,这些操作完全可以单独放入一个线程,异步执行。这样就只需要完成”插入订单数据”,“记录订单货品销售数量”完成之后就告诉用户订购成功,缩短用户响应时间,体验会更好。

并发编程的缺点

​ 并发编程的目的就是为了能提高程序的执行效率,提高程序运行速度,但是并发编程并不总是能提高程序运行速度的,而且并发编程可能会遇到很多问题,比如:内存泄漏、上下文切换、线程安全、死锁等问题。

2.Java多线程并发不安全指什么?

​ 多线程并发不安全:如果多个线程对同一个共享数据进行访问而不采取同步操作的话,那么操作的结果是与预期不一致的

案例分析:模拟多个线程执行自增操作

​ 多个线程同时ThreadUnsafeOperator对象的count属性执行自增操作,操作结束之后它的值有可能小于 500(且每次运行的结果可能都不一样)

​ 500 个线程同时对 cnt 执行自增操作,操作结束之后它的值有可能小于 500。(其中用到了线程池来创建线程;CountDownLatch是个并发工具类,用来保证线程池线程完成500次累加)

class ThreadUnsafeOperator{

    // 定义一个共享数据
    private static int count = 0;

    // 对外提供数据操作方法
    public void add(){
        count ++;
    }

    // 对外提供数据访问方法
    public int get(){
        return count;
    }

}

/**
 * 多线程并发不安全
 */
public class ThreadUnsafeDemo {
    // 通过实现Runnable创建线程模拟多线程操作
    public static void modOpByRunnable(){
        // 定义操作对象
        ThreadUnsafeOperator op = new ThreadUnsafeOperator();
        final int threadSize = 500;
        // 模拟500个线程执行自增操作
        for(int i =0;i<threadSize;i++){
            int finalI = i;
            new Thread(new Runnable(){
                @Override
                public void run() {
                    // 调用方法执行自增操作
                    System.out.println("线程" + finalI + Thread.currentThread().getName());
                    op.add();
                }
            }).start();
        }
        // 获取最终count结果
        System.out.println("count:"+op.get());
    }
    
    // 借助并发工具类CountDownLatch(保证线程池完成500次累加)
    public static void modOpByCountDownLatch() throws InterruptedException {
        // 定义操作对象
        ThreadUnsafeOperator op = new ThreadUnsafeOperator();
        final int threadSize = 500;
        final CountDownLatch countDownLatch = new CountDownLatch(threadSize);
        ExecutorService executorService = Executors.newCachedThreadPool();
        // 模拟500个线程执行自增操作
        for(int i =0;i<threadSize;i++){
            executorService.execute(()->{
                // 调用方法执行自增操作
                op.add();
                countDownLatch.countDown();
            });
        }
        countDownLatch.await();
        executorService.shutdown();
        System.out.println("程序执行了" + cur[0] + "次");
        // 获取最终count结果
        System.out.println("count:"+op.get());
    }

    public static void main(String[] args) {
        // modOpByRunnable();
        modOpByCountDownLatch();
    }
}

3.Java多线程并发出现问题的根源

​ 基于上述案例,思考一个问题:为什么输出不是500?并发出现问题的根源是什么?

并发出问题的根源

  • 缓存(本地内存)导致的可见性问题:使用多线程之间同步synchronized或者lock(锁)
  • 线程切换(分时复用)导致的原子性问题:使用synchronized、volatile、lock
  • 编译优化带来的有序性问题:基于Happens-Before规则解决

😒可见性问题

​ 可见性指一个线程对共享变量的修改,另外一个线程能够立刻看到

可见性问题:"线程本地内存"引起(本地内存概念:参考Java内存模型(JMM))

案例分析

// 线程1执行代码
int i=1;
i=5;

// 线程2执行代码
j=i;
if(j==5){
    // 操作A
}else{
    // 操作B
}

​ 假设CPU1执行线程1、CPU2执行线程2,依次分析执行

  • 线程1执行int i=1,主内存中存储i的值为1
  • 假设当线程1执行i=5,会先将i的初始值加载到线程的本地内存中,然后赋值为5,却没有立刻写入到主内存中
  • 随后CPU2执行线程2:j=i,它会先去主内存读取i的值并加载到线程2的本地内存,此时主内存的i值还没有被更改(还是为1),执行完成j的值为1(而不是5)

​ 基于上述案例:线程1对变量i进行修改之后,线程2没有立即看到线程1修改的值,而是拿到错误的数据执行后续的操作,就可能会带来程序错误,这便是可见性问题引发的Java多线程并发问题。

😒原子性问题

​ 原子性指的是一个或多个操作,要么全部执行并且执行的过程不会被任何因素打断,要么都不执行

原子性问题:"分时复用"引起

案例分析

int x=1;

// 线程1执行
x += 1;

// 线程2执行
x += 1;

x += 1;需要执行3条CPU指令

  • 指令1:将变量x从内存中读取到CPU寄存器
  • 指令2:在CPU寄存器中执行x+1操作
  • 指令3:将最后的结果x写入内存

​ 由于CPU分时复用(线程切换)的存在,线程1执行了“第一条指令后,就切换到线程2执行,假如线程2执行了这三条指令后,再切换回线程1执行后续两条指令(此时的x值是通过指令1读取到的1),将造成最后写到内存中的i值是2而不是预期的3。

​ 这也就是上面线程不安全演示程序中,累加500次,最终只有少于500次的原因。即因CPU分时复用(线程切换)的存在,导致原子性问题(一个完整的操作被打断)

😒有序性问题

​ 有序性:程序执行的顺序应当按照代码的先后顺序执行

有序性问题:指令重排序引起

int i=0;
boolean flag = false;
i = 1; // 语句1
flag = true; // 语句2

​ 上述代码含义:定义了一个int型变量,定义了一个boolean类型变量,然后分别对两个变量进行赋值操作。

​ 从代码顺序上看,语句1是在语句2前面的;但是JVM在真正执行这段代码的时候可能会发生指令重排序(InstructionReorder),从而导致无法保证语句1一定会在语句2前面执行。

​ 在执行程序时为了提高性能,编译器和处理器常常会对指令做重排序重排序分三种类型:

(1)编译器优化的重排序:编译器在不改变单线程程序语义的前提下可以重新安排语句的执行顺序

(2)指令级并行的重排序:现代处理器采用了指令级并行技术(Instruction-Level Parallelism,ILP)来将多条指令重善执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序

(3)内存系统的重排序:由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行

​ 从 java 源代码到最终实际执行的指令序列,会分别经历上面三种重排序:上述的(1)属于编译器重排序,(2)、(3)属于处理器重排序。

导致的问题:这些重排序都可能会导致多线程程序出现内存可见性问题。所以如果不采取手段,利用一些]ava提供的可见性问题处理工具(例如 volatile,锁等)加以控制,任由编译器和处理器进行指令重排序,程序就会运行出错。

为啥不全部禁止重排序呢?:重排序本身是需要用来提升性能的,全部禁止也不行。所以Java的内存模型实现的时候需要进行取舍,哪些重排序需要禁止,哪些不需要。

4.Java如何解决并发问题

​ JMM理解为,Java 内存模型规范了JVM 如何提供按需禁用缓存和编译优化的方法。具体来说,这些方法包括:

  • volatile、synchronized 和 final 三个关键字
  • Happens-Before 规则

如果正确利用这里的这些JMM方法,可以有效解决不具备原子性,可见性,顺序性的问题

可见性问题解决

  • volatile关键字可以保证可见性
  • 通过synchronized和Lock也能够保证可见性,synchronized和Lock能保证同一时刻只有一个线程获取锁然后执行同步代码,并且在释放锁之前会将对变量的修改刷新到主存当中。因此可以保证可见性。

原子性问题解决

  • Java保证对基本数据类型的变量的读取和赋值操作是原子性操作
  • 如果要实现更大范围操作的原子性,可以通过synchronized和Lock来实现。由于synchronized和Lock能够保证任一时刻只有一个线程执行该代码块,从而保证了原子性

有序性问题解决

  • 可以通过volatile关键字来保证一定的“有序性”
  • 通过synchronized和Lock来保证有序性,很显然synchronized和Lock保证每个时刻是有一个线程执行同步代码相当于是让线程顺序执行同步代码,自然就保证了有序性
  • JMM是通过Happens-Before 规则来保证有序性的

5.理解线程之间的上下文切换

​ CPU通过时间片分配算法来循环执行线程任务,当前任务执行一个时间片后会切换到下一个线程任务。但是,在切换前会保存上一个任务的状态,以便下次切换回这个任务时,可以再加载这个任务的状态。所以任务从保存到再加载的过程就是一次上下文切换

​ 这就像同时读两本书,当在读一本英文的技术书时,发现某个单词不认识,于是便打开中英文字典,但是在放下英文技术书之前,大脑必须先记住这本书读到了多少页的第多少行,等查完单词之后,能够继续读这本书。这样的切换是会影响读书效率的,同样上下文切换也会影响多线程的执行速度

​ 既然线程上下文切换会影响多线程的执行速度,在设计多线程并发程序的时候,减少线程之间的上下文切换会是一个优化方向。常用的减少上下文切换的手段包括:

无锁并发编程:多线程竞争锁时,会引起上下文切换,所以多线程处理数据时,可以用一 些办法来避免使用锁,如将数据的ID按照Hash算法取模分段,不同的线程处理不同段的数据

CAS算法:Java的Atomic包使用CAS算法来更新数据,而不需要加锁

使用最少线程:避免创建不需要的线程,比如任务很少,但是创建了很多线程来处理,这样会造成大量线程都处于等待状态

6.理解并行与并发

​ 并行:多个任务在不同的CPU上同时进行(同一时刻,多条指令在多个处理器上同时执行,是真正意义上的"同时进行")

​ 并发:多个任务在同一个CPU核上,按细分的时间片(交替)轮流执行(逻辑上看起来任务是同时执行);在同一时刻只能有一条指令执行,但多个进程指令被快速轮换执行(宏观上具有多个进程同时执行的效果,但其实只是把时间分为若干段,使多个进程快速交替执行)

image-20240525215807222

​ 串行:有n个任务,由一个线程按顺序执行。由于任务、方法都在一个线程执行所以不存在线程不安全情况,也就不存在临界区的问题。

关键字:synchronized、volatile、final

1.synchronized关键字

synchronized 基础概念

​ Java中的每一个对象都可以作为锁,具体表现为以下形式

  • 1)修饰实例方法:对于普通同步方法,锁是当前实例对象
  • 2)修饰代码块:对于同步方法块,锁是synchronized括号中指定的对象synchronized(xxx)
  • 3)修饰静态方法:对于静态同步方法,锁是当前类的Class对象

案例1:对于普通同步方法,锁是当前实例对象

// 1.对于普通同步方法
class SynchronizedMethod implements Runnable {

    @Override
    public void run() {
        method();
    }


    // 定义普通同步方法
    public synchronized void method(){
        System.out.println("线程" + Thread.currentThread().getName() + " start");
        try {
            Thread.sleep(2000);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
        System.out.println("线程" + Thread.currentThread().getName() + " end");
    }

    public static void main(String[] args) {
        // 创建两个线程模拟
        SynchronizedMethod sm = new SynchronizedMethod();
        Thread t1 = new Thread(sm);
        Thread t2 = new Thread(sm);
        t1.start();
        t2.start();
    }
}

// output
线程Thread-0 start
线程Thread-0 end
线程Thread-1 start
线程Thread-1 end
    
// 也可模拟多个线程测试看看执行效果
// 模拟多个线程执行
final int threadSize = 100;
SynchronizedMethod sm = new SynchronizedMethod();
for (int i = 0; i < threadSize; i++) {
    new Thread(sm).start();
}

​ 上述代码执行结果:一个线程执行完成后下一个线程才会执行

  • 代码分析
    • synchronized定义普通同步方法,默认锁的是当前实例对象,也就是main中的SynchronizedMethod对象sm
    • 而两个线程使用的是同一个sm(实例对象),所以线程会在此处竞争锁,竞争成功的线程运行完成之后下一个线程才会执行

案例2:对于同步方法块,锁是synchronized括号中指定的对象synchronized(xxx)

​ 同步代码块:就是定义一组原子操作,在这个原子代码块中一次只允许一个线程进入,只有当前线程运行结束才能允许下一个线程进入

​ 针对同步代码块的锁可以有多种形式:

  • 1)对象锁:this指代当前对象
  • 2)类锁:类名.class
  • 3)任意对象锁:Object对象
  • 4)锁字符串常量:区分new String("haha")、"haha"
# 1.对象锁:this当前对象
public void show(String str)
{
    synchronized (this) {
        for(int i = 0;i<str.length();i++)
        {
            System.out.print(str.charAt(i));
        }
        System.out.println();
    }
}

# 2.类锁:锁类的字节码
public void show(String str)
{
    synchronized (ShowString.class) {
        for(int i = 0;i<str.length();i++)
        {
            System.out.print(str.charAt(i));
        }
        System.out.println();
    }
}

# 3.任意对象锁
//obj要定义在方法体外,否则失效
Object obj = new Object();
public void show(String str)
{
    synchronized (obj) {
        for(int i = 0;i<str.length();i++)
        {
            System.out.print(str.charAt(i));
        }
        System.out.println();
    }
}	

# 4.锁字符串常量
public void show(String str)
{
    // new String("xx"):无效锁定;"xx":显示正常
    synchronized ("xx") {
        for(int i = 0;i<str.length();i++)
        {
            System.out.print(str.charAt(i));
        }
        System.out.println();
    }
}
// 2.对于同步方法块
class SynchronizedBlock implements Runnable {

    // 指定对象
    Object lockTarget = new Object();

    @Override
    public void run() {
        // 同步方法块
        synchronized (lockTarget){
            System.out.println("线程" + Thread.currentThread().getName() + " start");
            try {
                Thread.sleep(2000);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            System.out.println("线程" + Thread.currentThread().getName() + " end");
        }
    }

    public static void main(String[] args) {
        // 模拟多个线程执行
        final int threadSize = 100;
        SynchronizedBlock sb = new SynchronizedBlock();
        for (int i = 0; i < threadSize; i++) {
            new Thread(sb).start();
        }
    }
}
  • 代码分析
    • 此处synchronized锁指定对象(lockTarget)
    • 这些线程会在synchronized(lockTarget)处竞争锁,目标是锁住lockTarget,竞争成功的线程运行完成之后才会执行
    • 此处锁指定对象还有其他多种形式实现

案例3:对于静态同步方法,锁是当前类的Class对象

public static synchronized void method(){
    ......
}

public static void main(String[] args) {
    // 模拟多个线程执行
    SynchronizedStaticMethod ssm1 = new SynchronizedStaticMethod();
    SynchronizedStaticMethod ssm2 = new SynchronizedStaticMethod();
    new Thread(ssm1).start();
    new Thread(ssm2).start();
}

// output
线程Thread-0 start
线程Thread-0 end
线程Thread-1 start
线程Thread-1 end
  • synchronized用在静态方法上,默认的锁就是当前实例对应的Class类
  • 这些线程会在静态方法入口处尝试锁住实例对应的类Class。因为这两个实例ssm1、ssm2对应的类Class是同一个,所以线程1、2都会竞争一把锁。竞争成功的线程运行完成之后下一个线程才会执行

思考:如果去掉static,此处的运行效果是如何?为什么会出现这种情况?

​ 如果去除掉static,则程序执行结果会变成下述结果:因为去掉static,这种情况就回归案例1,锁的是当前对象,但是因为线程1、2指向的对象并不一样,因此不会竞争一把锁(也就是两个线程没有竞争)

线程Thread-0 start
线程Thread-1 start
线程Thread-1 end
线程Thread-0 end

​ 线程从start到最终运行中间还有一些调度流程,大部分情况下可能是先start的先执行,但是这点无法保证,因为这个调度流程中间的影响因素有很多。在此处最直观的运行效果可以看到虽然是线程2后启动但是也是它先执行完(运行更快)

synchronized 原理分析

​ 实现同步是synchronized关键字最重要的功能,加锁、解锁就是一个同步完成的过程。

​ 围绕这个功能,synchronized还有其他一些重要的特性,思考这些问题的同时去理解synchronized的原理

  • synchronized的底层语义
  • synchronized的可重入原理
  • 加锁解锁过程为什么保证了可见性
  • 在不同并发激烈程度下,加锁解锁的背后实现不同(涉及锁升级的过程)

扩展学习资料:synchronized底层如何实现?什么是锁的升级、降级?open in new window

✨synchronized的底层实现原理

​ Synchronized的语义底层是通过一个monitor(监视器锁)的对象来完成,每个对象有一个监视器锁(monitor)。每个Synchronized修饰过的代码当它的monitor被占用时就会处于锁定状态,当同步代码执行完毕,释放monitor后,其他线程重新尝试获取monitor的所有权 ,过程分析如下(结合图示分析):

(1)如果monitor的进入数为0,则该线程进入monitor,然后将进入数设置为1,该线程即为monitor的所有者

(2)如果线程已经占有该monitor,只是重新进入,则进入monitor的进入数加1

(3)如果其他线程已经占用了monitor,则该线程进入阻塞状态,直到monitor的进入数为0,再重新尝试获取monitor的所有权

1)加锁和释放锁的原理

案例分析:使用synchronized实现同步块和同步方法

​ 通过观察生成的class文件信息来分析实现细节

# 编译LockDemo.java文件,生成LockDemo.class
javac -encoding UTF-8 .\LockDemo.java
    
# 查看生成的class文件
javap -v .\LockDemo.class

image-20240526110319274

​ 结合上述class信息进行分析:

  • 同步块:使用monitorenter、monitorexit指令
  • 同步静态方法:使用方法修饰符上的ACC_SYNCHRONIZED

​ 无论是上述哪种方式,本质上是对一个对象的监视器(monitor)进行获取,这个获取过程是排他的。即同一时刻只能有一个线程获取到由synchronized所保护对象的监视器。

如何理解这个排他?:CAS或者操作系统的互斥机制都能保证只有一个线程获取成功

​ 通过一个图来展示线程、对象Object、监视器Monitor、同步队列SynchronizedQueue之间的关系

​ 图中可以看到,任意线程对Object(Object由synchronized保护)的访问,首先要获得 Object的监视器Monitor。如果获取失败,线程进入同步队列,线程状态变为BLOCKED。当访问Object的前驱(获得了锁的线程)释放了锁,则该释放操作唤醒阻塞在同步队列中的线程,使其重新尝试对监视器的获取。

image-20240526144327167

​ 其流程分析如下所示:

  • 线程通过Monitor.Enter方法尝试获取Object的监视器Monitor

    • 获取成功:获得锁的线程,执行操作完毕,释放线程锁
    • 获取失败:线程进入同步队列,线程状态变为BLOCKED
  • 如果访问Object的前驱(获得锁的线程)释放了锁,则该释放操作会唤醒阻塞在同步队列中的线程,使其重新尝试对监视器的获取

2)可重入的实现原理

synchronized对于一个对象加锁后是可以重入的(重入锁是指一个线程获取到该锁之后,该线程可以继续获得该锁),就是说同一个线程可以反复给该对象加锁,且并不会因为前一次加的锁还没有释放而阻塞

​ 原理:Synchronized加锁的对象拥有一个monitor计数器,当线程获取该对象锁后,monitor计数器就会加1,释放锁后就会将monitor计数器减1。所以同一个线程反复对该对象加锁时,只会引起monitor计数器加1,并不会触发Monitor.Enter失败的流程,即线程不会被阻塞。释放锁也是一样,每触发一次释放操作锁monitor计数器会减1,当最终monitor计数器重新减为0之后,才真正表示释放了锁。

// 2.可重入的实现原理
public class ReentrantDemo {

    private synchronized void method1() {
        System.out.println(Thread.currentThread().getName() + ":method1()");
        method2();
    }

    private synchronized void method2() {
        System.out.println(Thread.currentThread().getName() + ":method2()");
        method3();
    }

    private synchronized void method3() {
        System.out.println(Thread.currentThread().getName() + ":method3()");
    }

    public static void main(String[] args) {
        ReentrantDemo demo = new ReentrantDemo();
        demo.method1();
    }
}
// output
main:method1()
main:method2()
main:method3()
  • 执行monitorenter获取锁
    • (monitor计数器=0,可获取锁)
    • 执行method1()方法,monitor计数器+1=》1(获取到锁)
    • 执行method2()方法,monitor计数器+1=》2
    • 执行method3()方法,monitor计数器+1=》3
  • 执行monitorexit释放锁
    • method3()方法执行完,monitor计数器-1=》2
    • method2()方法执行完,monitor计数器-1=》1
    • method1()方法执行完,monitor计数器-1=》0(释放了锁)
    • (monitor计数器=0,锁被释放了)

​ 分析上述原理,创建ReentrantDemo对象demo,调用method1方法,此时获取到锁(这个锁是在普通方法上定义的锁,锁的是当前对象),然后再在方法中调用method2(method2也是在普通方法上定义的锁,锁的是当前对象),即当前这个线程又对当前对象进行加锁,因此monitor计数器+1,以此类推调用method3也是对当前同一个对象加锁,monitor计数器+1,最终monitor计数器为3

​ 当方法执行完成,执行monitorexit释放锁,则monitor计数器依次-1,最终monitor计数器为0,所有锁都被释放了

3)保证可见性的原理-锁的内存语义

锁的happens-before关系

​ Synchronized的happens-before规则,即监视器锁规则:对同一个监视器的解锁,happens-before于对该监视器的加锁。

// 3.保证可见性的原理-锁的内存语义
public class VisibilityDemo {

    private int a=0;

    public synchronized void writer(){
        a++;
    }
    public synchronized void reader(){
        int i=a;
    }

}

​ 该代码的happens-before关系如图所示:

image-20240526163730371

​ 在图中每一个箭头连接的两个节点就代表之间的happens-before关系:

  • 黑色的是通过程序顺序规则推导出来
  • 红色的为监视器锁规则推导而出:线程A释放锁happens-before线程B加锁
  • 蓝色的则是通过程序顺序规则和监视器锁规则推测出来happens-before关系,通过传递性规则进一步推导的happens-before关系

​ 重点关注2 happens-before 5,通过这个关系可以得出什么?

​ 根据happens-before的定义中的一条:如果A happens-before B,则A的执行结果对B可见,并且A的执行顺序先于B。线程A先对共享变量A进行加一,由2 happens-before 5关系可知线程A的执行结果对线程B可见即线程B所读取到的a的值为1。

锁的内存语义:

  • 当线程释放锁时:JMM会把该线程对应的本地内存中的共享变量刷新到主内存中
  • 当线程获取锁时:JMM会把该线程对应的本地内存置为无效(无效的意思是线程不能继续使用本地内存中的无效数据,需要从主内存中读取最新值)

锁的优化(锁升级与对比)

✨多线程中synchronized锁升级的原理是什么?

​ 在锁对象的对象头里面有一个 threadid 字段,在第一次访问的时候 threadid 为空,jvm 让其持有偏向锁,并将 threadid 设置为其线程 id,再次进入的时候会先判断 threadid 是否与其线程 id 一致,如果一致则可以直接使用此对象,如果不一致,则升级偏向锁为轻量级锁,通过自旋循环一定次数来获取锁,执行一定次数之后,如果还没有正常获取到要使用的对象,此时就会把锁从轻量级升级为重量级锁,此过程就构成了 synchronized 锁的升级。

无锁状态:对象刚创建时,没有任何线程来竞争锁,此时是无锁状态

偏向锁:当第一个线程尝试获取锁时,JVM 会将锁升级为偏向锁,并将该线程的ID 记录到对象的 Mark Word 中。这样,只要该线程再次尝试获取锁,JVM 就会检査 Mark Word 中的线程 ID 是否与当前线程 ID 相同,如果相同则直接获得锁,无需进行任何同步操作。这种策略对于只有一个线程频繁访问的场景非常高效

轻量级锁:如果有多个线程交替地访问同一个对象,偏向锁就会失效。此时,JVM会尝试将锁升级为轻量级锁。轻量级锁使用了一种称为“自旋等待”的策略,即当线程尝试获取锁但失败时,它会进入一个循环,不断地尝试再次获取锁,而不是立即阻塞。这种策略对于短时间的锁竞争非常有效,可以避免线程频繁地阻塞和唤醒

重量级锁:如果自旋等待持续较长时间仍未能获取锁,JVM 就会将锁升级为重量级锁。这时,线程的竞争会变得非常激烈,JVM 会使用操作系统的互斥量(如互斥锁或条件变量)来实现同步。线程的阻塞和唤醒会涉及到操作系统的内核态和用户态切换,因此性能开销较大。

1)Java对象头

​ Synchronized用的锁是存在]ava对象头里面的,所以理解对象头的存储结构和存储数据的类型可以有助于对锁的理解

​ Java对象头中主要存储三类数据:

  • 第一类:Mark Word,主要存储对象的hashcode、分代年龄、锁信息等运行数据

  • 第二类:ClassPointer,指向方法区中该 class 的对象,JVM 通过此字段来判断当前对象是哪个类的实例

  • 第三类:数组的长度,就是如果当前对象是数组的话才会有

三类中,此处重点关注第一类Mark Word,它是理解锁的核心

锁状态25bit4bit
无锁状态对象的hashCode对象分代年龄

程序运行期间,Mark Word存储的信息会随着锁标志位的变化而变化,可能会变化为以下四种状态之一

image-20240526164605650

​ 看到这里,会发现无锁、偏向锁的“锁标志位”是一样的,即都是 01,这是因为无锁、偏向锁是靠字段“是否是偏向锁”来区分的,0 代表没有启用偏向锁,1 代表启用偏向锁,可以通过JVM参数(XX:UseBiasedLocking=true 默认)控制。并且启动偏向锁还有延迟(默认4秒),可以通过]VM参数(XX:BiasedLockingStartupDelay=0)关闭延迟。

2)锁的升级与对比

​ 基于上面对]ava对象头的理解,可以看出锁一共有4种状态,分别是:无锁,偏向锁,轻量级锁,重量级锁(Java SE 1.6开始,为了降低锁的获取与释放带来的性能消耗,才引入的“偏向锁”和“轻量级锁”)。这几个锁状态会随着并发竞争情况逐渐升级,锁只能升级不能降级,也就是说轻量级锁不能变回偏向锁,重量级锁不能变回轻量级锁。下面详细介绍下这几个锁,以及锁的升级过程。

  • 偏向锁

    ​ 在大多实际环境下,锁不仅不存在多线程竞争,而且总是由同一个线程多次获取,那么在同一个线程反复获取所释放锁中,其中并没有锁的竞争,那么这样看上去,多次的获取锁和释放锁带来了很多不必要的性能开销和上下文切换。所以引入了偏向锁来处理这种情况。 ​ 无锁、偏向锁的“锁标志位”是一样的(01),这是因为无锁、偏向锁是靠字段“是否是偏向锁”来区分的,0 代表没有启用偏向锁,1 代表启用偏向锁,可以通过JVM参数(XX:UseBiasedLocking=true 默认)控制。并且启动偏向锁还有延迟(默认4秒),可以通过JVM参数(XX:BiasedLockingStartupDelay=0)来关闭延迟。

  • 偏问锁加锁

    ​ 当一个线程A访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID,以后该线程在进入和退出 同步块时不需要进行CAS操作来加锁和解锁,只需简单地测试一下对象头的Mark Word里是否存储着指向当前线程A的偏向锁。

    ​ 如果测试成功,表示线程已经获得了锁。如果测试失败,则需要再测试一下Mark Word中偏向锁的标识是否设置成1(表示当前是偏向锁):

    • 如果设置了,则尝试使用CAS将对象头的偏向锁指向当前线程
    • 如果没有设置,则使用CAS竞多锁(即轻量级锁)

​ 例子:当大呆需要上WC时,只有它自己要上WC,此时并没有其它的人需要上WC,那么这时这个WC可以直接给大呆使用,并且大呆把可以标识自己身份的ID贴到门上,表示此时大呆占用了这个WC。

image-20240526165401918

  • 偏向锁撤销

​ 基于上述图示案例分析此时当前的WC被大呆所占用,这时二呆来了也要使用WC。这时大呆和二呆就要通过CAS的方式来抢占WC。因为此时锁的状态是偏向锁的状态,二呆来了也要使用WC(这时有两个人同时要使用WC,这时就要将偏向锁升级成轻量级锁),在升级轻量锁之前首先需要将WC上的标识大呆身份的ID撕下来(这一步叫做偏向锁的撤销)。

image-20240526165413394

  • 轻量级锁

​ 上面锁被撤销后,升级为了轻量级锁,轻量级锁状态下两个人需要通过过自旋+CAS的方式两个人来抢锁。当其中一个线程抢锁成功后,会将LR贴到WC的门上,表示WC当前被某个线程占用,然后另一个没有抢到锁的线程就一直自旋获取锁。

image-20240526170044913

​ LR的锁记录中存储的是对象的Mark Word的备份,即拷贝进入的,而两个线程竞争的过程就是通过CAS的方式将对象本来的Mark Word 位置存储的信息替换为指向自己LR记录的指针。谁替换成功了,谁就获得了锁,例如A成功了。那没有获取到锁的线程B,就再自旋一段时间(自旋的原因是因为B认为A很快就能执行完,我就在门口等一下,也就是B认为竞争没有那么激烈)。当自旋一段时间后,如果还没有获得锁,那B就只能将锁修改为重量级锁了,然后自己进入阻塞状态,等待A执行完之后唤醒。

image-20240526170801657

  • 重量级锁

​ 重量级锁,线程加锁失败会进入阻塞状态,等待前驱获得线程的锁执行完之后唤醒

优点缺点使用场景
偏向锁加锁和解锁不需要额外的消耗,和执行非同步方法相比仅存在纳秒级的差距如果线程间存在锁竞争,会带来额外的锁撤销的消耗适用于只有一个线程访问同步块的场景
轻量级锁竞争的线程不会阻塞,提高程序的响应速度如果始终得不到锁竞争的线程,使用自旋会消耗CPU追求 响应时间,同步块执行速度非常快
重量级锁线程竞争不使用自旋,不会消耗CPU线程阻塞,响应时间缓慢追求吞吐量,同步块执行速度较慢

2.volatile关键字

volatile关键字的作用

​ Java提供了volatile关键字来保证可见性和禁止指令重排。

  • volatile确保一个线程的修改对其他线程是可见的(基于其==内存语义(读写操作)==分析原理)
    • 当一个共享变量被volatile修饰时,它会保证修改的值立刻被更新到主内存中,当其他线程需要读取该值则回去内存中读取新值
      • :JMM会将该线程对应的本地内存的值同步刷新到主内存
      • :JMM会将该线程对应的本地内存置于无效,读取的是主内存的值

​ 从实践角度而言,volatile 的一个重要作用就是和 CAS 结合,保证了原子性,详细的可以参见 java.util.concurrent.atomic 包下的类,比如 AtomicInteger。

​ volatile 常用于多线程环境下的单次操作(单次读或者单次写)。

volatile能使一个非原子操作变成原子操作吗?

​ 关键字volatile的主要作用是使变量在多个线程间可见,但无法保证原子性,对于多个线程访问同一个实例变量需要加锁进行同步。

​ 虽然volatile只能保证可见性不能保证原子性,但用volatile修饰long和double可以保证其操作原子性。

volatile变量和atomic原子类变量有什么区别?

​ volatile 变量可以确保先行关系,即写操作会发生在后续的读操作之前,但它并不能保证原子性。例如用 volatile 修饰 count 变量,那么 count++ 操作就不是原子性的。

​ 而 AtomicInteger 类提供的 atomic 方法可以让这种操作具有原子性如getAndIncrement()方法会原子性的进行增量操作把当前值加一,其它数据类型和引用变量也可以进行相似操作。

volatile关键字作用

// 1.volatile关键字:可见性 案例分析(保证可见性的原理-volatile的内存语义)// 1.volatile关键字:可见性 案例分析
public class VisibilityDemo {
    
    private static boolean stop = false;

    public static void main(String[] args) throws InterruptedException {
        // thread A
        new Thread("Thread A"){
            @Override
            public void run() {
                while(!stop){ }
                System.out.println("3:"+Thread.currentThread().getName() + "停止了");
            }
        }.start();

        // thread main
        TimeUnit.SECONDS.sleep(1);
        System.out.println("1:主线程等待1s....");
        System.out.println("2:将stop遍历设置为true");
        stop = true;
    }
}
// output
1:主线程等待1s....
2:将stop遍历设置为true

​ 结合执行结果分析,当主线程将变量stop设置为true,但是线程A还是没有执行第3步

​ 但是程序一直没有结束,说明线程A还在while循环中,即线程A读到的变量值还是false,并没有读取到主线程对stop变量的更改值(true)

变更:尝试给stop变量增加volatile关键字

private static volatile boolean stop = false;

// output
1:主线程等待1s....
2:将stop遍历设置为true
3:Thread A停止了 

​ 结果分析:线程A正常执行第3步,说明main线程在将stop变量修改为true之后,线程A立马就读取到了修改值,这就是volatile修饰变量的作用

volatile:如果一个字段被声明为volatile,Java线程模型(JMM)确保所有线程看到这个变量的值是一样的。

​ 上面案例中导致问题的原因,就是主线程和线程A两个线程看到的stop变量值不一样(主线程看到的是stop=true,线程A看到的是stop=false),直到增加volatile关键字之后,他们才看到的是一样的true

案例误区分析:在不给stop变量指定volatile关键字,在while中打印内容,可看到子线程还是读到了修改后的stop值

public class VisibilityDemo {

    private static boolean stop = false;

    public static void main(String[] args) throws InterruptedException {
        // thread A
        new Thread("Thread A"){
            @Override
            public void run() {
                while(!stop){
                    System.out.println("==============");
                }
                System.out.println("3:"+Thread.currentThread().getName() + "停止了");
            }
        }.start();

        // thread main
        TimeUnit.SECONDS.sleep(1);
        System.out.println("1:主线程等待1s....");
        System.out.println("2:将stop遍历设置为true");
        stop = true;
    }
}

// output
.......
==============
==============
1:主线程等待1s....
2:将stop遍历设置为true
==============
3:Thread A停止了

​ 可参考问题分析:volatile可见性案例执行失败的原因分析open in new window

​ 可以看到上述执行结果,在没有添加volatile关键字修饰stop变量的时候,想通过在while中打印语句查看程序执行状态,最终却发现没有复现最开始的案例提出的可见性问题,呈现的结果是线程A在run方法中的某一个时刻感知到了stop的变化,于是终止了循环并执行步骤3,这和预设的场景不太一样。

考虑可能是因为线程上下文切换(产生了CPU中断)(println方法的特殊性),线程切换之后共享变量的值会被同步更新到内存中,因此线程A就会读取到主内存的stop值进而进一步判断跳出循环并执行下一步操作,就会出现没有添加volatile关键字修改也可见的现象

volatile原理分析

1)volatile可见性的实现

保证可见性的原理:volatile的内存语义

​ 参考JMM模型,可见性问题出现的原因是基于”本地内存“导致的。当多个不同的线程在处理器上执行的时候,会拥有各自的本地内存,存储的是对共享变量的副本。

image-20240526173610442

​ 上述案例中读取变量不一致的原因分析如下:

  • main更改了stop变量的值为true,但更改记录只存在于自己的本地内存中,并未刷新到主内存
  • 线程A读取stop变量的时候,读取的是自己的本地内存(stop=false),因此导致读取数据不一致

volatile 写-读的内存语义:

写volatile变量:JMM将该线程对应的本地内存中的共享变量值刷新到主内存

读volatile变量:JMM会将该线程对应的本地内存置为无效,线程会从主内存中读取共享变量

结合案例分析:给stop变量加上volatile关键字之后

  • 当main线程修改stop为true,修改本地内存stop的值,并触发本地内存到主内存的回写(因此主内存的stop为true)
  • 当线程A尝试读取volatile修饰的stop变量时,不会从本地内存读取,而是去读主内存中的stop值,因此读取到的stop的值为true,就会跳出循环进而执行步骤3
2)volatile有序性的实现

volatile的happens-before规则

对一个volatile域的写happens-before于任意后续对这个volatile域的读

// 2.有序性的实现:happens-before规则
public class OrderDemo {
    int a = 0;
    volatile boolean flag = false;

    public void writer(){
        a  = 1;          // 1.线程A修改共享变量
        flag = true;     // 2.线程A写volatile变量
    }

    public void reader(){
        if(flag){        // 3.线程B读同一个volatile变量
            int i = a;   // 4.线程B读共享变量
        }
    }
}

​ 根据happens-before规则,上面的过程会建立3类happens-before关系:

  • 根据程序次序排序:1 happens-before 2 且 3 happens-before 4
  • 根据volatile规则:2 happens-before 3
  • 根据happens-before的传递性规则:1 happens-before 4

image-20240526180255251

​ 基于上上述规则,当线程A将volatile变量flag更改为true后,线程B可以循序感知

volatile禁止重排序

​ 为了性能优化,JMM 在不改变正确语义的前提下,会允许编译器和处理器对指令序列进行重排序。针对会改变语义的场景,Java 编译器会在生成指令系列时在适当的位置会插入内存屏障指令来禁止特定类型的处理器重排序,JMM 针对编译器制定的 volatile 重排序规则表如下(NO就是需要禁止重排序的场景)

image-20240526180518170

3.final关键字

final关键字的使用

1)final修饰类

final修饰的类不能被继承

​ final类中的所有方法都隐式为final,因为无法覆盖他们,所以在final类中给任何方法添加final关键字式没有意义的。

2)final修饰方法

final修饰的方法不能够被子类重写,但可以被重载

public class FinalMethodDemo{
	public final void test(){}
	public final void test(String str){}
}
3)final修饰变量
final 修饰参数列表

​ Java允许在参数列表中以声明的方式将参数指明为final,这意味这你无法在方法中更改参数引用所指向的对象。这个特性主要用来向匿名内部类传递数据。

public static void printMessage(final String message){ }

案例分析

// final
public class FinalParameterDemo {
    
    interface Printer{
        void print();
    }

    // 定义信息打印方法
    public static void printMessage(final String message){
        Printer printer = new Printer() {
            @Override
            public void print() {
                // 访问final参数
                System.out.println(message);
            }
        };
        // 执行方法
        printer.print();
    }

    public static void main(String[] args) {
        printMessage("Hello World");
    }

}

​ 结合案例分析,FinalParameterDemo 类有一个内部接口 Printer(定义 print 方法)。

​ main 方法调用了 printMessage 方法,并传递了一个字符串"Hello World"。printMessage 方法接受一个声明为final 的字符串参数 message ,然后创建了一个实现了 Printer 接口的匿名内部类的实例。在这个匿名内部类中,重写了 print 方法,并在其中打印了message 参数的内容

​ 由于 message 参数被声明为 final ,它不能在 printMessage 方法中被修改。这样做可以确保传递给匿名内部类的数据是不可变的,从而避免了潜在的并发修改问题。在这个简单的例子中,直接调用了匿名内部类的print 方法来打印消息。

final场景应用:不希望方法内改变参数的场景

final 修饰类变量

final修饰的变量值一旦被初始化之后无法被更改

​ 思考一个问题:所有被final修饰的字段都是编译期常量吗?(答案是否定的)

// 编译期常量
final int a = 1;
final static int b = 1;
final int[] c = {1,2,3,4,5};

// 非编译器常量
Random r = new Random();
final int e = r.nextInt();

// 此处e的值是由运行后的随机数对象所决定,并不是所有的final修饰的字段都是编译器常量,只是e的值在初始化之后无法被更改

final修饰变量:

  • static final:该字段只占据一段不能改变的存储空间,必须在定义的时候进行赋值(否则编译器不予通过)

  • blank final:Java允许生成空白final(被声明为final可以不给出指定值的而字段,但该字段必须在使用之前被赋值)

    • 在定义处进行赋值(不叫空白final):final int i = 1;
    • 在构造器中赋值(确认该值在使用前被赋值):final int x; (并在构造器中赋值,或者在使用前赋值)

final的重排序禁止规则

按照final修饰的数据类型分类:

  • 基本数据类型:

    • final域写:禁止final域写与构造方法重排序,即禁止final域写重排序到构造方法之外,从而保证该对象对所有线程可见时,该对象的final域全部已经初始化过
    • final域读:禁止初次读对象的引用与读该对象包含的final域的重排序
  • 引用数据类型:

    • 额外增加约束 :禁止“在构造函数内对一个final修饰的对象的成员域的写入”与“随后将这个被构造的对象的引用赋值给引用变量”重排序(需确保这两步不会被重排序,否则其他线程可以通过这个引用来访问尚未被初始化的成员变量)
评论
  • 按正序
  • 按倒序
  • 按热度
Powered by Waline v3.1.3