天天看点

Java性能优化十一、设计模式优化设计模式优化

Java性能优化十一、设计模式优化设计模式优化

文章目录

  • 设计模式优化
    • 一、单例模式:如何创建单一对象优化系统性能
      • 1、什么是单例模式
      • 2、饿汉模式
      • 3、懒汉模式
      • 4、通过内部类实现懒汉模式
      • 总结
    • 二、原型模式与享元模式:提升系统性能的利器
      • 1、原型模式
        • 1.深拷贝和浅拷贝
        • 2.适用场景
      • 2、享元模式
      • 总结

设计模式优化

一、单例模式:如何创建单一对象优化系统性能

在《Design Patterns: Elements of Reusable Object-Oriented Software》一书中,有 23 种设计模式的描述,其中,单例设计模式是最常用的设计模式之一。无论是在开源框架,还是在我们的日常开发中,单例模式几乎无处不在。

1、什么是单例模式

单例模式可以保证一个类仅创建一个实例,并提供一个访问它的全局访问点,该模式有三个基本要点:

  • 一是这个类只能有一个实例;
  • 二是它必须自行创建这个实例;
  • 三是它必须自行向整个系统提供这个实例。

结合这三点,我们来实现一个简单的单例:

// 饿汉模式
public final class Singleton {
    private static Singleton instance = new Singleton(); // 自行创建实例
    private Singleton(){}   // 构造函数
    public static Singleton getInstance(){ // 通过该函数向整个系统提供实例
        return instance;
    }
}
           

由于在一个系统中,一个类经常会被使用在不同的地方,通过单例模式,我们可以避免多次创建多个实例,从而节约系统资源。

2、饿汉模式

以上第一种实现单例的代码中,使用了 static 修饰了成员变量 instance,所以该变量会在类初始化的过程中被收集进类构造器即 <clinit> 方法中。在多线程场景下,JVM 会保证只有一个线程能执行该类的 <clinit> 方法,其它线程将会被阻塞等待。

等到唯一的一次 <clinit> 方法执行完成,其它线程将不会再执行 <clinit> 方法,转而执行自己的代码。也就是说,static 修饰了成员变量 instance,在多线程的情况下能保证只实例化一次。这种方式实现的单例模式,在类加载阶段就已经在堆内存中开辟了一块内存,用于存放实例化对象,所以也称为饿汉模式。

  • 饿汉模式实现的单例的优点是,可以保证多线程情况下实例的唯一性,而且 getInstance 直接返回唯一实例,性能非常高。
  • 然而,在类成员变量比较多,或变量比较大的情况下,这种模式可能会在没有使用类对象的情况下,一直占用堆内存。试想下,如果一个第三方开源框架中的类都是基于饿汉模式实现的单例,这将会初始化所有单例类,无疑是灾难性的。

3、懒汉模式

懒汉模式就是为了避免直接加载类对象时提前创建对象的一种单例设计模式。该模式使用懒加载方式,只有当系统使用到类对象时,才会将实例加载到堆内存中。通过以下代码,我们可以简单地了解下懒加载的实现方式:

// 懒汉模式
public final class Singleton {
    private static Singleton instance = null;  // 不实例化
    private Singleton(){}   // 构造函数
    public static Singleton getInstance(){   // 通过该函数向整个系统提供实例
        if(null == instance){   // 当 instance 为 null 时,则实例化对象,否则直接返回对象
            instance = new Singleton();   // 实例化对象
        }
        return instance;   // 返回已存在的对象
    }
}
           
以上代码在单线程下运行是没有问题的,但要运行在多线程下,就会出现实例化多个类对象的情况。这是怎么回事呢?

当线程 A 进入到 if 判断条件后,开始实例化对象,此时 instance 依然为 null;又有线程 B 进入到 if 判断条件中,之后也会通过条件判断,进入到方法里面创建一个实例对象。

