创建型设计模式之单例模式

概述

设计模式是针对软件开发中经常遇到的一些设计问题,总结出来的一套解决方案或者设计思路。

大部分设计模式要解决的都是代码的可扩展性问题。

对于灵活多变的业务,需要用到设计模式,提升扩展性和可维护性,让代码能适应更多的变化;

设计模式的核心就是,封装变化,隔离可变性


设计模式解决的问题:

  • 创建型设计模式主要解决"对象的创建"问题,创建和使用代码解耦;
  • 结构型设计模式主要解决"类或对象的组合或组装"问题,将不同功能代码解耦;
  • 行为型设计模式主要解决的就是"类或对象之间的交互"问题。将不同的行为代码解耦,具体到观察者模式,它是将观察者和被观察者代码解耦。

创建型模式主要解决对象的创建问题,封装复杂的创建过程,解耦对象的创建代码和使用代码。

  • 单例模式用来创建全局唯一的对象。
  • 工厂模式用来创建不同但是相关类型的对象(继承同一父类或者接口的一组子类),由给定的参数来决定创建哪种类型的对象。
  • 建造者模式是用来创建复杂对象,可以通过设置不同的可选参数,"定制化"地创建不同的对象。
  • 原型模式针对创建成本比较大的对象,利用对已有对象进行复制的方式进行创建,以达到节省创建时间的目的。

设计模式关注重点: 了解它们都能解决哪些问题,掌握典型的应用场景,并且懂得不过度应用。

经典的设计模式有 23 种。随着编程语言的演进,一些设计模式(比如 Singleton)也随之过时,甚至成了反模式,一些则被内置在编程语言中(比如 Iterator),另外还有一些新的模式诞生(比如 Monostate)。

常用的有:单例模式、工厂模式(工厂方法和抽象工厂)、建造者模式。

不常用的有:原型模式。

单例模式:

单例模式(singleton Pattern)是指确保一个类在任何情况下都绝对只有一个实例,并提供一个全局访问点。J2EE中的ServletContext,ServletContextConfig等;Spring中的ApplicationContext、数据库连接池等。

单例模式有 3 个特点:

  1. 单例类只有一个实例对象;
  2. 该单例对象必须由单例类自行创建;
  3. 单例类对外提供一个访问该单例的全局访问点。

单例模式的优点:

  • 单例模式可以保证内存里只有一个实例,减少了内存的开销。
  • 可以避免对资源的多重占用。
  • 单例模式设置全局访问点,可以优化和共享资源的访问。

单例模式的缺点:

  • 单例模式一般没有接口,扩展困难。如果要扩展,则除了修改原来的代码,没有第二种途径,违背开闭原则。
  • 在并发测试中,单例模式不利于代码调试。在调试过程中,如果单例中的代码没有执行完,也不能模拟生成一个新的对象。
  • 单例模式的功能代码通常写在一个类中,如果功能设计不合理,则很容易违背单一职责原则。

单例模式在同一个进程内只有一个实例,不会多次实例化。

单例模式确保在同一个进程内只有一个实例,不会多次实例化。但是ThreadLocal和Spring的容器注入Bean并不属于传统意义上的单例模式。

ThreadLocal是一种线程级别的变量,它为每个线程提供了独立的变量副本,每个线程都可以访问自己线程内部的副本,而不会影响其他线程。这样可以实现线程安全且独立的数据共享。虽然每个线程都能获取到相同的对象实例,但在不同的线程中,实际上创建了多个实例。

Spring的容器注入Bean通过控制反转(Inversion of Control)的方式管理对象的生命周期和依赖关系。默认情况下,Spring容器会将Bean配置为单例模式,即一个Bean在整个容器中只有一个实例。但是,Spring也提供了其他作用域来创建多个实例,如原型模式(prototype)、会话模式(session)、请求模式(request)等。所以,Spring的容器注入Bean并不完全属于传统的单例模式。

ThreadLocal和Spring的容器注入Bean与传统的单例模式并不完全一致,它们在对象实例化和管理方面具有一些特殊的行为和机制。

定义:

一个类只允许创建一个对象(或者实例),这个类就是一个单例类,这种设计模式就叫作单例模式

实现:

static类共享,final唯一

关注点:

  1. 构造函数需要是 private 访问权限的,这样才能避免外部通过 new 创建实例;
  2. 考虑对象创建时的线程安全问题;
  3. 考虑是否支持延迟加载;
  4. 考虑 getInstance() 性能是否高(是否加锁)。

1. 饿汉式(线程安全)

在类加载的时候,instance 静态实例就已经创建并初始化好了,所以,instance 实例的创建过程是线程安全的。不过,这样的实现方式不支持延迟加载。

推荐使用,在程序启动的时候将这个实例初始化好;按照fail-fast 的设计原则,有问题及早暴露,保障系统可用性

  1. 如果初始化耗时长,避免系统响应时间长甚至超时;
  2. 如果实例占用资源多,避免运行时因为资源不足系统崩溃,影响系统的可用性;
