Geek Time: la belleza de los patrones de diseño Modo Singleton (Parte 1): ¿Por qué se dice que la doble detección que admite la carga diferida no es mejor que el estilo hambriento?

Hay muchos artículos que explican el patrón singleton en Internet, pero la mayoría de ellos se enfocan en explicar cómo implementar un singleton seguro para subprocesos. Hoy también hablaré sobre los métodos de implementación de varios singletons, pero este no es el enfoque de nuestro estudio de columna. Mi enfoque sigue siendo mostrarles las siguientes preguntas (la primera pregunta se explicará hoy, las tres siguientes Esta pregunta se explicará en la próxima clase).

● ¿Por qué usar singleton?

● ¿Cuáles son los problemas con el singleton?

● ¿La diferencia entre clase singleton y estática?

● ¿Cuáles son las soluciones alternativas?

¿Por qué usar singleton?

El patrón de diseño Singleton (Patrón de diseño Singleton) es muy fácil de entender. Una clase solo puede crear un objeto (o instancia), entonces esta clase es una clase singleton. Este patrón de diseño se llama patrón de diseño singleton, o patrón singleton para abreviar.

Para el concepto de singleton, no creo que sea necesario explicar demasiado, puedes entenderlo de un vistazo. Centrémonos en ello, ¿por qué necesitamos un patrón de diseño singleton? ¿Qué problemas puede solucionar? A continuación, lo explicaré a través de dos casos reales de combate.

Caso práctico 1: Manejo de conflictos de acceso a recursos

Veamos primero el primer ejemplo. En este ejemplo, hemos implementado una clase Logger personalizada que imprime registros en un archivo. La implementación del código específico es la siguiente:


public class Logger {
    
    
  private FileWriter writer;
  
  public Logger() {
    
    
    File file = new File("/Users/wangzheng/log.txt");
    writer = new FileWriter(file, true); //true表示追加写入
  }
  
  public void log(String message) {
    
    
    writer.write(mesasge);
  }
}

// Logger类的应用示例:
public class UserController {
    
    
  private Logger logger = new Logger();
  
  public void login(String username, String password) {
    
    
    // ...省略业务逻辑代码...
    logger.log(username + " logined!");
  }
}

public class OrderController {
    
    
  private Logger logger = new Logger();
  
  public void create(OrderVo order) {
    
    
    // ...省略业务逻辑代码...
    logger.log("Created an order: " + order.toString());
  }
}

Después de leer el código, no se preocupe por leer mi explicación a continuación. Puede pensar en lo que está mal con este código.

En el código anterior, notamos que todos los registros están escritos en el mismo archivo /Users/wangzheng/log.txt. En UserController y OrderController, creamos dos objetos Logger respectivamente. En el entorno de múltiples subprocesos de Servlet del contenedor Web, si dos subprocesos de Servlet ejecutan las funciones login () y create () al mismo tiempo, y escriben registros en el archivo log.txt al mismo tiempo, puede haber información de registro sobrescribiéndose entre sí Sucediendo.

¿Por qué se cubren entre sí? Podemos entender por analogía. En un entorno multiproceso, si dos subprocesos suman 1 a la misma variable compartida al mismo tiempo, debido a que la variable compartida es un recurso de competencia, el resultado final de la variable compartida no puede incrementarse en 2, sino solo en 1. De la misma manera, el archivo log.txt aquí también compite por los recursos.Si dos subprocesos escriben datos en él al mismo tiempo, existe la posibilidad de que se sobrescriban entre sí.

Inserte la descripción de la imagen aquí
¿Cómo resolver este problema? Lo primero en lo que pensamos fue en la forma de bloquear: agregar un bloqueo mutex a la función log () (la palabra clave sincronizada se puede usar en Java), y solo un hilo puede llamar y ejecutar la función log () a la vez. La implementación del código específico es la siguiente:


public class Logger {
    
    
  private FileWriter writer;

  public Logger() {
    
    
    File file = new File("/Users/wangzheng/log.txt");
    writer = new FileWriter(file, true); //true表示追加写入
  }
  
  public void log(String message) {
    
    
    synchronized(this) {
    
    
      writer.write(mesasge);
    }
  }
}