所以我们需要对该方法进行加锁,保证多线程情况下仅创建一个实例。这里我们使用 Synchronized 同步锁来修饰 getInstance 方法:

// 懒汉模式 + synchronized 同步锁
public final class Singleton {
    private static Singleton instance= null; // 不实例化
    private Singleton(){} // 构造函数
    public static synchronized Singleton getInstance(){  // 加同步锁,通过该函数向整个系统提供实例对象
        if(null == instance){   // 当 instance 为 null 时,则实例化对象,否则直接返回对象
            instance = new Singleton();   // 实例化对象
        }
        return instance;   // 返回已存在的对象
    }
}
           

但我们前面讲过,同步锁会增加锁竞争,带来系统性能开销,从而导致系统性能下降,因此这种方式也会降低单例模式的性能。

还有,每次请求获取类对象时,都会通过 getInstance() 方法获取,除了第一次为 null,其它每次请求基本都是不为 null 的。在没有加同步锁之前,是因为 if 判断条件为 null 时,才导致创建了多个实例。

基于以上两点,我们可以考虑将同步锁放在 if 条件里面,这样就可以减少同步锁资源竞争。

// 懒汉模式 + synchronized 同步锁
public final class Singleton {
    private static Singleton instance= null;   // 不实例化
    private Singleton(){}   // 构造函数
    public static Singleton getInstance(){   // 加同步锁,通过该函数向整个系统提供实例
        if(null == instance){    // 当 instance 为 null 时,则实例化对象,否则直接返回对象
            synchronized (Singleton.class){
                instance = new Singleton();   // 实例化对象 
            } 
        }
        return instance;   // 返回已存在的对象
    }
}
           

看到这里,你是不是觉得这样就可以了呢?答案是依然会创建多个实例。

这是因为当多个线程进入到 if 判断条件里,虽然有同步锁,但是进入到判断条件里面的线程依然会依次获取到锁创建对象,然后再释放同步锁。所以我们还需要在同步锁里面再加一个判断条件:

// 懒汉模式 + synchronized 同步锁 + double-check
public final class Singleton {
    private static Singleton instance= null;   // 不实例化
    private Singleton(){}   // 构造函数
    public static Singleton getInstance(){   // 加同步锁,通过该函数向整个系统提供实例
        if(null == instance){     // 第一次判断,当 instance 为 null 时,则实例化对象,否则直接返
            synchronized (Singleton.class){     // 同步锁
                if(null == instance){      // 第二次判断
                    instance = new Singleton();    // 实例化对象
                }
            }
        }
        return instance;   // 返回已存在的对象
    }
}
           

以上这种方式,通常被称为 Double-Check,它可以大大提高支持多线程的懒汉模式的运行性能。那这样做是不是就能保证万无一失了呢?还会有什么问题吗?

其实这里又跟 Happens-Before 规则和重排序扯上关系了,这里我们先来简单了解下 Happens-Before 规则和重排序。

编译器为了尽可能地减少寄存器的读取、存储次数,会充分复用寄存器的存储值。比如以下代码,如果没有进行重排序优化,正常的执行顺序是步骤 1\2\3,而在编译期间进行了重排序优化之后,执行的步骤有可能就变成了步骤 1/3/ 2,这样就能减少一次寄存器的存取次数。

int a = 1;// 步骤 1:加载 a 变量的内存地址到寄存器中,加载 1 到寄存器中
int b = 2;// 步骤 2:加载 b 变量的内存地址到寄存器中,加载 2 到寄存器中
a = a + 1;// 步骤 3:重新加载 a 变量的内存地址到寄存器中,再加载 1 到寄存器中
           

在 JMM 中,重排序是十分重要的一环,特别是在并发编程中。如果 JVM 可以对它们进行任意排序以提高程序性能,也可能会给并发编程带来一系列的问题。例如,我上面讲到的 Double-Check 的单例问题,假设类中有其它的属性也需要实例化,这个时候,除了要实例化单例类本身,还需要对其它属性也进行实例化:

