Java中的设计模式——单例模式、代理模式、适配器模式

1. 单例模式

模式概述

  • 定义:单例模式是一种设计模式,目的是确保一个类只有一个实例,并提供一个全局访问点。
  • 实现方式:常见的单例模式实现方式有懒汉式和饿汉式。懒汉式是在第一次需要用到实例时才创建对象,而饿汉式是在类加载时就创建好实例。

Java 内存模型(JMM)和线程工作内存

Java 内存模型规定了线程与主内存之间的交互规则,具体体现在以下几个方面:

  1. 主内存:所有 Java 对象实例和静态变量都存储在主内存中。主内存可以看作是一个共享的内存区域,所有线程都可以访问。

  2. 工作内存(线程缓存):每个线程有自己的工作内存(也称为线程缓存)。线程的工作内存中存储着该线程所需要的数据(变量副本)。每个线程从主内存中读取所需变量值后,会将这些值暂存到自己的工作内存中,线程在计算或操作这些变量时,是在工作内存中进行的,而非直接在主内存中操作。

  3. 变量读取与写入:线程的工作内存会把从主内存加载的变量值保存在自己的缓存中,执行计算时直接对缓存中的数据进行操作。修改后的结果也会先写入线程的工作内存中,最后再同步回主内存中。

这种设计可以提高访问速度,但也会带来一个问题------可见性问题

可见性问题

因为每个线程对变量的操作都发生在自己的工作内存中,当多个线程操作同一个变量时,工作内存和主内存之间的更新不同步,就会导致一个线程对变量的修改对其他线程不可见。

例如,假设有一个变量 flag,被线程 A 和线程 B 同时使用:

  • 步骤 1flag 初始值为 true,存储在主内存中。
  • 步骤 2 :线程 A 将 flag 的值加载到自己的工作内存中。
  • 步骤 3 :线程 B 也将 flag 的值加载到自己的工作内存中。
  • 步骤 4 :线程 A 将 flag 的值修改为 false 并写回到主内存中。
  • 步骤 5 :线程 B 继续使用自己的工作内存中缓存的 flag 值,而不会意识到主内存中的值已经被修改为 false

结果就是,线程 B 继续操作的是一个过期的值,导致了可见性问题

volatile 如何解决可见性问题

在 Java 中,volatile 关键字是一种轻量级的同步机制,用于修饰变量。volatile 可以确保被修饰的变量在所有线程中都是可见的,具体来说有以下两方面保证:

  1. 读操作从主内存中加载最新的值 :当一个变量被声明为 volatile 时,JMM 会确保每次线程读取这个变量时,都是从主内存中直接读取最新的值,而不会使用工作内存中的缓存值。

  2. 写操作立即同步到主内存 :同样,当一个线程对 volatile 变量进行写操作时,JMM 会强制将这个更新后的值立即刷新回主内存,使得其他线程可以立即看到最新的变化。

以下是一个简单的代码示例,说明 volatile 如何确保线程之间的可见性:

java 复制代码
public class VolatileExample {
    private volatile boolean flag = true;

    public void updateFlag() {
        flag = false;  // 这里修改flag值会立即同步到主内存
    }

    public void checkFlag() {
        while (flag) {
            // 这里每次读取flag的值,都是从主内存读取最新值
        }
    }
}

在这个例子中:

  • flagvolatile 修饰后,线程执行 updateFlag() 方法将 flag 设置为 false 时,值会立即写回主内存。
  • 另一个线程执行 checkFlag() 方法,每次读取 flag 值时,都会从主内存中获取最新的值,而不会使用线程缓存。

volatile 的局限性

虽然 volatile 能解决可见性问题,但它并不能保证操作的原子性 。只能用在简单的赋值操作中举例来说,count++ 这样的操作包含了多个步骤(读取 count 值、增加 1 并写回),volatile 并不能确保多个线程同时执行该操作时不会产生冲突。

使用 volatilesynchronized 实现单例模式