Sin embargo, si lo piensa, ¿puede esto realmente resolver el problema de sobrescribirse entre sí cuando varios subprocesos escriben registros? la respuesta es negativa. Esto se debe a que este tipo de bloqueo es un bloqueo a nivel de objeto. Si un objeto llama a la función log () en diferentes subprocesos al mismo tiempo, se verá obligado a ejecutarse secuencialmente. Sin embargo, diferentes objetos no comparten el mismo candado. En diferentes subprocesos, la función log () es llamada y ejecutada por diferentes objetos, el bloqueo no funcionará y aún puede existir el problema de escribir registros sobrescribiéndolos entre sí.

Inserte la descripción de la imagen aquí
Permítanme agregar un poco aquí: en la explicación y el código que se dieron ahora, deliberadamente "oculté" un hecho: realmente no importa si agregamos bloqueos a nivel de objeto a la función log (). Debido a que FileWriter en sí es seguro para subprocesos, su implementación interna agrega bloqueos a nivel de objeto. Por lo tanto, cuando se llama a la función write () en la capa externa, agregar bloqueos a nivel de objeto es realmente innecesario. Debido a que los diferentes objetos Logger no comparten el objeto FileWriter, los bloqueos en el nivel del objeto FileWriter no pueden resolver el problema de la escritura de datos sobrescribiéndose entre sí.

¿Cómo podemos solucionar este problema? De hecho, no es difícil resolver este problema, solo necesitamos reemplazar el bloqueo de nivel de objeto con el bloqueo de nivel de clase. Deje que todos los objetos compartan el mismo candado. Esto evita el problema de cobertura de logs causado al llamar a la función log () entre diferentes objetos al mismo tiempo. La implementación del código específico es la siguiente:


public class Logger {
    
    
  private FileWriter writer;

  public Logger() {
    
    
    File file = new File("/Users/wangzheng/log.txt");
    writer = new FileWriter(file, true); //true表示追加写入
  }
  
  public void log(String message) {
    
    
    synchronized(Logger.class) {
    
     // 类级别的锁
      writer.write(mesasge);
    }
  }
}

Además de utilizar bloqueos a nivel de clase, existen muchas formas de resolver el problema de la competencia de recursos. Los bloqueos distribuidos son la solución que se escucha con más frecuencia. Sin embargo, no es fácil implementar un bloqueo distribuido seguro, confiable, sin errores y de alto rendimiento. Además, las colas concurrentes (como BlockingQueue en Java) también pueden resolver este problema: varios subprocesos escriben registros en la cola concurrente al mismo tiempo, y un solo subproceso es responsable de escribir los datos en la cola concurrente en el archivo de registro. Este método también es un poco más complicado de implementar.

En comparación con estas dos soluciones, la idea de solución del modo singleton es más simple. En comparación con el bloqueo de nivel de clase anterior, la ventaja del modo singleton es que no hay necesidad de crear tantos objetos Logger. Por un lado, ahorra espacio en la memoria y, por otro lado, guarda los identificadores de archivos del sistema. (Para los sistemas operativos, los identificadores de archivos también son un tipo de recurso y no se pueden desperdiciar. ).

Diseñamos Logger como una clase singleton. Solo se permite crear un objeto Logger en el programa. Todos los subprocesos comparten este objeto Logger y comparten un objeto FileWriter. FileWriter en sí es seguro para subprocesos a nivel de objeto, lo que evita múltiples En el caso de los hilos, los registros de escritura se cubrirán entre sí.

De acuerdo con esta idea de diseño, implementamos la clase Logger singleton. El código específico es el siguiente:


public class Logger {
    
    
  private FileWriter writer;
  private static final Logger instance = new Logger();

  private Logger() {
    
    
    File file = new File("/Users/wangzheng/log.txt");
    writer = new FileWriter(file, true); //true表示追加写入
  }
  
  public static Logger getInstance() {
    
    
    return instance;
  }
  
  public void log(String message) {
    
    
    writer.write(mesasge);
  }
}

// Logger类的应用示例:
public class UserController {
    
    
  public void login(String username, String password) {
    
    
    // ...省略业务逻辑代码...
    Logger.getInstance().log(username + " logined!");
  }
}