// 懒汉模式 + synchronized 同步锁 + double-check
public final class Singleton {
    private static Singleton instance= null;    // 不实例化
    public List<String> list = null;     //list 属性
    private Singleton(){
        list = new ArrayList<String>();
    }      // 构造函数
    public static Singleton getInstance(){    // 加同步锁,通过该函数向整个系统提供实例
        if(null == instance){      // 第一次判断,当 instance 为 null 时,则实例化对象,否则直接返
            synchronized (Singleton.class){     // 同步锁
                if(null == instance){     // 第二次判断
                    instance = new Singleton();    // 实例化对象
                }
            } 
        }
        return instance;    // 返回已存在的对象
    }
}
           

在执行 instance = new Singleton(); 代码时,正常情况下,实例过程这样的:

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

如果虚拟机发生了重排序优化,这个时候步骤 3 可能发生在步骤 2 之前。如果初始化线程刚好完成步骤 3,而步骤 2 没有进行时,则刚好有另一个线程到了第一次判断,这个时候判断为非 null,并返回对象使用,这个时候实际没有完成其它属性的构造,因此使用这个属性就很可能会导致异常。在这里,Synchronized 只能保证可见性、原子性,无法保证执行的顺序。

这个时候,就体现出 Happens-Before 规则的重要性了。通过字面意思,你可能会误以为是前一个操作发生在后一个操作之前。然而真正的意思是,前一个操作的结果可以被后续的操作获取。这条规则规范了编译器对程序的重排序优化。

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

// 懒汉模式 + synchronized 同步锁 + double-check
public final class Singleton {
    private volatile static Singleton instance= null;   // 不实例化
    public List<String> list = null;   //list 属性
    private Singleton(){
        list = new ArrayList<String>();
    }    // 构造函数
    public static Singleton getInstance(){   // 加同步锁,通过该函数向整个系统提供实例
        if(null == instance){    // 第一次判断,当 instance 为 null 时,则实例化对象,否则直接返
            synchronized (Singleton.class){   // 同步锁
                if(null == instance){   // 第二次判断
                    instance = new Singleton();   // 实例化对象
                }
            } 
        }
        return instance;   // 返回已存在的对象
    }
}
           

4、通过内部类实现懒汉模式

以上这种同步锁 +Double-Check 的实现方式相对来说,复杂且加了同步锁,那有没有稍微简单一点儿的可以实现线程安全的懒加载方式呢?

我们知道,在饿汉模式中,我们使用了 static 修饰了成员变量 instance,所以该变量会在类初始化的过程中被收集进类构造器即 <clinit> 方法中。在多线程场景下,JVM 会保证只有一个线程能执行该类的 <clinit> 方法,其它线程将会被阻塞等待。这种方式可以保证内存的可见性、顺序性以及原子性。

如果我们在 Singleton 类中创建一个内部类来实现成员变量的初始化,则可以避免多线程下重复创建对象的情况发生。这种方式,只有在第一次调用 getInstance() 方法时,才会加载 InnerSingleton 类,而只有在加载 InnerSingleton 类之后,才会实例化创建对象。具体实现如下:

// 懒汉模式 内部类实现
public final class Singleton {
    public List<String> list = null;  // list 属性

    private Singleton() {   // 构造函数
        list = new ArrayList<String>();
    }

    // 内部类实现
    public static class InnerSingleton {
        private static Singleton instance = new Singleton();   // 自行创建实例
    }

    public static Singleton getInstance() {
        return InnerSingleton.instance;   // 返回内部类中的静态变量
    }
}
           

总结

