当前位置: 首页 > article >正文

Java【多线程】(3)单例模式与线程安全


目录

1.前言

2.正文

2.1线程安全类

2.2杂谈(介绍几个概念)

2.2.1内存可见性

2.2.2指令重排序

2.2.3线程饥饿

1. 什么是线程饥饿?

2. 线程饥饿的常见原因

2.2.4区分wait和sleep

2.4单例模式

2.4.1饿汉模式

2.4.2懒汉模式

2.4.2指令重排序与线程安全

3.小结


1.前言

哈喽大家好吖,今天继续给大家分享Java中多线程的学习,今天主要先给上文做个收尾以及讲解单例模式,那么废话不多说,让我们开始吧。

2.正文

2.1线程安全类

先再重新回顾一个概念,到底如何判断会涉及线程安全问题,凡是该方法涉及到修改数据的操作,而且没有内部进行加锁操作,这样就会导致线程安全问题,那么接下来就来详细介绍线程安全类以及线程不安全类:

常见的线程安全类

  1. 集合框架

    • Vector(同步方法)

    • Hashtable(同步方法)

    • CopyOnWriteArrayList(写时复制)

    • BlockingQueue 实现类(如 ArrayBlockingQueue,用于生产者-消费者模式)

  2. 原子类

    • AtomicIntegerAtomicLong

    • AtomicReferenceAtomicBoolean

  3. 工具类

    • String(不涉及修改)

    • StringBuffer(同步方法,线程安全版的 StringBuilder

    • Collections.synchronizedList()(包装非线程安全集合,如 ArrayList


常见的线程不安全类

  1. 集合框架(集合类本身没有进行任何加锁限制)

    • ArrayListLinkedList

    • HashMapHashSet

    • StringBuilder(非同步的字符序列操作)

  2. 日期格式化类

    • SimpleDateFormat(内部状态可变)

  3. 其他工具类

    • Random(共享种子可能导致竞争)

上述集合中,有的虽然有synchronized,但不推荐使用,因为加锁这个事情,是有代价的,一旦在代码中使用了锁,意味着代码可能会因为锁的竞争,产生阻塞,这样程序执行的效率会大打折扣,一旦造成线程阻塞,从cpu中调度走,啥时候才能回来执行就未知了。

2.2杂谈(介绍几个概念)

2.2.1内存可见性

内存可见性也是造成线程安全问题的原因之一,我们先附上一个代码:

import java.util.Scanner;

public class test {
    public static int flag = 0;
    public static void main(String[] args) {
        Thread t1 = new Thread(()->{
            while(flag == 0){

            }
            System.out.println("t1线程结束");
        });

        Thread t2 = new Thread(()->{
            Scanner scanner = new Scanner(System.in);
            System.out.println("请输入flag值:");
            flag = scanner.nextInt();
        });
        t1.start();
        t2.start();
    }
}

我们尝试运行一下,结果发现:

我们修改了flag值,结果发现t1线程没有像我们预期的会结束线程,一个线程读取,一个线程修改,修改线程的值,并没有被线程读到,这就是内存可见性问题。

讲一下为什么:

研究 JDK 的大佬们,就希望通过让编译器 & JVM 对程序员写的代码,自动的进行优化
本来写的代码是进行 xxxxx,编译器/VM 会在你原有逻辑不变的前提下, 对你的代码进行调整
使程序效率更高。

编译器,虽然声称优化操作,是能够保证逻辑不变,尤其是在多线程的程序中,编译器的判断可能出现失误.可能导致编译器的优化,使优化后的逻辑,和优化前的逻辑出现细节上的偏差。

于是原因就显而易见了:

  1. 硬件架构影响

    • CPU缓存:每个线程可能在自己的CPU缓存中操作变量,而非直接读写主内存。

    • 指令重排序:编译器和处理器可能优化指令顺序以提高性能,导致代码执行顺序与预期不一致。

  2. Java内存模型(JMM)抽象

    • JMM规定所有变量存储在主内存,线程通过本地内存(缓存副本)操作变量。

    • 线程间通信需通过主内存完成,本地内存更新若未同步到主内存,其他线程无法感知变化。

于是上述代码我们这样稍作修改就可以了:

import java.util.Scanner;

public class test {
    public static int flag = 0;
    public static void main(String[] args) {
        Thread t1 = new Thread(()->{
            while(flag == 0){
                try {
                    Thread.sleep(1);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            System.out.println("t1线程结束");
        });

        Thread t2 = new Thread(()->{
            Scanner scanner = new Scanner(System.in);
            System.out.println("请输入flag值:");
            flag = scanner.nextInt();
        });
        t1.start();
        t2.start();
    }
}

结果如我们所愿。

那么我们不能一遇到内存可见性问题就选择sleep,那样会影响程序执行效率,所以说接下来我们引入一个关键字来解决这个问题:

volatile 是 Java 提供的一种轻量级的同步机制,主要解决 内存可见性 和 指令重排序 问题,但 不保证原子性。确保一个线程对 volatile 变量的修改对其他线程立即可见。

  • 问题根源:线程操作变量时可能使用本地缓存(如 CPU 缓存),而非直接读写主内存。

  • volatile 的解决:强制所有读写操作直接操作主内存,绕过线程本地缓存。

import java.util.Scanner;

public class test {
    public volatile static int flag = 0;
    public static void main(String[] args) {
        Thread t1 = new Thread(()->{
            while(flag == 0){

            }
            System.out.println("t1线程结束");
        });

        Thread t2 = new Thread(()->{
            Scanner scanner = new Scanner(System.in);
            System.out.println("请输入flag值:");
            flag = scanner.nextInt();
        });
        t1.start();
        t2.start();
    }
}

结束!

2.2.2指令重排序

还记得上文提到的volatile关键字吗,里面的讲解提到了一个指令重排序的问题,那么这个问题是什么意思呢?这里先简单提及下,讲到单例模式时会有详细讲解:

指令重排序是指在不改变单线程程序执行结果的前提下,编译器或处理器通过调整指令的执行顺序来优化性能。


编译器进行指令重排序的原因:

  • 提高CPU利用率:减少流水线停顿,避免等待慢操作(如内存访问,上文提及)。

  • 优化缓存效率:通过局部性原理提高缓存命中率。

  • 并行执行指令:现代CPU的多级流水线和多核心架构需要指令级并行。


我们为什么之前没有遇到这个问题呢,因为我们在Java初阶的学习过程中大部分都是单线程环境下,只有在多线程环境下才会受到指令重排序的影响。

  • 可见性问题:一个线程的修改对另一个线程不可见。

  • 有序性问题:代码的实际执行顺序与预期不一致。

2.2.3线程饥饿

什么是线程饥饿呢?

1. 什么是线程饥饿?

线程饥饿指在多线程环境下,某个或某些线程长期无法获得所需的资源(如CPU时间片、锁、I/O等),导致其任务无法正常执行的现象。饥饿的线程可能永远等待,或执行进度远慢于其他线程。

关键特征

  • 非全局阻塞(其他线程仍正常运行)。

  • 由资源分配策略或调度机制引起。

  • 可能伴随优先级反转、资源竞争等问题。


2. 线程饥饿的常见原因

原因说明示例
高优先级线程抢占高优先级线程始终优先获得CPU时间片,低优先级线程长期无法执行。线程优先级设置不合理(如Java中setPriority(10)抢占低优先级线程)。
非公平锁竞争锁的获取策略不公平,某些线程始终竞争失败。synchronized关键字导致某些线程饥饿。
资源独占某个线程长期持有共享资源(如数据库连接、文件句柄),其他线程无法获取。未合理释放资源(如忘记关闭锁或未用try-finally块)。
任务调度策略缺陷任务队列设计不合理(如固定顺序的任务分配)。线程池使用无界队列或固定顺序提交任务。
通过合理设计资源分配策略和使用同步工具,可有效减少线程饥饿的发生,保障多线程程序的稳定性和性能。

2.2.4区分wait和sleep

在讲解单例模式前,再最后区分一下wait和sleep:

wait有等待时间,sleep也有等待时间,wait可以使用notify提前唤醒,sleep也可以使用Interrupt提前唤醒。


wait 和 sleep 最主要的区别,在于针对锁的操作.

  1. wait 必须要搭配锁.先加锁, 才能用 wait. sleep 不需要.
  2. 2)如果都是在 synchronized 内部使用, wait 会释放锁.sleep 不会释放锁~

2.4单例模式

单例模式是一种常用的软件设计模式,用于确保某个类只有一个实例,并且提供一个全局访问点。其中饿汉模式和懒汉模式是其中最经典的两种单例模式。

2.4.1饿汉模式

饿汉式单例在类加载时就创建实例,这种方式可以保证线程安全,但是实例的创建是立即进行的,可能会浪费资源。


 

class Singleton {
    private static Singleton instance = new Singleton(100);

    public static Singleton getInstance() {
        return instance;
    }

    private Singleton() {

    }

    private Singleton(int n) {

    }
}

public class test2 {
    public static void main(String[] args) {
        Singleton t1 = Singleton.getInstance();
        Singleton t2 = Singleton.getInstance();
        System.out.println(t1 == t2);
        
    }
}

为了判断该代码仅创建了一个实例,我们创建t1和t2来判断是一个实例还是两个:

可以发现是一个实例。

2.4.2懒汉模式

懒和饿是相对的,一个是在程序一启动就创建好示例,另一个是尽可能晚的创建实例,以达到节省效率的目的。

懒汉式单例的特点是在需要的时候才创建实例,这种实现方式可以延迟实例的创建,节省资源。但是,如果多个线程同时访问getInstance方法,可能会导致多个实例的创建,因此需要进行同步处理。


class SingletonLazy{
    public static SingletonLazy instance = null;

    public static SingletonLazy getInstance(){
        if(instance == null){
            instance = new SingletonLazy();
        }
        return instance;
    }

    private SingletonLazy(){

    }
}
public class test3 {
    public static void main(String[] args) {
        SingletonLazy s1 = SingletonLazy.getInstance();
        SingletonLazy s2 = SingletonLazy.getInstance();
        System.out.println(s1 == s2);
    
        // SingletonLazy s3 = new SingletonLazy();
    }
}

 

仿照着饿汉模式,我们仿佛就把正确的代码写出来了,但这里要抛出一个很重要的问题:这样的代码是否是线程安全的呢?

2.4.2指令重排序与线程安全

第一个饿汉模式,在getinstance方法中只涉及到return的读操作,不涉及到线程安全问题。然而懒汉模式的getinstance方法

创建实例时可能涉及到多线程的修改操作,并且一个if语句加上与一个创建示例的语句,这样就违背了原子性的原则。

在多线程环境下,如果有多个线程同时调用 getInstance() 方法,可能会在检查 instance == null 后,多个线程都进入 if 块并创建新的实例。这是因为多个线程可能在同一时间检查到 instancenull,从而都执行 new SingletonLazy(),导致创建多个实例。


所以我们就希望通过修改代码,使其避免上述问题。


但又有新的问题出现了,在多线程情况下,加锁会互相阻塞,影响执行效率,所以我们再进行修改:

此处最外层的if语句即为判断该实例是否已被创建,如果该实例以及被创建,就不需要进行获得锁操作,提升程序执行效率。


这样总会没问题了吧,不其实还有,有没有可能出现内存重排序问题呢,稳妥起见我们加上volatile

到这里就要呼应上文了,那指令重排序呢,现在就要讲了:

在创建实例时,要经过下面几个步骤:

  1. 申请内存空间
  2. 在空间上构造对象(初始化)
  3. 内存空间的首地址,赋值给引用变量

正常来说,这三个步骤按照 123这样的顺序来执行的,但是在指令重排序下,可能成为132 这样的顺序单线程环境下,123 还是 132 其实无所谓~~如果是13 2 这样的顺序执行,多线程环境下,可能会出现 bug !!

如果先进行1,3,那么很有可能出现尝试赋值时在对一个“未初始化的对象”进行操作,于是乎在这里,volatile也起到了解决指令重排序的问题。接下来就是正确的完整代码:
 

class SingletonLazy{
    private static volatile SingletonLazy instance = null;
    private static Object locker = new Object();

    public static SingletonLazy getInstance() {
        if (instance == null) {
            synchronized (locker) {
                if (instance == null) {
                    instance = new SingletonLazy();
                }
            }
        }
        return instance;
    }
}
public class test3 {
    public static void main(String[] args) {
        SingletonLazy s1 = SingletonLazy.getInstance();
        SingletonLazy s2 = SingletonLazy.getInstance();
        System.out.println(s1 == s2);

        // SingletonLazy s3 = new SingletonLazy();
    }
}

大功告成!

3.小结

今天的分享到这里就结束了,喜欢的小伙伴点点赞点点关注,你的支持就是对我最大的鼓励,大家加油!


http://www.kler.cn/a/579664.html

相关文章:

  • Python自动点击器开发教程 - 支持键盘连按和鼠标连点
  • 单体架构、微服务组件与解决方案、微服务面试
  • CentOS缺少宋体和黑体字体
  • 如何用更少的内存训练你的PyTorch模型?深度学习GPU内存优化策略总结
  • DC3-靶机练习
  • Javaweb后端文件上传@value注解
  • 【Java---数据结构】二叉树(Tree)
  • NetBeans 8.2 开发 CIFLog3.5 - 数据导入导出案例
  • 220页满分PPT | 华为ISC供应链解决方案
  • 【新闻资讯】IT 行业最新动向:AI 引领变革,多领域融合加速
  • 【从零开始学习计算机科学】计算机体系结构(一)计算机体系结构、指令、指令集(ISA)与量化评估
  • 不同开发语言对字符串的操作
  • 一文理清概念:数据中台(DMP)-数据仓库(DW)-数据湖(DL)-湖仓一体-数据治理(DG)
  • SpringMVC概述以及入门案例
  • Hadoop、Spark、Flink Shuffle对比
  • 音乐API
  • 渗透测试之利用sql拿shell(附完整流程+防御方案)【下】
  • 机器学习实战——音乐流派分类(主页有源码)
  • 行为模式---观察者模式
  • 生物信息学与计算生物学:各自概念、主要内容、区别与联系、发展展望?