synchronized 关键字

  • 定义synchronized 是一种重量级的同步机制,可以修饰方法或代码块,用于控制线程访问的顺序。
  • 作用synchronized 可以确保同一时刻只有一个线程执行同步代码块或方法,保证了代码的原子性和可见性。进入 synchronized 块的线程会自动获取锁,执行完毕后会释放锁。
  • 适用场景:适用于需要保护某段关键代码的场景,例如对共享资源的读写操作。

轻量级和重量级同步的区别

在多线程编程中,轻量级和重量级同步主要指同步机制对系统资源的占用程度,以及对性能的影响。

1. 轻量级(volatile 属于轻量级同步)

volatile 被称为轻量级同步,原因是它仅仅确保了变量的可见性 ,但并不保证原子性 。它没有像 synchronized 那样的锁机制,不会阻塞线程,因此不会引起线程上下文切换。使用 volatile 不需要进入同步块,也就没有额外的资源消耗和性能开销。

优点

  • 无锁机制,性能较高。
  • 可以保证变量的最新值对所有线程可见。

局限性

  • 只能用在简单的赋值操作中,不适用于复合操作(如 count++)。
  • 不保证操作的原子性,不适合需要排他访问的场景。

2. 重量级(synchronized 属于重量级同步)

synchronized 是重量级同步,因为它涉及锁机制。当一个线程进入 synchronized 块或方法时,其他线程无法同时进入该块,这会导致线程的阻塞和等待。锁的获取和释放会带来额外的系统开销,比如线程的上下文切换(切换线程时操作系统保存和恢复线程的状态),因此性能相对较低,属于重量级操作。

优点

  • 可以保证操作的原子性和可见性。
  • 适用于需要保护复合操作的场景,例如共享资源的修改。

局限性

  • 由于线程需要等待锁,性能较低。
  • 在大量线程争抢锁时,可能会导致性能下降。

为什么选择轻量或重量

轻量和重量的选择取决于程序对同步的需求和性能的权衡:

  • 如果只是需要简单的变量可见性(比如某个标志位的状态),使用 volatile 更合适。
  • 如果操作涉及多个步骤且需要原子性保障(如增减计数器、更新共享资源),synchronized 更可靠,尽管它会带来更多性能开销。

双重检查锁定模式(Double-Checked Locking)

双重检查锁定是一种延迟初始化的懒汉式单例模式,它利用 volatilesynchronized 确保线程安全,同时避免了每次获取实例时都进入同步块的性能开销。

java 复制代码
public class Singleton {
    // 使用 volatile 关键字,确保 instance 对所有线程的可见性
    private static volatile Singleton instance = null;

    // 私有构造函数,防止外部创建实例
    private Singleton() {}