单例的实现方式其实有很多,但总结起来就两种:饿汉模式和懒汉模式,我们可以根据自己的需求来做选择。

  • 如果我们在程序启动后,一定会加载到类,那么用饿汉模式实现的单例简单又实用;
  • 如果我们是写一些工具类,则优先考虑使用懒汉模式,因为很多项目可能会引用到 jar 包,但未必会使用到这个工具类,懒汉模式实现的单例可以避免提前被加载到内存中,占用系统资源。

二、原型模式与享元模式:提升系统性能的利器

原型模式和享元模式,前者是在创建多个实例时,对创建过程的性能进行调优;后者是用减少创建实例的方式,来调优系统性能。这么看,你会不会觉得两个模式有点相互矛盾呢?

其实不然,它们的使用是分场景的。

  • 在有些场景下,我们需要重复创建多个实例,例如在循环体中赋值一个对象,此时我们就可以采用原型模式来优化对象的创建过程;
  • 而在有些场景下,我们则可以避免重复创建多个实例,在内存中共享对象就好了。
今天我们就来看看这两种模式的适用场景,了解了这些你就可以更高效地使用它们提升系统性能了。

1、原型模式

原型模式是通过给出一个原型对象来指明所创建的对象的类型,然后使用自身实现的克隆接口来复制这个原型对象,该模式就是用这种方式来创建出更多同类型的对象。

使用这种方式创建新的对象的话,就无需再通过 new 实例化来创建对象了。这是因为 Object 类的 clone 方法是一个本地方法,它可以直接操作内存中的二进制流,所以性能相对 new 实例化来说更佳。

我们现在通过一个简单的例子来实现一个原型模式:

// 实现 Cloneable 接口的原型抽象类 Prototype 
class Prototype implements Cloneable {
    // 重写 clone 方法
    public Prototype clone(){
        Prototype prototype = null;
        try{
            prototype = (Prototype)super.clone();
        }catch(CloneNotSupportedException e){
            e.printStackTrace();
        }
        return prototype;
    }
}
// 实现原型类
class ConcretePrototype extends Prototype{
    public void show(){
        System.out.println(" 原型模式实现类 ");
    }
}
public class Client {
    public static void main(String[] args){
        ConcretePrototype cp = new ConcretePrototype();
        for(int i=0; i< 10; i++){
            ConcretePrototype clonecp = (ConcretePrototype)cp.clone();
            clonecp.show();
        }
    }
}
           

要实现一个原型类,需要具备三个条件:

  • 实现 Cloneable 接口:Cloneable 接口与序列化接口的作用类似,它只是告诉虚拟机可以安全地在实现了这个接口的类上使用 clone 方法。在 JVM 中,只有实现了 Cloneable 接口的类才可以被拷贝,否则会抛出 CloneNotSupportedException 异常。
  • 重写 Object 类中的 clone 方法:在 Java 中,所有类的父类都是 Object 类,而 Object 类中有一个 clone 方法,作用是返回对象的一个拷贝。
  • 在重写的 clone 方法中调用 super.clone():默认情况下,类不具备复制对象的能力,需要调用 super.clone() 来实现。

从上面我们可以看出,原型模式的主要特征就是使用 clone 方法复制一个对象。通常,有些人会误以为 Object a=new Object();Object b=a; 这种形式就是一种对象复制的过程,然而这种复制只是对象引用的复制,也就是 a 和 b 对象指向了同一个内存地址,如果 b 修改了,a 的值也就跟着被修改了。

我们可以通过一个简单的例子来看看普通的对象复制问题:

class Student {  
    private String name;  

    public String getName() {  
        return name;  
    }  

    public void setName(String name) {  
        this.name= name;  
    }  

}  
public class Test {  
    public static void main(String args[]) {  
        Student stu1 = new Student();  
        stu1.setName("test1");  

        Student stu2 = stu1;  
        stu1.setName("test2");  

        System.out.println(" 学生 1:" + stu1.getName());  
        System.out.println(" 学生 2:" + stu2.getName());  
    }  
}
           

如果是复制对象,此时打印的日志应该为:

学生 1:test1
学生 2:test2
           