java 复制代码
public class IdGenerator { 
    private AtomicLong id = new AtomicLong(0);
    private static final IdGenerator instance = new IdGenerator();
    private IdGenerator() {}
    public static IdGenerator getInstance() {
        return instance;
    }
    public long getId() { 
        return id.incrementAndGet();
    }
}

2. 懒汉式(线程安全,延迟加载)

支持延迟加载,但是并发度很低的实现方式:

  • 懒汉式是在静态方法上加锁,是一个类锁,导致这个函数的并发度很低。
  • 如果频繁地用到,那频繁加锁、释放锁及并发度低等问题,会导致性能瓶颈,这种实现方式基础不用。

需要支持延迟加载,可以使用双重检查和静态内部类

java 复制代码
public class IdGenerator { 
  private AtomicLong id = new AtomicLong(0);
  private static IdGenerator instance;
  private IdGenerator() {}
  public static synchronized IdGenerator getInstance() {
    if (instance == null) {
      instance = new IdGenerator();
    }
    return instance;
  }
  public long getId() { 
    return id.incrementAndGet();
  }
}

3. 双重检测(线程安全,延迟加载)

支持延迟加载、又支持高并发的单例实现方式:

在这种实现方式中,只要单例对象被创建之后,调用获取实例函数不会再进入到加锁逻辑中了。所以,这种实现方式解决了懒汉式并发度低的问题。

在对象被new出来,并且赋值给静态成员变量(instance)的时候,还没来得及初始化(执行构造函数中的代码逻辑),就被另一个线程使用了。

要解决这个问题,我们需要给静态 instance 成员变量加上 volatile 关键字,禁止指令重排序才行。

实际上,只有很低版本的 Java 才会有这个问题。我们现在用的高版本的 Java 已经在 JDK 内部实现中解决了这个问题(解决的方法很简单,只要把对象 new 操作和初始化操作设计为原子操作,就自然能禁止重排序)。

JDK 8确实引入了一些优化措施来保证new和初始化操作的原子性,但仍然存在一定程度上的指令重排可能性。

在JDK 5之前的版本中,new操作是非原子性的,可能会导致在多线程环境下出现一些问题。为了解决这个问题,JDK 5引入了volatile关键字,并根据volatile的语义来确保new操作的原子性。

然而,JDK 8之后,JIT编译器进行了一系列的优化,其中包括对new和初始化的优化。这些优化可以使JIT在某些情况下对代码进行指令重排,以提高性能。尽管这些重排不会破坏程序的正确性,但可能会影响到多线程环境的可见性和有序性。

因此,虽然JDK 8确保了new和初始化操作的原子性,但仍然需要使用其他同步机制(例如synchronized或volatile)来确保在多线程环境下的可见性和有序性。

java 复制代码
public class IdGenerator { 
  private AtomicLong id = new AtomicLong(0);
  private static volatile IdGenerator instance;
  private IdGenerator() {}
  public static IdGenerator getInstance() {
    if (instance == null) {
      synchronized(IdGenerator.class) { // 此处为类级别的锁
        if (instance == null) {
          instance = new IdGenerator();
        }
      }
    }
    return instance;
  }
  public long getId() { 
    return id.incrementAndGet();
  }
}
DCL(doublecheck lock,也就是双重锁判断机制) 单例为什么要加volatile?

当 INSTANCE = new SingleInstance() 创建实例对象时,并不是原子操作,它是分三步来完成的:

  1. 创建内存空间。
  2. 执行构造函数,初始化(init)
  3. 将INSTANCE引用指向分配的内存空间

上述正常步骤按照1-->2-->3来执行的,但是,我们知道,JVM为了优化指令,提高程序运行效率,允许指令重排序。正是有了指令重排序的存在,那么就有可能按照1-->3-->2步骤来执行,这时候,当线程a执行步骤3完毕,在执行步骤2之前,被切换到线程b上,这时候instance判断为非空,此时线程b直接来到return instance语句,拿走instance然后使用,接着就顺理成章地报错(对象尚未初始化)。

synchronized虽然保证了线程的原子性(即synchronized块中的语句要么全部执行,要么一条也不执行),但单条语句编译后形成的指令并不是一个原子操作(即可能该条语句的部分指令未得到执行,就被切换到另一个线程了)。

volatile关键字其中一个作用就是禁止指令重排序,所以DCL单例必须要加volatile

volatile作用:

  • 保证被修饰的变量对所有线程的可见性。
  • 禁止指令重排序优化。

4. 静态内部类(线程安全,延迟加载)

静态内部类比双重检测更加简单的实现方法,那就是利用 Java 的静态内部类。它有点类似饿汉式,但又能做到了延迟加载。