    // 提供一个全局访问点
    public static Singleton getInstance() {
        if (instance == null) {  // 第一次检查
            synchronized (Singleton.class) {  // 同步代码块
                if (instance == null) {  // 第二次检查
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

详细解读双重检查锁定中的过程

  1. 第一个 if (instance == null)

    • 多个线程可能同时调用 getInstance() 方法。
    • 因为这是在同步块外执行的第一次检查,所以多个线程会同时通过 if (instance == null) 的判断,认为 instancenull(因为最开始 instance 确实是 null)。
  2. synchronized (Singleton.class)

    • 虽然多个线程通过了第一次检查,但只有一个线程能成功获取锁,进入 synchronized 块。其他线程会在同步块外等待,直到锁被释放。
    • 第一个进入 synchronized 块的线程会创建 Singleton 实例。
  3. 第二个 if (instance == null)

    • 第一个获得锁的线程在 synchronized 块中再次检查 instance == null。因为这是第一次创建实例的线程,所以 instance 仍然为 null,于是这个线程创建实例。
    • 当第一个线程释放锁后,其他等待的线程会依次进入 synchronized 块。此时 instance 已经被创建 ,所以这些线程在 synchronized 块内的第二次检查 if (instance == null) 会发现 instance 不再是 null,因此不会再次创建实例。

双重检查的原因是性能优化。在大多数情况下,单例对象已经被创建,我们不需要进入 synchronized 块,从而减少了同步的开销。

2. 代理模式

模式概述

代理模式是一种设计模式 ,它让一个对象(代理对象)代替另一个对象去处理请求。我们用代理对象来控制对实际对象的访问,这样我们可以在访问实际对象前后添加一些额外功能,比如控制权限、记录日志、延迟加载资源等。

想象一下,当你要进入一个大型活动会场时,会有一个安保人员检查你的门票。这时,安保人员就是一个代理,他控制了你对会场的"访问"。通过代理人员的检查,确保只有合法的客人才能进入会场。

适用场景
代理模式适用于以下几种情况:

  1. 访问控制:限制谁可以访问对象,比如权限控制。
  2. 延迟加载:在需要时才加载某些资源,比如数据库连接或大文件。
  3. 日志和监控:代理对象可以记录谁在什么时间访问了资源,方便后续分析。

代理模式的结构

代理模式通常包含以下三个角色:

  • 接口或抽象类(Subject):定义了实际对象和代理对象共同的接口。这样代理对象和实际对象可以被同样的方式调用。

  • 实际对象(RealSubject):这是被代理的对象,它包含了业务逻辑,比如文件读取、数据库连接等功能。

  • 代理对象(Proxy):这是负责"代理"访问的对象,它持有对实际对象的引用,并实现了与实际对象相同的接口。

让我们来通过一个例子一步步地解释代理模式。

示例:延迟加载图片

假设我们有一个图片类 RealImage,它需要从磁盘加载图片的操作,但这个操作可能比较耗时。因此,我们可以创建一个代理类 ImageProxy,用来在需要时才实际加载图片。

  1. 定义图片接口

    首先,我们定义一个 Image 接口,这样代理类和实际图片类都可以实现这个接口,并保持相同的操作方法(在这里是 display() 方法):

    java 复制代码
    public interface Image {
        void display();
    }
  2. 实现实际图片类(RealSubject)

    接下来,我们创建 RealImage 类,它负责从磁盘加载图片。加载图片的操作可能很耗时,我们可以用 System.out.println 模拟这种加载的延迟效果。

    java 复制代码
    public class RealImage implements Image {
        private String fileName;
    
        public RealImage(String fileName) {
            this.fileName = fileName;
            loadFromDisk();  // 模拟加载图片的耗时操作
        }
    
        private void loadFromDisk() {
            System.out.println("Loading " + fileName);
        }
    
        public void display() {
            System.out.println("Displaying " + fileName);
        }
    }
  3. 创建代理类(Proxy)

    然后,我们创建 ImageProxy 类,它是 Image 接口的代理实现。代理类持有实际图片对象的引用(RealImage),并在需要时才去创建和加载它。通过代理,我们可以延迟 RealImage 的初始化,直到第一次调用 display() 才加载图片。

    java 复制代码
    public class ImageProxy implements Image {
        private RealImage realImage;   // 持有实际图片对象的引用
        private String fileName;
    
        public ImageProxy(String fileName) {
            this.fileName = fileName;
        }
    
        public void display() {
            if (realImage == null) {  // 仅在需要时才加载实际图片
                realImage = new RealImage(fileName);
            }
            realImage.display();
        }
    }
  4. 测试代理模式的效果

    在客户端代码中,我们通过代理类 ImageProxy 来访问图片对象。这样,我们可以在第一次调用 display() 方法时才实际加载图片,避免了每次创建图片时都加载的开销。

    java 复制代码
    public class ProxyPatternDemo {
        public static void main(String[] args) {
            Image image = new ImageProxy("test_image.jpg");
    
            // 第一次调用 display,实际图片会被加载
            image.display();
            
            // 第二次调用 display,使用已经加载的图片
            image.display();
        }
    }

执行结果

java 复制代码
Loading test_image.jpg
Displaying test_image.jpg
Displaying test_image.jpg

解析

  • 延迟加载 :代理类 ImageProxy 通过 realImage == null 的检查,仅在第一次调用 display() 方法时才去创建 RealImage,从而实现了延迟加载。

  • 访问控制 :用户通过 ImageProxy 访问 RealImage,从而将实际图片的加载过程隔离出来,用户不必直接创建和加载图片对象,而是通过代理类来控制加载行为。

总结代理模式

代理模式在不修改实际对象的情况下,控制了对实际对象的访问,还可以增加额外的操作,例如延迟加载和访问权限验证等。代理模式非常适用于需要访问控制或延迟初始化的场景。

3. 适配器模式

模式概述

适配器模式是一种设计模式 ,它将一个类的接口转换为客户端期望的接口。简单来说,适配器模式解决了接口不兼容的问题,使得原本无法直接使用的类能够配合工作。

举个简单的例子:假如你的手机充电器插头是USB-C型,但你的插座是三孔的,这时你就需要一个适配器,它能够把USB-C型插头转换成符合三孔插座的插头,让你可以正常充电。

适用场景
适配器模式适用于以下情况:

  1. 接口不兼容:当使用的接口与已有类的接口不匹配时,通过适配器连接两者。
  2. 复用现有类:不改变已有类的代码,让它适配新的接口要求。

适配器模式的结构

适配器模式一般包含以下几部分:

  • 目标接口(Target):客户端期望使用的接口。
  • 已有接口(Adaptee):原本不兼容的接口,需要被适配的接口。
  • 适配器(Adapter):实现目标接口,并将已有接口的功能转换为目标接口的功能。

实现步骤

我们来通过一个具体示例逐步理解适配器模式的实现。

示例:音频播放器扩展

假设我们有一个音频播放器 AudioPlayer,它只能播放 MP3 格式的音频文件。现在,我们需要扩展播放器,让它可以播放其他格式的音频文件(如 VLC 和 MP4 格式)。

1. 目标接口 (MediaPlayer)

AudioPlayer 类需要实现 MediaPlayer 接口,该接口定义了播放器的基本方法。

java 复制代码
public interface MediaPlayer {
    void play(String audioType, String fileName); // 播放音频文件
}

2. 被适配接口 (AdvancedMediaPlayer)

我们将定义一个 AdvancedMediaPlayer 接口,用来支持播放 MP4 和 VLC 格式的文件。这个接口包含两个方法:一个用于播放 MP4 文件,另一个用于播放 VLC 文件。

java 复制代码
public interface AdvancedMediaPlayer {
    void playVlc(String fileName); // 播放 VLC 文件
    void playMp4(String fileName); // 播放 MP4 文件
}

3. 被适配的类 (VlcPlayerMp4Player)

然后我们实现 AdvancedMediaPlayer 接口的具体类。VlcPlayer 用于播放 VLC 格式的文件,Mp4Player 用于播放 MP4 格式的文件。

java 复制代码
public class VlcPlayer implements AdvancedMediaPlayer {
    @Override
    public void playVlc(String fileName) {
        System.out.println("Playing VLC file. Name: " + fileName);
    }

    @Override
    public void playMp4(String fileName) {
        // 不实现
    }
}

public class Mp4Player implements AdvancedMediaPlayer {
    @Override
    public void playVlc(String fileName) {
        // 不实现
    }

    @Override
    public void playMp4(String fileName) {
        System.out.println("Playing MP4 file. Name: " + fileName);
    }
}

4. 适配器类 (MediaAdapter)

为了使 AudioPlayer 能够播放 MP4 和 VLC 格式的文件,我们创建一个适配器类 MediaAdapter,该类将 MediaPlayer 接口的 play() 方法与 AdvancedMediaPlayer 的方法连接起来。

java 复制代码
public class MediaAdapter implements MediaPlayer {
    AdvancedMediaPlayer advancedMusicPlayer;

    public MediaAdapter(String audioType) {
        if (audioType.equalsIgnoreCase("vlc")) {
            advancedMusicPlayer = new VlcPlayer(); // 支持 VLC 格式
        } else if (audioType.equalsIgnoreCase("mp4")) {
            advancedMusicPlayer = new Mp4Player(); // 支持 MP4 格式
        }
    }

    @Override
    public void play(String audioType, String fileName) {
        if (audioType.equalsIgnoreCase("vlc")) {
            advancedMusicPlayer.playVlc(fileName);
        } else if (audioType.equalsIgnoreCase("mp4")) {
            advancedMusicPlayer.playMp4(fileName);
        }
    }
}

5. 音频播放器类 (AudioPlayer)

AudioPlayer 类实现了 MediaPlayer 接口,并且在播放 MP3 文件时直接处理,如果是其他格式,则通过 MediaAdapter 来适配。

java 复制代码
public class AudioPlayer implements MediaPlayer {
    MediaAdapter mediaAdapter;

    @Override
    public void play(String audioType, String fileName) {
        if (audioType.equalsIgnoreCase("mp3")) {
            System.out.println("Playing MP3 file. Name: " + fileName);
        }
        else if (audioType.equalsIgnoreCase("vlc") || audioType.equalsIgnoreCase("mp4")) {
            mediaAdapter = new MediaAdapter(audioType); // 使用适配器
            mediaAdapter.play(audioType, fileName);
        }
        else {
            System.out.println("Invalid media. " + audioType + " format not supported");
        }
    }
}

6. 测试类 (AdapterPatternDemo)

最终,我们可以创建一个测试类来验证我们的 AudioPlayer 是否能够成功支持 MP3、MP4 和 VLC 格式的文件。

java 复制代码
public class AdapterPatternDemo {
    public static void main(String[] args) {
        AudioPlayer audioPlayer = new AudioPlayer();

        audioPlayer.play("mp3", "beyond the horizon.mp3");  // MP3 文件
        audioPlayer.play("mp4", "alone.mp4");              // MP4 文件
        audioPlayer.play("vlc", "far far away.vlc");       // VLC 文件
        audioPlayer.play("avi", "mind me.avi");            // 不支持的格式
    }
}

执行结果

java 复制代码
Playing MP3 file. Name: beyond the horizon.mp3
Playing MP4 file. Name: alone.mp4
Playing VLC file. Name: far far away.vlc
Invalid media. avi format not supported

总结

  1. 目标接口 (MediaPlayer) :为 AudioPlayer 类提供统一的播放方法。
  2. 被适配接口 (AdvancedMediaPlayer):定义了播放 MP4 和 VLC 文件的方法。
  3. 适配器类 (MediaAdapter) :实现了 MediaPlayer 接口,并通过适配的方式调用 AdvancedMediaPlayer 的方法,支持 MP4 和 VLC 文件格式。
  4. 音频播放器类 (AudioPlayer) :实现了 MediaPlayer 接口,并根据文件类型选择是否通过 MediaAdapter 来播放 MP4 或 VLC 文件。

通过适配器模式,AudioPlayer 类能够扩展支持其他音频格式,而不需要改变现有的代码结构。

相关推荐
颜淡慕潇14 分钟前
【数据库系列】 Spring Boot 集成 Neo4j 的详细介绍
java·数据库·spring boot·后端·neo4j
乌啼霜满天24916 分钟前
tomcat与servlet版本对应关系
java·servlet·tomcat
爱吃土豆的程序员17 分钟前
windows tomcat 报错后如何让窗口不闪退
java·windows·tomcat·窗口闪退
呼啦啦啦啦啦啦啦啦32 分钟前
【Java多线程】wait方法和notify方法
java·开发语言
chusheng18401 小时前
Python 如何通过 cron 或 schedule 实现爬虫的自动定时运行
java·爬虫·python
树不懒1 小时前
【设计模式】关联关系与依赖关系
设计模式
有点困的拿铁1 小时前
Java中的享元模式
java·开发语言·享元模式
随心............1 小时前
python设计模式
java·开发语言·设计模式
威哥爱编程1 小时前
Java灵魂拷问13个为什么,你都会哪些?
java·面试·javaee
噜啦啦噜啦啦噜啦噜啦嘞噜啦噜啦1 小时前
源码解析-Spring Eureka
java·spring·eureka