public class OrderController {
    
      
  public void create(OrderVo order) {
    
    
    // ...省略业务逻辑代码...
    Logger.getInstance().log("Created a order: " + order.toString());
  }
}

Caso real 2: representación de la clase única a nivel mundial

Desde el concepto empresarial, si algunos datos solo deben guardarse en el sistema, es más adecuado diseñarlos como singleton.

Por ejemplo, clase de información de configuración. En el sistema, solo tenemos un archivo de configuración, cuando el archivo de configuración se carga en la memoria, existe en forma de objeto y solo debe haber una copia.

Otro ejemplo es el generador de número de ID incremental único. Si hay dos objetos en el programa, habrá una situación de generación de ID duplicados. Por lo tanto, debemos diseñar la clase del generador de ID como un singleton.


import java.util.concurrent.atomic.AtomicLong;
public class IdGenerator {
    
    
  // AtomicLong是一个Java并发库中提供的一个原子变量类型,
  // 它将一些线程不安全需要加锁的复合操作封装为了线程安全的原子操作,
  // 比如下面会用到的incrementAndGet().
  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();
  }
}

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

De hecho, los dos ejemplos de código (Logger, IdGenerator) mencionados hoy no tienen un diseño elegante y todavía existen algunos problemas. En cuanto a qué está mal y cómo reformarlo, hoy lo cerraré por el momento, y lo explicaré en detalle en la próxima lección.

¿Cómo implementar un singleton?

Aunque hay muchos artículos que describen cómo implementar un patrón singleton, para garantizar la integridad del contenido, presentaré brevemente varios métodos de implementación clásicos. En resumen, para implementar un singleton, debemos prestar atención a los siguientes puntos:

● El constructor debe tener derechos de acceso privados, para evitar la creación externa de instancias a través de nuevas;

● Tenga en cuenta los problemas de seguridad de los subprocesos al crear objetos;

● Considere la posibilidad de admitir la carga diferida;

● Considere si el rendimiento de getInstance () es alto (si está bloqueado).

Si ya está familiarizado con esta área, puede usarla como revisión. Tenga en cuenta que las siguientes implementaciones de singleton son para la sintaxis del lenguaje Java. Si está familiarizado con otros lenguajes, es posible que desee comparar estas implementaciones de Java y tratar de resumir usted mismo cómo implementarlo utilizando el lenguaje con el que está familiarizado. .

1. Chino hambriento

La implementación del estilo del hombre hambriento es relativamente simple. Cuando se carga la clase, la instancia estática de la instancia se ha creado e inicializado, por lo que el proceso de creación de la instancia de instancia es seguro para subprocesos. Sin embargo, esta implementación no admite la carga diferida (cree una instancia cuando IdGenerator se use realmente), como podemos ver en el nombre. La implementación del código específico es la siguiente:


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();
  }
}

Algunas personas sienten que esta implementación no es buena porque no admite la carga diferida. Si la instancia ocupa muchos recursos (por ejemplo, mucha memoria) o la inicialización lleva mucho tiempo (por ejemplo, necesita cargar varios archivos de configuración), inicializar la instancia con anticipación es una pérdida de recursos. . La mejor forma es inicializarlo cuando se utiliza. Sin embargo, personalmente no estoy de acuerdo con este punto de vista.

Si la inicialización lleva mucho tiempo, será mejor que no esperemos hasta el momento de usarla antes de realizar este proceso de inicialización que consume mucho tiempo, lo que afectará el rendimiento del sistema (por ejemplo, al responder a las solicitudes de la interfaz del cliente, haga esta inicialización Operación, el tiempo de respuesta de esta solicitud será más largo o incluso se agotará). Usando el método de implementación de chino hambriento, la operación de inicialización que consume mucho tiempo se completa antes de que comience el programa, para evitar los problemas de rendimiento causados ​​por la inicialización cuando el programa se está ejecutando.

Si la instancia ocupa muchos recursos, de acuerdo con el principio de diseño a prueba de fallas (los problemas se exponen temprano), también esperamos inicializar la instancia cuando se inicie el programa. Si los recursos no son suficientes, se activará un error cuando se inicie el programa (como PermGen Space OOM en Java), y podemos solucionarlo de inmediato. Esto también puede evitar que después de que el programa se haya estado ejecutando durante un período de tiempo, la inicialización de esta instancia de repente ocupe demasiados recursos, provocando que el sistema se bloquee y afectando la disponibilidad del sistema.