当外部类加载的时候,并不会创建静态内部类实例对象。只有当调用静态内部类的方法getInstance()的时候,静态内部类才会被加载,单例对象instance才会被创建。

instance 的唯一性、创建过程的线程安全性,都由 JVM 来保证。所以,这种实现方法既保证了线程安全,又能做到延迟加载。

java 复制代码
public class IdGenerator { 
  private AtomicLong id = new AtomicLong(0);
  private IdGenerator() {}

  private static class SingletonHolder{
    private static final IdGenerator instance = new IdGenerator();
  }
  
  public static IdGenerator getInstance() {
    return SingletonHolder.instance;
  }
 
  public long getId() { 
    return id.incrementAndGet();
  }
}

5. 枚举:最佳实践

通过 Java 枚举类型本身的特性,保证了实例创建的线程安全性和实例的唯一性

枚举实现单例的最佳实践。代码简洁,由jvm保证线程安全和单一实例。可以有效防止序列化和反序列化、反射创建多个实例的情况

饿汉式,懒汉式,双重检查、静态内部类不对外提供构造函数,但是可以通过序列化和反序列化造成多个实例和利用反射创建多个实例;

好处:

  • 不用定义私有构造器;
  • 不用定义获取单例的方法,通过 枚举类.INSTANCE() 就可以获取单例了;

通过idea反编译插件可以发现:

  • 枚举类默认私有构造器
  • 枚举类实例 INSTANCE 是一个static final 静态常量,仅有一份;所以可以直接引用;

总结:

  • 1,Enum枚举子类的类被final修饰,所以无法被子类继承;
  • 2,构造器默认为private,所以不能被其他类实例化;
  • 3,通过反射也是无法实例化枚举类的;
  • 4,线程安全,因为枚举类实例 被 static final修饰,主程序启动时,枚举类实例就已经加载到内存了;
java 复制代码
public enum IdGenerator {
  INSTANCE;
  private AtomicLong id = new AtomicLong(0);
 
  public long getId() { 
    return id.incrementAndGet();
  }
}

使用:IdGenerator.INSTANCE.getId() 
java 复制代码
/**
 * @Description kafka管理器
 *调用:PPKafkaManager.INSTANCE.getOrCreate("clusterName") 
 */
public enum PPKafkaManager {
    /** 单例 */
    INSTANCE;
 
    /** 缓存/kafka集群名-属性配置映射 */
    public final ConcurrentMap<String, Properties> kafkaConfigs = new ConcurrentHashMap<>();
    /**
     * @description 获取或添加属性配置
     * @param clusterName 集群名
     */
    public Properties getOrCreate(String clusterName) {
        // 若存在,直接取走
        if (kafkaConfigs.containsKey(clusterName)) {
            return kafkaConfigs.get(clusterName);
        }
        // 新建kafka属性
        Properties newProps = create();
        Properties oldProps = kafkaConfigs.putIfAbsent(clusterName, newProps);
        // 若旧属性存在,则使用旧属性,丢弃新属性(注意关闭资源)
        if (oldProps != null) {
            // 这里注意关闭资源,其他业务场景可能这里还连接到了kafka集群
            newProps.clear();
        }
        return kafkaConfigs.get(clusterName);
    }
    /**
     * @description 新建kafka属性
     * @return kafka属性
     */
    private Properties create() {
        // 其他属性加工逻辑
        return new Properties();
    }
}

反射破坏单例:

序列化和反序列化、反射创建多个实例的情况通过定义的私有构造方法创建的对象,破坏了单例模式,可以在反序列化的时候返回创建的单例对象,可以在定义的构造函数中判断对象已创建就抛异常