然而,实际上是:

学生 2:test2
学生 2:test2
           

通过 clone 方法复制的对象才是真正的对象复制,clone 方法赋值的对象完全是一个独立的对象。刚刚讲过了,Object 类的 clone 方法是一个本地方法,它直接操作内存中的二进制流,特别是复制大对象时,性能的差别非常明显。我们可以用 clone 方法再实现一遍以上例子。

// 学生类实现 Cloneable 接口
class Student implements Cloneable{  
    private String name;  // 姓名

    public String getName() {  
        return name;  
    }  

    public void setName(String name) {  
        this.name= name;  
    } 
    // 重写 clone 方法
    public Student clone() { 
        Student student = null; 
        try { 
            student = (Student) super.clone(); 
        } catch (CloneNotSupportedException e) { 
            e.printStackTrace(); 
        } 
        return student; 
    } 
} 
           

1.深拷贝和浅拷贝

在调用 super.clone() 方法之后,首先会检查当前对象所属的类是否支持 clone,也就是看该类是否实现了 Cloneable 接口。

如果支持,则创建当前对象所属类的一个新对象,并对该对象进行初始化,使得新对象的成员变量的值与当前对象的成员变量的值一模一样,但对于其它对象的引用以及 List 等类型的成员属性,则只能复制这些对象的引用了。所以简单调用 super.clone() 这种克隆对象方式,就是一种浅拷贝。

所以,当我们在使用 clone() 方法实现对象的克隆时,就需要注意浅拷贝带来的问题。我们再通过一个例子来看看浅拷贝。
// 定义学生类
@Data
class Student implements Cloneable{  
    private String name; // 学生姓名
    private Teacher teacher; // 定义老师类
    // 重写克隆方法
    public Student clone() { 
        Student student = null; 
        try { 
            student = (Student) super.clone(); 
        } catch (CloneNotSupportedException e) { 
            e.printStackTrace(); 
        } 
        return student; 
    } 
}
// 定义老师类
@Data
class Teacher implements Cloneable{  
    private String name;  // 老师姓名
    // 重写克隆方法,堆老师类进行克隆
    public Teacher clone() { 
        Teacher teacher= null; 
        try { 
            teacher= (Teacher) super.clone(); 
        } catch (CloneNotSupportedException e) { 
            e.printStackTrace(); 
        } 
        return student; 
    } 
}
public class Test {  
    public static void main(String args[]) {
        Teacher teacher = new Teacher (); // 定义老师 1
        teacher.setName(" 刘老师 ");
        Student stu1 = new Student();  // 定义学生 1
        stu1.setName("test1");           
        stu1.setTeacher(teacher);

        Student stu2 = stu1.clone(); // 定义学生 2
        stu2.setName("test2");  
        stu2.getTeacher().setName(" 王老师 ");  // 修改老师
        System.out.println(" 学生 " + stu1.getName + " 的老师是:" + stu1.getTeacher().getName);
        System.out.println(" 学生 " + stu2.getName + " 的老师是:" + stu2.getTeacher().getName);
    }
           

运行结果:

学生 test1 的老师是:王老师
学生 test2 的老师是:王老师
           

观察以上运行结果,我们可以发现:在我们给学生 2 修改老师的时候,学生 1 的老师也跟着被修改了。这就是浅拷贝带来的问题。

我们可以通过深拷贝来解决这种问题,其实深拷贝就是基于浅拷贝来递归实现具体的每个对象,代码如下:

public Student clone() { 
    Student student = null; 
    try { 
        student = (Student) super.clone(); 
        Teacher teacher = this.teacher.clone();// 克隆 teacher 对象
        student.setTeacher(teacher);
    } catch (CloneNotSupportedException e) { 
        e.printStackTrace(); 
    } 
    return student; 
} 
           

2.适用场景

前面我们详讲了原型模式的实现原理,那到底什么时候我们要用它呢?

在一些重复创建对象的场景下,我们就可以使用原型模式来提高对象的创建性能。比如,我在开头提到的,循环体内创建对象时,我们就可以考虑用 clone 的方式来实现。例如:

for(int i=0; i<list.size(); i++){
    Student stu = new Student(); 
    ...
}
           

我们可以优化为:

Student stu = new Student(); 
for(int i=0; i<list.size(); i++){ 
    Student stu1 = (Student)stu.clone(); 
    ...
}
           

除此之外,原型模式在开源框架中的应用也非常广泛。例如 Spring 中,@Service 默认都是单例的。使用了私有全局变量,若不想影响下次注入或每次从上下文获取 bean,就需要用到原型模式,我们可以通过以下注解来实现,@Scope(“prototype”)。

2、享元模式

享元模式是运用共享技术有效地最大限度地复用细粒度对象的一种模式。该模式中,以对象的信息状态划分,可以分为内部数据和外部数据。内部数据是对象可以共享出来的信息,这些信息不会随着系统的运行而改变;外部数据则是在不同运行时被标记了不同的值。

享元模式一般可以分为三个角色,分别为 Flyweight(抽象享元类)、ConcreteFlyweight(具体享元类)和 FlyweightFactory(享元工厂类)。抽象享元类通常是一个接口或抽象类,向外界提供享元对象的内部数据或外部数据;具体享元类是指具体实现内部数据共享的类;享元工厂类则是主要用于创建和管理享元对象的工厂类。

我们还是通过一个简单的例子来实现一个享元模式:

// 抽象享元类
interface Flyweight {
    // 对外状态对象
    void operation(String name);
    // 对内对象
    String getType();
}
           
// 具体享元类
class ConcreteFlyweight implements Flyweight {
    private String type;