2. Hombre vago

Está el estilo hambriento, y el correspondiente, está el estilo del hombre perezoso. La ventaja del perezoso sobre el hambriento es que admite la carga perezosa. La implementación del código específico es la siguiente:


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();
  }
}

Sin embargo, las deficiencias del estilo perezoso también son obvias. Agregamos un bloqueo grande (sincronizado) al método getInstance (), lo que resultó en una baja concurrencia de esta función. Para cuantificarlo, la concurrencia es 1, lo que equivale a una operación en serie. Y esta función siempre se llama durante el período de uso de singleton. Si esta clase singleton se usa ocasionalmente, entonces esta implementación es aceptable. Sin embargo, si se usa con frecuencia, problemas como bloqueos frecuentes, liberaciones de bloqueo y baja concurrencia causarán cuellos de botella en el rendimiento Esta implementación no es deseable.

3. Doble detección

El estilo hambriento no admite la carga diferida, y el estilo diferido tiene problemas de rendimiento y no admite alta concurrencia. Luego, veamos una implementación de singleton que admite tanto la carga diferida como la alta concurrencia, es decir, la implementación de doble detección.

En esta implementación, siempre que se cree la instancia, incluso si se vuelve a llamar a la función getInstance (), ya no entrará en la lógica de bloqueo. Por tanto, este método de implementación resuelve el problema de la baja concurrencia del perezoso. La implementación del código específico es la siguiente:


public class IdGenerator {
    
     
  private AtomicLong id = new AtomicLong(0);
  private static 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();
  }
}

Algunas personas en Internet dicen que hay algunos problemas con esta implementación. Debido al reordenamiento de las instrucciones, puede hacer que el objeto IdGenerator sea nuevo y se asigne a la instancia antes de que pueda inicializarse (ejecutar la lógica del código en el constructor) antes de ser utilizado por otro hilo.

Para resolver este problema, necesitamos agregar la palabra clave volátil a la variable miembro de instancia para prohibir el reordenamiento de instrucciones. De hecho, solo las versiones muy bajas de Java tienen este problema. La versión alta de Java que estamos usando ha resuelto este problema en la implementación interna del JDK (la solución es muy simple, siempre que la operación nueva del objeto y la operación de inicialización estén diseñadas como operaciones atómicas, el reordenamiento está naturalmente prohibido). La explicación detallada sobre este punto está relacionada con un idioma específico, por lo que no voy a entrar en él y los estudiantes interesados ​​pueden estudiarlo por sí mismos.

4. Clase interna estática

Veamos un método de implementación más simple que la detección doble, que consiste en utilizar las clases internas estáticas de Java. Es un poco similar al hombre hambriento, pero puede realizar una carga lenta. ¿Cómo lo hizo exactamente? Primero veamos su implementación de código.


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();
  }
}

SingletonHolder es una clase interna estática. Cuando se carga la clase externa IdGenerator, no se creará el objeto de instancia SingletonHolder. Solo cuando se llame al método getInstance (), se cargará SingletonHolder y se creará la instancia en este momento. La singularidad de la instancia y la seguridad de los subprocesos del proceso de creación están garantizadas por la JVM. Por lo tanto, este método de implementación no solo garantiza la seguridad de los subprocesos, sino que también permite la carga diferida.

5. Enumeración

Finalmente, presentamos una de las implementaciones más simples, una implementación singleton basada en tipos enumerados. Esta implementación garantiza la seguridad de los subprocesos de la creación de instancias y la singularidad de la instancia a través de las características del tipo de enumeración de Java en sí. El código específico es el siguiente:


public enum IdGenerator {
    
    
  INSTANCE;
  private AtomicLong id = new AtomicLong(0);
 
  public long getId() {
    
     
    return id.incrementAndGet();
  }
}

Supongo que te gusta

Origin blog.csdn.net/zhujiangtaotaise/article/details/110442711
Recomendado
Clasificación