java 复制代码
public class LazyInnerClassSingletonTest {
    public static void main(String[] args) {
        try {
            Class<?> clazz = LazyInnerClassSingleton.class;
            //通过反射回去私有构造方法
            Constructor constructor = clazz.getDeclaredConstructor(null);
            //强制访问
            constructor.setAccessible(true);
            //暴力初始化
            Object o1 = constructor.newInstance();
            //创建两个实例
            Object o2 = constructor.newInstance();
            System.out.println("o1:" + o1);
            System.out.println("o2:" + o2);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

创建了两个实例,违反了单例,现在在构造方法中做一些限制,使得多次重复创建时,抛出异常:

java 复制代码
private LazyInnerClassSingleton() {
        if (LazyHolder.class != null) {
            throw new RuntimeException("不允许创建多个实例");
        }
    }

注册式单例模式

注册式单例模式又称为登记式单例模式,就是将每个实例都登记到某个地方,使用唯一标识获取实例。注册式单例模式有两种:枚举式单例模式、容器式单例模式。

注册式单例模式主要解决通过反序列化破坏单例模式的情况。

1.枚举式单例模式

那枚举式单例是如何解决反序列化得问题呢?

当使用枚举实现单例时,每个枚举常量都被视为一个单例对象。这是因为Java语言规定,枚举类型的实例只能通过枚举常量列表显式创建,而无法通过反射来创建新的实例。即使使用反射获取构造函数并尝试创建新的枚举实例,也会抛出异常Cannot reflectively create enum objects 。

通过反编译,可以发现static{} 代码块,枚举式单例模式在静态代码块中给INSTANCE进行了赋值,是饿汉式单例模式的实现。查看JDK源码可知,枚举类型其实通过类名和类对象找到一个唯一的枚举对象。因此,枚举对象不可能被类加载器加载多次。

容器式单例

spring中使用的就是容器式单例模式, 它的唯一性的作用范围仅仅在同一个 IOC 容器内。

在Spring中,容器式单例模式指的是Spring容器负责管理和创建对象,保证每个被管理的Bean只有一个实例。这种单例模式是通过Spring容器自身的机制来维护的,而不是通过开发者手动实现。

反射是一种机制,可以在运行时检查、访问和修改类、属性、方法等信息。虽然反射可以创建对象并调用私有构造函数,但它不能直接破坏Spring容器中的单例模式。这是因为Spring容器使用了缓存,一旦一个Bean被创建,它会被缓存起来,并且在后续请求中返回相同的实例。

即使你使用反射创建一个新的实例,但在Spring容器范围内,该Bean仍然是单例的。任何对该Bean的请求都将返回由容器管理的同一个实例。

然而,如果你使用反射创建一个新的实例,它将与Spring容器中的实例存在两个完全独立的对象。这可能导致应用程序中产生意外的行为或不一致性,因为其他组件依赖于Spring容器所提供的单例对象。

反射不能直接破坏Spring容器中的单例模式,因为Spring容器本身会确保只有一个实例。但是,如果你手动使用反射创建新的实例,它与Spring容器中的实例将不再是同一个对象,可能会引发问题。因此,通常情况下,最好遵循Spring容器的管理机制,并避免手动使用反射来创建Bean。

java 复制代码
public class ContainerSingleton {
    private ContainerSingleton() {
    }

    private static Map<String, Object> ioc = new ConcurrentHashMap<>();

    public static Object getBean(String className) {
        synchronized (ioc) {
            if (!ioc.containsKey(className)) {
                Object o = null;
                try {
                    o = Class.forName(className).newInstance();
                    ioc.put(className, o);
                } catch (Exception e) {
                    e.printStackTrace();
                }
                return o;
            } else {
                return ioc.get(className);
            }
        }
    }
}

单例模式的缺点:

大部分情况下,我们在项目中使用单例,都是用它来表示一些全局唯一类,比如配置信息类、连接池类、ID 生成器类。单例模式书写简洁、使用方便,在代码中,我们不需要创建对象,直接通过类似 IdGenerator.getInstance().getId() 这样的方法来调用就可以了。但是,这种使用方法有点类似硬编码(hard code),会带来诸多问题。

如果单例类并没有后续扩展的需求,并且不依赖外部系统,那设计成单例类就没有太大问题。对于一些全局的类,我们在其他地方 new 的话,还要在类之间传来传去,不如直接做成单例类,使用起来简洁方便。

单例这种设计模式存在哪些问题?为什么会被称为反模式?如果不用单例,该如何表示全局唯一类?有何替代的解决方案?

1.单例对 OOP 特性的支持不友好

单例这种设计模式对于封装、抽象、继承、多态支持得都不好。

  1. 破坏封装:不需要通过对象,而直接通过类就能访问类的属性和方法,破坏类的封装性;
  2. 不支持继承:单例模式的构造方法是私有的,这个类就没办法被继承(单例类被继承的时候,子类无法调用父类构造方法)
  3. 多态:单例类不是一个接口也无法继承

一旦你选择将某个类设计成到单例类,也就意味着放弃了继承和多态这两个强有力的面向对象特性,也就相当于损失了可以应对未来需求变化的扩展性。

在单例设计模式中,一般情况下是不能被继承的,因为它的构造函数被私有化了,在子类中的构造函数要都会隐式调用父类的默认构造函数super()来完成自己的构造函数。

2. 单例会隐藏类之间的依赖关系

通过构造函数、参数传递等方式声明的类之间的依赖关系,我们通过查看函数的定义,就能很容易识别出来。

单例类不需要显示创建、不需要依赖参数传递,在函数中直接调用就可以了。如果代码比较复杂,这种调用关系就会非常隐蔽。在阅读代码的时候,我们就需要仔细查看每个函数的代码实现,才能知道这个类到底依赖了哪些单例类。

3. 单例对代码的扩展性不友好

单例类在某些情况下会影响代码的扩展性、灵活性。所以,数据库连接池、线程池这类的资源池,最好还是不要设计成单例类。实际上,一些开源的数据库连接池、线程池也确实没有设计成单例类。

实现线程唯一的单例:HashMap和ThreadLocal

"线程唯一"指的是线程内唯一,线程间可以不唯一。

在代码中,我们通过一个 HashMap 来存储对象,其中 key 是线程 ID,value 是对象。这样我们就可以做到,不同的线程对应不同的对象,同一个线程只能对应一个对象。实际上,Java 语言本身提供了 ThreadLocal 工具类,可以更加轻松地实现线程唯一单例。不过,ThreadLocal 底层实现原理也是基于下面代码中所示的 HashMap。

java 复制代码
public class IdGenerator {
  private AtomicLong id = new AtomicLong(0);

  private static final ConcurrentHashMap<Long, IdGenerator> instances
          = new ConcurrentHashMap<>();

  private IdGenerator() {}

  public static IdGenerator getInstance() {
    Long currentThreadId = Thread.currentThread().getId();
    instances.putIfAbsent(currentThreadId, new IdGenerator());
    return instances.get(currentThreadId);
  }

  public long getId() {
    return id.incrementAndGet();
  }
}

ThreadLocal

java 复制代码
**
 * 用户信息上下文
 *
 */
public class UserInfoContextHolder {
	private static final ThreadLocal<AppUser> tenantIdLocal = new ThreadLocal<AppUser>();

	//设置
	public static void setUserInfo(AppUser userInfo) {
		tenantIdLocal.set(userInfo);
	}
        //获取
	public static AppUser getUserInfo() {
		return tenantIdLocal.get();
	}

	//移除
	public static void removeUserInfo() {
		tenantIdLocal.remove();
	}
}

实现集群环境下的单例:外部存储

不同的进程间共享同一个对象,不能创建同一个类的多个对象。

  1. 进程唯一指的是进程内唯一、进程间不唯一。
  2. 线程唯一指的是线程内唯一、线程间不唯一。
  3. 集群相当于多个进程构成的一个集合,"集群唯一"就相当于是进程内唯一、进程间也唯一。

我们需要把这个单例对象序列化并存储到外部共享存储区(比如文件)。进程在使用这个单例对象的时候,需要先从外部共享存储区中将它读取到内存,并反序列化成对象,然后再使用,使用完成之后还需要再存储回外部共享存储区。

为了保证任何时刻,在进程间都只有一份对象存在,一个进程在获取到对象之后,需要对对象加锁,避免其他进程再将其获取。在进程使用完这个对象之后,还需要显式地将对象从内存中删除,并且释放对对象的加锁。

java 复制代码
public class IdGenerator {
  private AtomicLong id = new AtomicLong(0);
  private static IdGenerator instance;
  private static SharedObjectStorage storage = FileSharedObjectStorage(/*入参省略,比如文件地址*/);
  private static DistributedLock lock = new DistributedLock();
  
  private IdGenerator() {}

  public synchronized static IdGenerator getInstance() 
    if (instance == null) {
      lock.lock();
      instance = storage.load(IdGenerator.class);
    }
    return instance;
  }
  
  public synchroinzed void freeInstance() {
    storage.save(this, IdGeneator.class);
    instance = null; //释放对象
    lock.unlock();
  }
  
  public long getId() { 
    return id.incrementAndGet();
  }
}

// IdGenerator使用举例
IdGenerator idGeneator = IdGenerator.getInstance();
long id = idGenerator.getId();
idGenerator.freeInstance();

实现一个多例模式:HashMap

  • "单例"指的是,一个类只能创建一个对象。
  • "多例"指的就是,一个类可以创建多个对象,但是个数是有限制的,比如只能创建 3 个对象。

多例的实现也比较简单,通过一个 Map 来存储对象类型和对象之间的对应关系,来控制对象的个数:

java 复制代码
public class BackendServer {
  private long serverNo;
  private String serverAddress;

  private static final int SERVER_COUNT = 3;
  private static final Map<Long, BackendServer> serverInstances = new HashMap<>();

  static {
    serverInstances.put(1L, new BackendServer(1L, "192.134.22.138:8080"));
    serverInstances.put(2L, new BackendServer(2L, "192.134.22.139:8080"));
    serverInstances.put(3L, new BackendServer(3L, "192.134.22.140:8080"));
  }

  private BackendServer(long serverNo, String serverAddress) {
    this.serverNo = serverNo;
    this.serverAddress = serverAddress;
  }

  public BackendServer getInstance(long serverNo) {
    return serverInstances.get(serverNo);
  }

  public BackendServer getRandomInstance() {
    Random r = new Random();
    int no = r.nextInt(SERVER_COUNT)+1;
    return serverInstances.get(no);
  }
}

单例与多线程:

单例模式饿汉式、枚举、静态内部类通过类只初始化一次来创建对象保证线程安全;

懒汉式和双重检查通过加类锁保证创建对象只有一次来实现线程安全;

如果没有类锁,就会多次创建对象给共享成员变量赋值,共享资源的状态被改变了,这样线程就不安全的;

对象在多线程是可以共享的:我们调用对象的方法的时候通过对象引用找到对象的方法,单例对象只不过引用的是同一个对象;只要对象的方法不改变共享资源的状态就是线程安全的;

在多线程中的单例对象,要保证线程安全,就不能有成员变量,一般都需要线程安全

单例为何能够处理多线程的任务

Spring中默认类的实例都是单例的,SpringMVC中用来处理http请求的Controller是基于Servlet实现的,也是单例的。

单例模式可以处理多线程任务,因为在同一个进程中,单例对象只有一个实例存在,多个线程共享这个实例。这确保了多个线程访问单例对象时的数据一致性。

在Spring中,默认情况下,类的实例都是单例的。这意味着每次通过Spring容器获取这些实例时,都会得到同一个实例对象。这样做有助于提高应用程序的性能和资源利用率,尤其是对于那些被频繁使用或开销较大的对象。

在Spring MVC中,控制器(Controller)是基于Servlet实现的,并且默认情况下也是单例的。这意味着所有的HTTP请求将由同一个控制器实例处理。

然而,尽管控制器是单例的,Spring MVC仍然能够同时处理多个请求的原因是每个请求都在独立的线程中进行处理。当一个请求到达时,Spring MVC框架会创建一个新的线程来处理该请求,该线程将独立地执行控制器中的逻辑,不会影响其他请求的处理。

这种方式称为"线程安全",即每个请求在自己的线程上独立执行,互相之间不会干扰或影响。这样就可以同时处理多个请求,每个请求都有自己独立的控制器实例和线程,彼此之间互不干扰。

每个请求都是由一个线程来处理,我们也就可以明白一个服务器同时能够处理的请求数与它的线程数有关系。线程的创建是比较消耗资源的,所以容器一般维持一个线程池。像Tomcat的线程池 maxThreads 是200, minSpareThreads 是25。实际中单个Tomcat服务器的最大并发数只有几百,有一部分原因就是只能同时处理这么多线程上的任务。

在Spring中,单例模式确保了类的唯一实例,提高了性能和资源利用率。在Spring MVC中,通过每个请求在独立的线程中处理,即使控制器是单例的,也能够同时处理多个请求,保证每个请求的独立性和线程安全性。

spring中的单例 也不影响应用并发访问。大多数时候客户端都在访问我们应用中的业务对象,为减少并发控制,不应该在业务对象中设置那些容易造成出错的成员变量

java 多线程调用单例类的同一个方法

为什么spring单例模式可以支持多线程并发访问?

  1. spring单例模式是指,在内存中只实例化一个类的对象
  2. 类的变量有线程安全的问题,就是有get和set方法的类成员属性。执行单例对象的方法不会有线程安全的问题
  3. 因为方法是磁盘上的一段代码,每个线程在执行这段代码的时候,会自己去内存申请临时变量

成员变量会受到多线程影响

成员变量(实例变量)会受到多线程影响是因为多个线程可以同时访问和修改这些变量。当多个线程并发地读取、写入或修改同一个变量时,可能会导致以下问题:

  1. 竞态条件(Race Condition):如果多个线程同时读取和写入同一个变量,最终的结果可能是不确定的。这是由于每个线程的操作顺序和时间片分配无法预测,导致操作的执行顺序出现混乱。
  2. 内存一致性错误(Memory Consistency Errors):当一个线程修改了某个变量的值,但其他线程没有及时看到该变化,就会出现内存一致性错误。这可能导致某些线程基于过期的或不一致的数据进行操作。
  3. 并发访问冲突(Concurrent Access Conflict):如果多个线程同时对同一个变量进行写操作,可能会导致数据丢失或损坏。这是因为写操作通常需要多个步骤,而中间的操作可能被其他线程中断,破坏了原子性。

为了解决这些问题,需要采取适当的并发控制机制,如使用锁(synchronized关键字)、并发集合类(ConcurrentHashMap、ConcurrentLinkedQueue等)、原子类(AtomicInteger、AtomicReference等)等来确保线程安全。

在多线程环境下,保护共享的成员变量免受并发访问的影响是非常重要的,以避免数据不一致或损坏。合适的同步机制和并发控制方法可以确保线程安全,并解决多线程环境中的竞态条件和内存一致性问题。

为什么局部变量不会受多线程影响?

1、对于那些会以多线程运行的单例类,例如Web应用中的Servlet,每个方法中对局部变量的操作都是在线程自己独立的内存区域内完成的,所以是线程安全的 2、局部变量不会受多线程影响 3、成员变量会受到多线程影响 4、对于成员变量的操作,可以使用ThreadLocal来保证线程安全

JVM是如何实现线程的独立内存空间?

Java中的栈 1、每当启用一个线程时,JVM就为他分配一个Java栈,栈是以帧为单位保存当前线程的运行状态。某个线程正在执行的方法称为当前方法,当前方法使用的栈帧称为当前帧,当前方法所属的类称为当前类,当前类的常量池称为当前常量池。当线程执行一个方法时,它会跟踪当前常量池。 2、每当线程调用一个Java方法时,JVM就会在该线程对应的栈中压入一个帧,这个帧自然就成了当前帧。当执行这个方法时,它使用这个帧来存储参数、局部变量、中间运算结果等等。 3、Java栈上的所有数据都是私有的。任何线程都不能访问另一个线程的栈数据。所以我们不用考虑多线程情况下栈数据访问同步的情况。

适用场景:

对于 Java来说,单例模式可以保证在一个 JVM 中只存在单一实例。单例模式的应用场景主要有以下几个方面。

  • 需要频繁创建的一些类,使用单例可以降低系统的内存压力,减少 GC。
  • 某类只要求生成一个对象的时候,如一个班中的班长、每个人的身份证号等。
  • 某些类创建实例时占用资源较多,或实例化耗时较长,且经常使用。
  • 某类需要频繁实例化,而创建的对象又频繁被销毁的时候,如多线程的线程池、网络连接池等。
  • 频繁访问数据库或文件的对象。
  • 对于一些控制硬件级别的操作,或者从系统上来讲应当是单一控制逻辑的操作,如果有多个实例,则系统会完全乱套。
  • 当对象需要被共享的场合。由于单例模式只允许创建一个对象,共享该对象可以节省内存,并加快对象访问速度。如 Web 中的配置对象、数据库的连接池等。

实现全局唯一类

在有些系统中,为了节省内存资源、保证数据内容的一致性,对某些类要求只能创建一个实例。例如,Windows 中只能打开一个任务管理器,这样可以避免因打开多个任务管理器窗口而造成内存资源的浪费,或出现各个窗口显示内容的不一致等错误。

在计算机系统中,还有 Windows 的回收站、操作系统中的文件系统、多线程中的线程池、显卡的驱动程序对象、打印机的后台处理服务、应用程序的日志对象、数据库的连接池、网站的计数器、Web 应用的配置对象、应用程序中的对话框、系统中的缓存等常常被设计成单例。

单例模式在现实生活中的应用也非常广泛,例如公司 CEO、部门经理等都属于单例模型。J2EE 标准中的 ServletContext 和 ServletContextConfig、Spring框架应用中的 ApplicationContext、数据库中的连接池等也都是单例模式。

从业务概念上,有些数据在系统中只应该保存一份,就比较适合设计为单例类。比如,系统的配置信息类。除此之外,我们还可以使用单例解决资源访问冲突的问题。

需要频繁的进行创建和销毁的对象、创建对象时耗时过多或耗费资源过多,还可以节省系统资源

应用场景比如:应用配置、日志文件写日志、系统配置信息、网站计数器、任务管理器、回收站

XStream内存泄露和性能问题

XStream是线程安全的,不需要重复初始化xstream对象,每一种类型实例化一个对象即可,而正是由于开发人员错误地在每次处理请求时都实例化一个新的xstream对象,没有把相同类型的缓存起来使用,才导致了该性能问题。

实战案例:表示全局唯一类

从业务概念上,如果有些数据在系统中只应保存一份,那就比较适合设计为单例类。

比如,配置信息类。在系统中,我们只有一个配置文件,当配置文件被加载到内存之后,以对象的形式存在,也理所应当只有一份。

比如创建用户上下文对象(ThreadLocal)是一个全局唯一类,所有的线程都在这个对象中获取各自线程的用户信息;

java 复制代码
/**
 * 用户信息上下文
 *
 */
public class UserInfoContextHolder {
	private static final ThreadLocal<AppUser> tenantIdLocal = new ThreadLocal<AppUser>();

	//设置
	public static void setUserInfo(AppUser userInfo) {
		tenantIdLocal.set(userInfo);
	}
        //获取
	public static AppUser getUserInfo() {
		return tenantIdLocal.get();
	}

	//移除
	public static void removeUserInfo() {
		tenantIdLocal.remove();
	}
}

spring中ioc管理对象单例的实现:

单例模式在ApplicationContext:

提供了全局的访问点BeanFactory。但没有从构造器级别去控制单例,这是因为spring管理的是任意的java对象。

Spring依赖注入Bean实例默认是单例的。

Spring的依赖注入(包括lazy-init方式)都是发生在AbstractBeanFactory的getBean里。getBean的doGetBean方法调用getSingleton进行bean的创建。

分析getSingleton()方法:

java 复制代码
public Object getSingleton(String beanName){
     //参数true设置标识允许早期依赖
     return getSingleton(beanName,true);
 }
 protected Object getSingleton(String beanName, boolean allowEarlyReference) {
     //检查缓存中是否存在实例
     Object singletonObject = this.singletonObjects.get(beanName);
     if (singletonObject == null && isSingletonCurrentlyInCreation(beanName)) {
         //如果为空,则锁定全局变量并进行处理。
         synchronized (this.singletonObjects) {
             //如果此bean正在加载,则不处理
             singletonObject = this.earlySingletonObjects.get(beanName);
             if (singletonObject == null && allowEarlyReference) {
                 //当某些方法需要提前初始化的时候则会调用addSingleFactory 方法将对应的ObjectFactory初始化策略存储在singletonFactories
                 ObjectFactory<?> singletonFactory = this.singletonFactories.get(beanName);
                 if (singletonFactory != null) {
                     //调用预先设定的getObject方法
                     singletonObject = singletonFactory.getObject();
                     //记录在缓存中,earlysingletonObjects和singletonFactories互斥
                     this.earlySingletonObjects.put(beanName, singletonObject);
                     this.singletonFactories.remove(beanName);
                 }
             }
         }
     }
     return (singletonObject != NULL_OBJECT ? singletonObject : null);
 }

getSingleton()的工作流程:

singletonObjects-->earlySingletonObjects-->singletonFactories-->创建单例实例

spring依赖注入时,使用了双重检查锁实现的单例模式

controller如何实现单例

对于一个浏览器请求,tomcat会指定一个处理线程,或是在线程池中选取空闲的,或者新建一个线程。

在Tomcat容器中,每个servlet是单例的。在SpringMVC中,Controller 默认也是单例。 采用单例模式的最大好处,就是可以在高并发场景下极大地节省内存资源,提高服务抗压能力。

单例模式容易出现的问题是:在Controller中定义的实例变量,在多个请求并发时会出现竞争访问,Controller中的实例变量不是线程安全的。

Controller并发安全的解决办法

  • 尽量不要在 Controller 中定义成员变量
  • 如果必须要定义一个非静态成员变量,那么可以通过注解 @Scope("prototype") ,将Controller设置为多例模式。
java 复制代码
@Controller
@Scope(value="prototype")
public class TestController {
    private int num = 0;
 
    @RequestMapping("/addNum")
    public void addNum() {
        System.out.println(++num);
    }
}

Scope属性是用来声明IOC容器中的对象(Bean )允许存在的限定场景,或者说是对象的存活空间。在对象进入相应的使用场景之前,IOC容器会生成并装配这些对象;当该对象不再处于这些使用场景的限定时,容器通常会销毁这些对象。

Controller也是一个Bean,默认的 Scope 属性为Singleton ,也就是单例模式。如果Bean的 Scope 属性设置为 prototype 的话,容器在接受到该类型对象的请求时,每次都会重新生成一个新的对象给请求方。

  • Controller 中使用 ThreadLocal 变量。每一个线程都有一个变量的副本。
java 复制代码
public class TestController {
    private int num = 0;
    private final ThreadLocal <Integer> uniqueNum =
             new ThreadLocal <Integer> () {
                 @Override protected Integer initialValue() {
                     return num;
                 }
             };
 
    @RequestMapping("/addNum")
    public void addNum() {
        int unum = uniqueNum.get();
       uniqueNum.set(++unum);
       System.out.println(uniqueNum.get());
    }
}

更严格的做法是用AtomicInteger类型定义成员变量,对于成员变量的操作使用AtomicInteger的自增方法完成。

总的来说,还是尽量不要在 Controller 中定义成员变量为好。

springIOC容器注入对象默认单例模式在IOC容器唯一,controller,service,dao由spring注入的对象也是单例唯一的,这些对象一般是无状态的是线程安全的,调用方法中的成员变量一次一个线程,不是共享的,也是线程安全的

相关推荐
ningqw5 小时前
SpringBoot 常用跨域处理方案
java·后端·springboot
你的人类朋友5 小时前
vi编辑器命令常用操作整理(持续更新)
后端
胡gh5 小时前
简单又复杂,难道只能说一个有箭头一个没箭头?这种问题该怎么回答?
javascript·后端·面试
一只叫煤球的猫6 小时前
看到同事设计的表结构我人麻了!聊聊怎么更好去设计数据库表
后端·mysql·面试
uzong6 小时前
技术人如何对客做好沟通(上篇)
后端
颜如玉7 小时前
Redis scan高位进位加法机制浅析
redis·后端·开源
Moment7 小时前
毕业一年了,分享一下我的四个开源项目!😊😊😊
前端·后端·开源
why技术8 小时前
在我眼里,这就是天才般的算法!
后端·面试
绝无仅有8 小时前
Jenkins+docker 微服务实现自动化部署安装和部署过程
后端·面试·github
程序视点8 小时前
Escrcpy 3.0投屏控制软件使用教程:无线/有线连接+虚拟显示功能详解
前端·后端