    public ConcreteFlyweight(String type) {
        this.type = type;
    }

    @Override
    public void operation(String name) {
        System.out.printf("[类型 (内在状态)] - [%s] - [名字 (外在状态)] - [%s]\n", type, n);
    }

    @Override
    public String getType() {
        return type;
    }
}
           
// 享元工厂类
class FlyweightFactory {
    private static final Map<String, Flyweight> FLYWEIGHT_MAP = new HashMap<>();  // 享元池

    public static Flyweight getFlyweight(String type) {
        if (FLYWEIGHT_MAP.containsKey(type)) {   // 如果在享元池中存在对象,则直接获取
            return FLYWEIGHT_MAP.get(type);
        } else {    // 在响应池不存在,则新创建对象,并放入到享元池
            ConcreteFlyweight flyweight = new ConcreteFlyweight(type);
            FLYWEIGHT_MAP.put(type, flyweight);
            return flyweight;
        }
    }
}
           

如果对象已经存在于享元池中,则不会再创建该对象了,而是共用享元池中内部数据一致的对象。这样就减少了对象的创建,同时也节省了同样内部数据的对象所占用的内存空间。

适用场景

享元模式在实际开发中的应用也非常广泛。例如 Java 的 String 字符串,在一些字符串常量中,会共享常量池中字符串对象,从而减少重复创建相同值对象,占用内存空间。代码如下:

String s1 = "hello";
String s2 = "hello";
System.out.println(s1==s2);  //true
           

还有,在日常开发中的一些应用。例如,线程池就是享元模式的一种实现;将商品存储在应用服务的缓存中,那么每当用户获取商品信息时,则不需要每次都从 redis 缓存或者数据库中获取商品信息,并在内存中重复创建商品信息了。

总结

  • 在不得已需要重复创建大量同一对象时,我们可以使用原型模式,通过 clone 方法复制对象,这种方式比用 new 和序列化创建对象的效率要高;
  • 在创建对象时,如果我们可以共用对象的内部数据,那么通过享元模式共享相同的内部数据的对象,就可以减少对象的创建,实现系统调优。

继续阅读