Diario del arquitecto: guía de optimización del rendimiento desde el código hasta el diseño | Equipo técnico de JD Cloud

I. Introducción

El rendimiento del servicio se refiere al rendimiento del servicio en términos de velocidad de respuesta, rendimiento y utilización de recursos en condiciones específicas. Según las estadísticas, la inversión energética en la optimización del rendimiento suele representar entre el 10 % y el 25 % del ciclo de desarrollo del software. Por supuesto, esto está relacionado con la naturaleza y la escala de la aplicación. El rendimiento tiene un gran impacto en la mejora de la experiencia del usuario, asegurando la confiabilidad del sistema, reduciendo el uso de recursos e incluso mejorando la competitividad del mercado.

La optimización del rendimiento es un proyecto sistemático, que se puede dividir en direcciones de red, servicio y almacenamiento a nivel macro, y cada dirección se puede subdividir en varios subelementos, como arquitectura, diseño, código, usabilidad y medición. Este artículo se centrará en los dos subelementos de código y diseño , y hablará sobre los puntos de conocimiento que mejoran el rendimiento. Por supuesto, muchas estrategias de mejora del desempeño tienen un precio y son aplicables a ciertos escenarios. Al aprenderlas y usarlas, es mejor pensar críticamente y sopesar los pros y los contras antes de tomar decisiones.

Primero, enumere brevemente las direcciones de optimización del rendimiento:

Optimización de dos códigos

2.1 Código asociado

La optimización del código vinculado es para evitar cargar el código de destino en el tiempo de ejecución mediante la carga previa del código relevante, lo que genera una carga en el tiempo de ejecución. Sabemos que Java tiene dos cargadores de clases: cargador de clases Bootstrap y cargador de clases de aplicación. El cargador de clases Bootstrap es responsable de cargar las clases principales contenidas en la API de Java, mientras que el cargador de clases de la aplicación es responsable de cargar las clases personalizadas. La optimización del código asociativo se puede lograr de las siguientes maneras.

asociación de precarga

Precargar clases asociadas se refiere a precargar el destino y las clases asociadas cuando el programa comienza a evitar la carga en tiempo de ejecución. La precarga se puede lograr a través de bloques de código estático, de la siguiente manera:

public class MainClass {
    static {
        // 预加载MyClass,其实现了相关功能
        Class.forName("com.example.MyClass");
    }
    // 运行相关功能的代码
    // ...
}

usar grupo de subprocesos

El grupo de subprocesos permite que varias tareas utilicen subprocesos en el mismo grupo de subprocesos, lo que reduce el costo de creación y destrucción de subprocesos. Al usar un grupo de subprocesos, puede crear un grupo de subprocesos cuando se inicia el programa y precargar códigos relacionados en el subproceso principal. Luego use los subprocesos en el grupo de subprocesos para ejecutar códigos relacionados de manera asincrónica, lo que puede mejorar el rendimiento del programa.

usar variables estáticas

Puede usar variables estáticas para almacenar en caché objetos y datos relacionados con el código asociado. Al inicio del programa, el código asociado se puede precargar y los objetos o datos se pueden almacenar en variables estáticas. Los objetos o datos almacenados en caché en variables estáticas se utilizan mientras el programa se ejecuta para evitar la carga y generación repetidas. Este método puede mejorar efectivamente el rendimiento del programa, pero debe prestar atención al uso de variables estáticas para garantizar su seguridad en un entorno de subprocesos múltiples.

2.2 Alineación de caché

Antes de introducir la alineación de caché, es necesario popularizar algunos conocimientos sobre la ejecución de instrucciones de la CPU.

  • Línea de caché : cuando la CPU lee datos de la memoria, no solo lee un byte a la vez, sino que generalmente lee un bloque continuo de memoria (trozos de memoria) con una longitud de 64 bytes (determinada por el hardware). pidió la línea de caché.
  • Uso compartido falso (uso compartido falso): cuando dos subprocesos que se ejecutan en dos CPU diferentes escriben en dos variables diferentes, si las dos variables están almacenadas en la misma línea de caché de la CPU, se produce un uso compartido falso (uso compartido falso). Es decir, cuando el primer subproceso modifica una de las variables en la línea de caché, las líneas de caché de otros subprocesos que hacen referencia a esta variable de línea de caché no serán válidas. Si la CPU necesita leer una línea de caché obsoleta, debe esperar a que se vacíe la línea de caché, lo que da como resultado un rendimiento deficiente.
  • Bloqueo de la CPU : cuando un núcleo necesita esperar a que otro núcleo vuelva a cargar la línea de caché (cuando se produce un intercambio falso ), no puede continuar ejecutando la siguiente instrucción, solo puede detenerse y esperar, lo que se denomina bloqueo. Reducir el uso compartido falso también significa reducir la ocurrencia de estancamientos.
  • IPC (instrucciones por ciclo): Representa el número promedio de instrucciones ejecutadas por ciclo de CPU, obviamente, cuanto mayor sea el valor, mejor será el rendimiento. Basado en el índice IPC (por ejemplo: umbral 1.0), se puede juzgar simplemente si el programa es intensivo en acceso o computación. En el sistema Linux, puede usar el comando tiptop para ver los datos del hardware de la CPU de cada proceso:

¿Cómo distinguir fácilmente los programas intensivos en memoria y los intensivos en computación?

  1. Si IPC < 1.0, es probable que la parada de memoria domine, lo que probablemente signifique un uso intensivo de memoria.

  2. Si IPC > 1.0, es probable que sea un programa computacionalmente intensivo.

  • Utilización de la CPU : se refiere a la relación entre el tiempo que la CPU está ocupada en el sistema y el tiempo total. El tiempo de estado ocupado se puede dividir en ciclo de ciclo de consumo de ejecución de instrucción (instrucción) (%INS) y ciclo de ciclo detenido (%STL). perf recopila el estado de ejecución de todas las CPU en 10 segundos:

IPC计算

IPC = instructions/cycles
上图中,可以计算出结果为:0.79
现代处理器一般有多条流水线(比如:4核心),运行 perf 的那台机器,IPC的理论值可达到4.0。
如果我们从 IPC的角度来看,这台机器只运行到其处理器最高速度的 19.7%(0.79 / 4.0)。

En resumen, después de ver el uso de la CPU a través del comando Top, puede analizar más a fondo el ciclo de consumo de ejecución de instrucciones y el ciclo detenido.Con estos indicadores más detallados, puede saber cómo ajustar mejor la aplicación y el sistema.

  • Alineación de caché: al ajustar la distribución de datos en la memoria, es más propicio que la CPU lea desde el caché cuando los datos están almacenados en caché, evitando así lecturas frecuentes de memoria y mejorando la velocidad de acceso a los datos.

Relleno de caché (relleno)

Reducir el uso compartido falso también significa reducir la aparición de estancamientos. Uno de los métodos es llenar la línea de caché insertando un espacio alineado a una distancia adecuada en forma de datos de relleno, de modo que la modificación de cada subproceso no ensucie el mismo. línea de caché

/**
 * 缓存行填充测试
 *
 * @author liuhuiqing
 * @date 2023年04月28日
 */
public class FalseSharingTest {
    private static final int LOOP_NUM = 1000000000;

    public static void main(String[] args) throws InterruptedException {
        Struct struct = new Struct();
        long start = System.currentTimeMillis();
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < LOOP_NUM; i++) {
                struct.x++;
            }
        });

        Thread t2 = new Thread(() -> {
            for (int i = 0; i < LOOP_NUM; i++) {
                struct.y++;
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println("cost time [" + (System.currentTimeMillis() - start) + "] ms");
    }

    static class Struct {
        // 共享变量
        volatile long x;
        // 一个long占用8个字节,此处定义7个填充数据,来保证业务数据x和y分布在不同的缓存行中
        long p1, p2, p3, p4, p5, p6, p7;
        // long[] paddings = new long[7];// 使用数组代替不会生效,思考一下,为什么?
        // 共享变量
        volatile long y;
    }
}

Después de las pruebas locales, este método de intercambio de espacio por tiempo realiza la alineación de los datos de la línea de caché. En términos de eficiencia de ejecución, ¡es 5 veces mayor que antes sin alineación!

@Anotación en disputa

En Java 8, se introdujo la anotación @Contended, que se puede utilizar para indicarle a la JVM que alinee en caché los campos (coloque los campos en diferentes líneas de caché), mejorando así el rendimiento del programa. Al usar la anotación @Contended, debe agregar el parámetro -XX:-RestrictContended cuando se inicia la JVM, y la implementación es la siguiente:

import sun.misc.Contended;

public class ContendedTest {

    @Contended
    volatile long a;
    @Contended
    volatile long b;

    public static void main(String[] args) throws InterruptedException {
        ContendedTest c = new ContendedTest();
        Thread thread1 = new Thread(() -> {
            for (int i = 0; i < 10000_0000L; i++) {
                c.a = i;
            }
        });

        Thread thread2 = new Thread(() -> {
            for (int i = 0; i < 10000_0000L; i++) {
                c.b = i;
            }
        });
        final long start = System.nanoTime();
        thread1.start();
        thread2.start();
        thread1.join();
        thread2.join();
        System.out.println((System.nanoTime() - start) / 100_0000);
    }
}

Alinear la memoria y las variables locales

El llenado de caché es una de las soluciones para resolver el problema de uso compartido falso de la CPU. En aplicaciones prácticas, ¿existen otras soluciones para resolver este problema? La respuesta es sí: es decir, alinear memoria y variables locales.

  • Memoria alineada : el tamaño de una línea de memoria es generalmente de 64 bytes. Este tamaño lo determina el hardware, pero la mayoría de los compiladores están alineados en un límite de 4 bytes de forma predeterminada. Al alinear las variables de acuerdo con el tamaño de la línea de memoria, puede evitar el problema de compartir falso;
  • Variables locales : use diferentes variables para almacenar datos entre diferentes subprocesos y evite compartir la misma memoria entre diferentes subprocesos ThreadLocal en Java es un método de implementación típico;

2.3 Predicción de bifurcación

La predicción de bifurcaciones es el contenido principal de la tecnología de ejecución dinámica de CPU. Es una tecnología para mejorar la eficiencia de ejecución de la CPU al adivinar la ruta de ejecución de las sentencias de bifurcación (como las sentencias if-else o las sentencias de bucle) en el programa. El principio es predecir si la siguiente instrucción que ejecutará el programa es una instrucción de salto de rama o una instrucción de ejecución secuencial basada en registros históricos y datos estadísticos anteriores, para cargar datos relevantes por adelantado y reducir el tiempo de inactividad de la CPU. esperando la ejecución de la instrucción. Cuanto mayor sea la precisión de la predicción, mayor será la mejora del rendimiento de la CPU. Entonces, ¿cómo mejorar la precisión de la predicción?

  • Complejidad ciclomática de interés

Demasiadas sentencias condicionales y sentencias condicionales anidadas aumentarán en gran medida la dificultad de la predicción de bifurcaciones, lo que reducirá la precisión y la eficiencia de la predicción de bifurcaciones. En términos generales, las sentencias condicionales excesivas y las sentencias condicionales anidadas se pueden evitar optimizando la estructura lógica del código y reduciendo la redundancia.

  • Priorizar caminos comunes

Al escribir código, debe priorizar las rutas comunes para reducir la predicción de bifurcaciones de la CPU y mejorar la precisión y la eficiencia de la predicción. Por ejemplo, en una declaración if-else, debe colocar las rutas de uso común en la declaración if y las rutas menos utilizadas en la declaración else.

2.4 copia en escritura

Copy-On-Write (COW) es un mecanismo de gestión de memoria, también conocido como copy-on-write. La idea principal es que cuando es necesario escribir datos, primero se copian y luego se operan, evitando así la copia y manipulación innecesarias de los datos. El mecanismo COW puede reducir efectivamente el uso de la memoria y mejorar el rendimiento del programa.

Al crear un proceso o subproceso, cuando el sistema operativo le asigna memoria, en lugar de copiar un espacio de direcciones físicas completo, crea un espacio de direcciones virtuales que apunta al espacio de direcciones físicas del proceso/subproceso principal y establece "" read- solo" bandera. Cuando un subproceso/proceso secundario necesita modificar una página, activará una excepción de falla de página, copiará los datos de la página involucrada y reasignará la memoria para la página copiada. El proceso/hilo secundario solo puede operar el espacio de direcciones copiado, y el espacio de memoria original del proceso/hilo principal está reservado.

Debido a que el mecanismo COW copia los datos antes de escribirlos, puede evitar eficazmente las operaciones frecuentes de copia y asignación de memoria, reducir el uso de memoria y mejorar el rendimiento del programa. Además, el mecanismo COW también evita la copia innecesaria de datos, lo que reduce el consumo y la fragmentación de la memoria y aumenta la cantidad de memoria disponible en el sistema.

La clase ArrayList puede usar el mecanismo Copy-On-Write para mejorar el rendimiento.

// 初始化数组
private List<String> list = new CopyOnWriteArrayList<>();

// 向数组中添加元素
list.add("value");

Cabe señalar que el mecanismo Copy-On-Write es adecuado para situaciones en las que hay más operaciones de lectura que de escritura, porque asume que la frecuencia de las operaciones de escritura es baja, por lo que puede reducir el consumo de operaciones de bloqueo y memoria. asignación sacrificando los gastos generales de copiado.

2.5 Optimización en línea

En Java, cada vez que se llama a un método, se requieren algunas operaciones adicionales, como crear un marco de pila, guardar el estado del registro, etc. Estas operaciones adicionales consumen una cierta cantidad de tiempo y recursos de memoria. La optimización en línea es una técnica de optimización del compilador. La máquina virtual de Java generalmente usa un compilador justo a tiempo (JIT) para realizar el método en línea para mejorar el rendimiento del programa. El objetivo de la optimización en línea es reemplazar las llamadas a funciones con el código de la función en sí, para reducir la sobrecarga de las llamadas a funciones y, por lo tanto, mejorar la eficiencia operativa del programa.

Cabe señalar que el método en línea no mejora la eficiencia de ejecución del programa en todos los casos. Si la inserción de métodos conduce a una mayor complejidad del código o un mayor uso de la memoria, se degradará el rendimiento del programa. Por lo tanto, las compensaciones y optimizaciones deben realizarse caso por caso cuando se utiliza el método en línea.

modificador final

El modificador final puede hacer que un método no se pueda anular. Debido a que no se pueden reescribir, su código se puede incrustar en el código que los llama cuando el compilador se optimiza, evitando así la sobrecarga de las llamadas a funciones. Usar el modificador final puede mejorar el rendimiento del programa hasta cierto punto, pero también debilita la escalabilidad del código.

limitar la longitud del método

La longitud de un método afecta si se puede insertar en línea en el momento de la compilación. En general, es más probable que los métodos con longitudes más pequeñas estén en línea. Por lo tanto, el código se puede dividir y refactorizar en funciones más pequeñas en el diseño. De esta manera, no es 100% seguro de que la inserción sea posible, pero al menos mejora las posibilidades de que se logre esta optimización. Parámetros de ajuste en línea, como se muestra en la siguiente tabla:

Parámetros de JVM Predeterminado (JDK 8, Linux x86_64) Descripción de parámetros
-XX:MaxInlineSize=<n> código de 35 bytes Límite de tamaño del método en línea
-XX:FreqInlineSize=<n> código de 325 bytes Valor máximo para métodos calientes en línea
-XX:CódigoPequeñoEnLínea=<n> 1000 bytes de código nativo (sin capas) 2000 bytes de código nativo (compilación en capas) Si la cantidad de código compilado en capas en la última capa supera este valor, no se realizará la compilación en línea.
-XX:MaxInlineLevel=<n> 9 Si el nivel de llamada es más profundo que este valor, no se realizará ninguna alineación.

comentarios en línea

Después de Java 5, se introdujo la anotación en línea @inline , que se puede usar para notificar al compilador en el momento de la compilación para insertar el método en su sitio de llamada. La anotación @inline ha quedado obsoleta después de Java 9, puede usar la anotación @ForceInline en su lugar y configurar los parámetros de JVM al mismo tiempo:

-XX:+UnlockExperimentalVMOptions -XX:+EnableJVMCI -XX:+JVMCICompiler
@ForceInline
public static int add(int a, int b) {
    return a + b;
}

2.6 Optimización de la codificación

mecanismo de reflexión

La reflexión de Java afecta el rendimiento hasta cierto punto, porque requiere la conversión de verificación de tipos y la búsqueda de métodos en tiempo de ejecución, lo que lleva más tiempo que llamar al método directamente. Además, la reflexión no está sujeta a optimizaciones del compilador y, por lo tanto, puede resultar en una ejecución de código más lenta.

Hay varias formas de solucionar este problema:

  • Utilice llamadas a métodos nativos tanto como sea posible en lugar de llamar a través de la reflexión;
  • Guarde en caché los resultados de las llamadas reflejadas tanto como sea posible para evitar llamadas repetidas. Por ejemplo, el resultado de la reflexión se puede almacenar en caché en una variable estática para que se pueda obtener directamente la próxima vez que se use sin tener que volver a usar la reflexión;
  • Utilizar técnicas de mejora de código de bytes;

Lo siguiente se centra en las dos soluciones de almacenamiento en caché de resultados de reflexión y mejora de código de bytes.

  • El almacenamiento en caché de resultados de reflexión puede reducir en gran medida acciones como la verificación de tipos, la conversión de tipos y la búsqueda de métodos en el proceso de reflexión. Es una estrategia de optimización para reducir el impacto de la reflexión en la eficiencia de ejecución del programa.
/**
 * 反射工具类
 *
 * @author liuhuiqing
 * @date 2023年5月7日
 */
public abstract class BeanUtils {
    private static final Logger LOGGER = LoggerFactory.getLogger(BeanUtils.class);
    private static final Field[] NO_FIELDS = {};
    private static final Map<Class<?>, Field[]> DECLARED_FIELDS_CACHE = new ConcurrentReferenceHashMap<Class<?>, Field[]>(256);
    private static final Map<Class<?>, Field[]> FIELDS_CACHE = new ConcurrentReferenceHashMap<Class<?>, Field[]>(256);

    /**
     * 获取当前类及其父类的属性数组
     *
     * @param clazz
     * @return
     */
    public static Field[] getFields(Class<?> clazz) {
        if (clazz == null) {
            throw new IllegalArgumentException("Class must not be null");
        }
        Field[] result = FIELDS_CACHE.get(clazz);
        if (result == null) {
            Field[] fields = NO_FIELDS;
            Class<?> searchType = clazz;
            while (Object.class != searchType && searchType != null) {
                Field[] tempFields = getDeclaredFields(searchType);
                fields = mergeArray(fields, tempFields);
                searchType = searchType.getSuperclass();
            }
            result = fields;
            FIELDS_CACHE.put(clazz, (result.length == 0 ? NO_FIELDS : result));
        }
        return result;
    }

    /**
     * 获取当前类属性数组(不包含父类的属性)
     *
     * @param clazz
     * @return
     */
    public static Field[] getDeclaredFields(Class<?> clazz) {
        if (clazz == null) {
            throw new IllegalArgumentException("Class must not be null");
        }
        Field[] result = DECLARED_FIELDS_CACHE.get(clazz);
        if (result == null) {
            result = clazz.getDeclaredFields();
            DECLARED_FIELDS_CACHE.put(clazz, (result.length == 0 ? NO_FIELDS : result));
        }
        return result;
    }

    /**
     * 数组合并
     *
     * @param array1
     * @param array2
     * @param <T>
     * @return
     */
    public static <T> T[] mergeArray(final T[] array1, final T... array2) {
        if (array1 == null || array1.length < 1) {
            return array2;
        }
        if (array2 == null || array2.length < 1) {
            return array1;
        }
        Class<?> compType = array1.getClass().getComponentType();
        int newArrLength = array1.length + array2.length;
        T[] newArr = (T[]) Array.newInstance(compType, newArrLength);
        int firstArrayLen = array1.length;
        System.arraycopy(array1, 0, newArr, 0, firstArrayLen);
        try {
            System.arraycopy(array2, 0, newArr, firstArrayLen, array2.length);
        } catch (ArrayStoreException ase) {
            final Class<?> type2 = array2.getClass().getComponentType();
            if (!compType.isAssignableFrom(type2)) {
                throw new IllegalArgumentException("Cannot store " + type2.getName() + " in an array of "
                        + compType.getName(), ase);
            }
            throw ase;
        }
        return newArr;
    }
}
  • La tecnología de mejora de bytecode generalmente se implementa utilizando bibliotecas de terceros, como Javassist o Byte Buddy, para generar bytecode en tiempo de ejecución, evitando así el uso de la reflexión.

¿Por qué la generación dinámica de códigos de bytes puede mejorar la eficiencia de ejecución en comparación con la reflexión?

  1. El método de generación de código de bytes dinámico ya ha determinado la información de tipo en tiempo de compilación, sin necesidad de verificación y conversión de tipo;
  2. El método de generación de código de bytes dinámico puede llamar directamente al método sin buscar, lo que mejora la eficiencia de ejecución;
  3. El método de generación de bytecode dinámico solo necesita obtener el objeto Method una vez al generar el bytecode, y se puede usar directamente cuando se llama varias veces, evitando la sobrecarga de obtener repetidamente el objeto Method;

No daré ningún ejemplo aquí, y los estudiantes interesados ​​pueden consultar la información para un estudio más profundo.

manejo de excepciones

El manejo eficaz de las excepciones puede garantizar la estabilidad y confiabilidad del programa. Sin embargo, el manejo de excepciones todavía tiene un cierto impacto en el rendimiento, que a menudo se pasa por alto. Las manifestaciones específicas que afectan el rendimiento son:

  • Retraso en la respuesta: cuando se lanza una excepción, la máquina virtual Java necesita encontrar y ejecutar el controlador de excepciones correspondiente, lo que provocará un cierto retraso. Si hay mucho manejo de excepciones en el programa, estos retrasos pueden acumularse, degradando el rendimiento general del programa.
  • Uso de memoria: el manejo de excepciones requiere la creación de objetos de excepción en la pila, y estos objetos ocupan memoria. Si hay una gran cantidad de manejo de excepciones en el programa, estos objetos de excepción pueden ocupar una gran cantidad de memoria, lo que da como resultado un aumento en el uso de memoria general del programa.
  • Uso de la CPU: el manejo de excepciones requiere la ejecución de código adicional, lo que conduce a un mayor uso de la CPU. Si hay una gran cantidad de manejo de excepciones en el programa, estos códigos adicionales pueden conducir a un alto uso de la CPU, lo que resulta en una disminución del rendimiento general del programa.

Algunos puntos de referencia han demostrado que el manejo de excepciones puede degradar el rendimiento de un programa en varios porcentajes. Se menciona en la especificación de la máquina virtual de Java que las llamadas a métodos basados ​​en pilas pueden ser 2 o 3 veces más rápidas que las llamadas a métodos basados ​​en excepciones cuando no se produce ninguna excepción. Además, algunos experimentos han demostrado que el uso de una gran cantidad de instrucciones try-catch en los controladores de excepciones puede provocar una caída del rendimiento de más de 10 veces.

Para evitar estos problemas, use los mecanismos de manejo de excepciones con cuidado al escribir código y asegúrese de que las excepciones se registren y notifiquen correctamente, y evite el uso excesivo de los mecanismos de manejo de excepciones.

procesamiento de registros

Mira primero el siguiente código:

LOGGER.info("result:" + JsonUtil.write2JsonStr(contextAdContains) + ", logid = " + DigitThreadLocal.getLogId());

En el código de muestra anterior, los métodos de impresión de registros similares son muy comunes. ¿Hay algún problema?

  • Problemas de rendimiento: cada vez que se usa + para el empalme de cadenas, se creará un nuevo objeto de cadena, lo que puede generar una mayor asignación de memoria y una sobrecarga de recolección de elementos no utilizados;
  • Problemas de legibilidad: al usar + para la concatenación de cadenas, el código puede volverse difícil de leer y comprender, especialmente cuando es necesario concatenar varias cadenas;
  • Si el nivel de registro se ajusta al modo ERROR, esperamos que el contenido de la cadena del registro no necesite ser procesado y calculado, pero esta forma de escribir, incluso si el registro está en un modo que no necesita ser impreso, el contenido del registro también se calcula de forma no válida;

Especialmente en escenarios donde el volumen de solicitudes e impresión de registros es relativamente alto, el impacto que consume mucho tiempo de la serialización del contenido del registro y las operaciones de escritura de archivos en el servicio puede alcanzar el 10 % o incluso más.

objeto temporal

Los objetos temporales generalmente se refieren a objetos creados dentro de métodos. La creación de una gran cantidad de objetos temporales hará que la máquina virtual Java realice una recolección de elementos no utilizados con frecuencia, lo que afectará el rendimiento del programa. También ocupa mucho espacio en la memoria, lo que puede causar problemas como bloqueos del programa o pérdidas de memoria.

Para evitar la creación de una gran cantidad de objetos temporales, se pueden tomar las siguientes medidas al codificar:

  1. En el empalme de cadenas, use StringBuilder o StringBuffer para el empalme de cadenas, evite usar conectores y cree nuevos objetos de cadena cada vez;
  2. En las operaciones de recopilación, intente utilizar operaciones por lotes, como agregarTodo, eliminarTodo, etc., para evitar operaciones frecuentes de agregar y quitar, y desencadenar la expansión o reducción de la matriz;
  3. En una expresión regular, puede usar el método Pattern.compile() para precompilar la expresión regular para evitar crear un nuevo objeto Matcher cada vez;
  4. Intente usar tipos de datos básicos y evite usar clases contenedoras, porque la creación y destrucción de clases contenedoras generará objetos temporales;
  5. Intente usar el grupo de objetos para crear y administrar objetos, como usar métodos de fábrica estáticos para crear objetos, evite usar la nueva palabra clave para crear objetos, porque los métodos de fábrica estáticos pueden reutilizar objetos y evitar la creación de nuevos objetos temporales;

El ciclo de vida de los objetos temporales debe ser lo más corto posible para que los recursos de memoria puedan liberarse a tiempo. La vida útil excesivamente larga de los objetos temporales suele deberse a:

  1. El objeto no se libera correctamente: si el objeto temporal no se libera correctamente después de ejecutar el método, habrá riesgo de pérdida de memoria;
  2. Uso compartido excesivo de objetos: si el objeto temporal se comparte en exceso, puede causar que varios subprocesos accedan al mismo objeto al mismo tiempo, lo que genera problemas de seguridad y de rendimiento;
  3. La creación de objetos es demasiado frecuente: si los objetos temporales se crean con frecuencia dentro del método, dará lugar a una sobrecarga de memoria excesiva, lo que puede causar problemas de rendimiento o incluso de desbordamiento de memoria;

Para evitar una vida útil excesivamente larga de los objetos temporales, se recomiendan las siguientes medidas:

  1. Liberar el objeto a tiempo: después de ejecutar el método, el objeto temporal debe liberarse a tiempo (como establecer activamente el objeto en nulo) para recuperar los recursos de memoria;
  2. Evite el uso compartido excesivo: en un entorno de subprocesos múltiples, se debe evitar el uso compartido excesivo de objetos temporales y se pueden usar variables locales o ThreadLocal para evitar problemas de uso compartido;
  3. Tecnología de agrupación de objetos: el uso de la tecnología de agrupación de objetos puede evitar la creación frecuente de objetos temporales, lo que reduce la sobrecarga de memoria. El grupo de objetos puede crear previamente una cierta cantidad de objetos, obtener objetos del grupo cuando sea necesario y volver a colocar los objetos en el grupo después de su uso;

resumen

Como dice el refrán: "Si no acumulas pasos, no puedes llegar a miles de millas; si no acumulas pequeños arroyos, no puedes formar ríos y mares". Los detalles de codificación enumerados anteriormente afectarán directa o indirectamente la eficiencia de ejecución del servicio, es solo una cuestión de cuánto afecta. En realidad, a veces no tenemos que ser demasiado exigentes, pero tienen una nota al pie común: espíritu geek.

Optimización de tres diseños

3.1 Almacenamiento en caché

El uso razonable de la caché puede mejorar efectivamente el rendimiento de la aplicación, acortar el tiempo de acceso a los datos y reducir la dependencia de las fuentes de datos. La memoria caché se puede diseñar en varios niveles. Por ejemplo, para mejorar la eficiencia operativa, la CPU ha diseñado una memoria caché L1-L3 de tres niveles. Al diseñar aplicaciones, también podemos diseñar capas de acuerdo con los requisitos comerciales. Los diseños jerárquicos comunes incluyen caché local (L1) y caché distribuida remota (L2).

El almacenamiento en caché local puede reducir las solicitudes de red, ahorrar recursos informáticos y reducir el acceso a fuentes de datos de alta carga, mejorando así la velocidad de respuesta y el rendimiento de las aplicaciones. El middleware de caché local común es: Caffeine, Guava Cache, Ehcache. Por supuesto, también puede usar un contenedor como Map para construir su propia estructura de caché en la aplicación. En comparación con el caché local, el caché distribuido tiene las ventajas de garantizar la coherencia de los datos, mantener solo una copia de los datos, reducir la redundancia de datos, permitir la fragmentación de datos y lograr un almacenamiento de datos de gran capacidad. Los cachés distribuidos comunes son: Redis, Memcached.

Un ejemplo de implementación de un caché local LRU simple es el siguiente:

/**
 * Least recently used 内存缓存过期策略:最近最少使用
 * Title: 带容量的<b>线程不安全的</b>最近访问排序的Hashmap
 * Description: 最后访问的元素在最后面。<br>
 * 如果要线程安全,请使用<pre>Collections.synchronizedMap(new LRUHashMap(123));</pre> <br>
 *
 * @author: liuhuiqing
 * @date: 20123/4/27
 */
public class LRUHashMap<K, V> extends LinkedHashMap<K, V> {

    /**
     * The Size.
     */
    private final int maxSize;

    /**
     * 初始化一个最大值, 按访问顺序排序
     *
     * @param maxSize the max size
     */
    public LRUHashMap(int maxSize) {
        //0.75是默认值,true表示按访问顺序排序
        super(maxSize, 0.75f, true);
        this.maxSize = maxSize;
    }

    /**
     * 初始化一个最大值, 按指定顺序排序
     *
     * @param maxSize     最大值
     * @param accessOrder true表示按访问顺序排序,false为插入顺序
     */
    public LRUHashMap(int maxSize, boolean accessOrder) {
        //0.75是默认值,true表示按访问顺序排序,false为插入顺序
        super(maxSize, 0.75f, accessOrder);
        this.maxSize = maxSize;
    }

    @Override
    protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
        return super.size() > maxSize;
    }

}

3.2 Asíncrono

La asincronía puede mejorar el rendimiento y la capacidad de respuesta de un programa, lo que le permite manejar de manera más eficiente datos a gran escala o solicitudes simultáneas. Su principio subyacente involucra tecnologías clave como subprocesamiento múltiple, bucle de eventos, cola de tareas y función de devolución de llamada del sistema operativo.Además, la idea de asincronía también se usa ampliamente en el diseño de arquitectura de aplicaciones. Aquí no se presentarán los subprocesos múltiples convencionales, las colas de mensajes, la programación receptiva y otras soluciones de procesamiento asincrónico Aquí hay dos habilidades prácticas que todos pueden pasar por alto fácilmente: E/S sin bloqueo y corrutinas.

E/S sin bloqueo

La especificación Java Servlet 3.0 introduce el concepto de Servlet asíncrono, que puede ayudar a los desarrolladores a mejorar el rendimiento y las capacidades de procesamiento simultáneo de las aplicaciones. El principio es que la E/S sin bloqueo utiliza un único subproceso para procesar varias solicitudes al mismo tiempo, evitando la sobrecarga. de conmutación y bloqueo de subprocesos, especialmente Puede evitar bloquear el procesamiento de otras solicitudes al leer archivos grandes o realizar escenarios de cálculo complejos que consumen mucho tiempo. El esquema de procesamiento asíncrono correspondiente también se proporciona en el marco Spring MVC.

• Utilice el método Callable para realizar el procesamiento asíncrono

@GetMapping("/async/callable")
public WebAsyncTask<String> asyncCallable() {
    Callable<String> callable = () -> {
        // 执行异步操作
        return "异步任务已完成";
    };
    return new WebAsyncTask<>(10000, callable);
}

• Utilice DeferredResult para lograr un procesamiento asíncrono

@GetMapping("/async/deferredresult")
public DeferredResult<String> asyncDeferredResult() {
    DeferredResult<String> deferredResult = new DeferredResult<>(10000L);
    // 异步处理完成后设置结果
    deferredResult.setResult("DeferredResult异步任务已完成");
    return deferredResult;
}

rutina

Sabemos que la creación y destrucción de subprocesos consume muchos recursos del sistema, por lo que existe un grupo de subprocesos, pero esto no es suficiente, porque la cantidad de subprocesos es limitada (miles de niveles), los subprocesos bloquearán los subprocesos del sistema operativo y el rendimiento no se puede mejorar tanto como sea posible. Debido a que el costo de usar subprocesos es muy alto, existen subprocesos virtuales, que son subprocesos en modo usuario, el costo es bastante bajo y la programación está completamente controlada por el usuario (programador en JDK).También puede bloquear, pero hay no es necesario bloquear el subproceso del sistema operativo, lo que mejora por completo la tasa de utilización del hardware, y la alta concurrencia también aumenta en un orden de magnitud.

Durante mucho tiempo, el concepto de rutinas no era una característica integrada de la JVM, sino que se implementaba a través de bibliotecas o marcos de terceros. En la actualidad, las bibliotecas de implementación de rutinas comúnmente utilizadas incluyen Quasar, Kilim, etc. Pero en la versión Java19, se introdujo soporte para subprocesos virtuales (Virtual Threads) (en la etapa de Vista previa).

Un subproceso virtual es una implementación de java.lang.Thread y se puede crear utilizando la interfaz java.lang.Thread.Builder

Thread thread = Thread.ofVirtual()
     .name("Virtual Threads")
     .unstarted(runnable);

También se puede crear a través de una clase de fábrica de hilos:

ThreadFactory factory = Thread.ofVirtual().factory();

El portador de un subproceso virtual debe ser un subproceso y varias instancias de subprocesos virtuales pueden ejecutarse en el mismo subproceso.

3.3 Paralelo

La idea del procesamiento paralelo juega un papel importante en varios aspectos, como big data, multitarea, procesamiento de canalización y entrenamiento de modelos, incluido el asíncrono (multihilo, corotina, mensaje, etc.) presentado anteriormente, que también se basa en el paralelismo. . A nivel de aplicación, los escenarios típicos incluyen:

  • MapReduce en el marco de computación distribuida está diseñado con una idea de divide y vencerás. Divide las tareas complejas o computacionalmente intensivas en pequeñas tareas, y las tareas pequeñas se ejecutan en paralelo en diferentes subprocesos o servidores. Finalmente, resuma los resultados de cada pequeña tarea.
  • Edge Computing (Edge Computing) es un paradigma de computación distribuida que extiende algunas funciones de computación, almacenamiento y servicios de red desde centros de datos en la nube a lugares más cercanos a la fuente de datos, es decir, el borde de la red. Este método informático puede lograr una baja latencia, ahorrar ancho de banda, mejorar la seguridad de los datos y el procesamiento y análisis en tiempo real.

En términos de implementación de código, el diseño de desacoplamiento se hace bien y luego se puede realizar un diseño paralelo, como:

  • Múltiples solicitudes pueden ser procesadas en paralelo por múltiples subprocesos, con diferentes etapas de procesamiento para cada solicitud;
  • Por ejemplo, en la fase de consulta, las rutinas se pueden usar para ejecutar en paralelo;
  • En la etapa de almacenamiento, puede ser procesado mediante suscripción y publicación de mensajes;
  • En la etapa de monitoreo y estadísticas, NIO se puede utilizar para escribir archivos de datos de indicadores de manera asíncrona;
  • La solicitud/respuesta adopta el modo IO sin bloqueo;

3.4 Agrupación

La agrupación es el recurso preestablecido inicial, que reduce el consumo de adquisición de recursos cada vez, como la sobrecarga de crear subprocesos y la sobrecarga de obtener conexiones remotas. Los escenarios típicos son grupos de subprocesos, grupos de conexiones de bases de datos, grupos de caché de resultados de procesamiento empresarial, etc.

Tome el grupo de conexiones de la base de datos como ejemplo, su esencia es una conexión de socket. Abrir y mantener una conexión de base de datos para cada solicitud, especialmente para aplicaciones dinámicas basadas en bases de datos, es costoso y desperdicia recursos. ¿Por qué dices eso? Tomando como ejemplo el establecimiento de conexión de base de datos MySQL (protocolo TCP), hay tres pasos para establecer una conexión:

  • Establezca una conexión TCP a través de un protocolo de enlace de tres vías;
  • El servidor envía un "mensaje de reconocimiento" al cliente y el cliente responde al mensaje de reconocimiento;
  • El cliente "envía un paquete de autenticación" para la autenticación del usuario. Después de que la autenticación es exitosa, el servidor devuelve una respuesta OK y luego comienza a ejecutar el comando;

Estadísticas simples y aproximadas, para completar una conexión de base de datos, se requieren al menos 7 viajes de ida y vuelta entre el cliente y el servidor, y el tiempo promedio total es de aproximadamente 200 ms, lo que es casi inaceptable para muchos servicios del lado C.

A nivel de escritura de código, también podemos usar esta idea para optimizar el rendimiento de ejecución de nuestro programa.

  1. Solo se puede definir globalmente una copia de los datos públicos, como el uso de enumeraciones, objetos contenedores modificados estáticamente, etc.;
  2. De acuerdo con la situación real, configure la capacidad inicial de los objetos contenedores como List y Map con anticipación para evitar la expansión posterior y el impacto en el rendimiento;
  3. Aplicación del patrón de diseño de Hengyuan, etc.;

3.5 Preprocesamiento

En general, el contenido que debe agruparse debe procesarse previamente. Por ejemplo, para garantizar la estabilidad del servicio, el contenido que debe agruparse, como el grupo de subprocesos y el grupo de conexiones de la base de datos, se procesa cuando el El contenedor JVM se inicia y antes de procesar la solicitud real, realice el preprocesamiento y, cuando llegue la solicitud de procesamiento comercial real, se puede procesar con normalidad y rapidez. Además, el preprocesamiento también se puede reflejar en el nivel de arquitectura del sistema.

  1. Para mejorar el rendimiento de la respuesta, algunos datos comerciales se cargan previamente en la memoria;
  2. Para reducir la presión de la CPU, la lógica de cálculo se ejecuta por adelantado y los datos de resultados calculados se guardan directamente para que la persona que llama los use directamente;
  3. Para reducir el costo del ancho de banda de la red, los datos transmitidos se comprimen a través de un algoritmo de compresión, y cuando llegan al servicio de destino, se descomprimen para obtener los datos originales;
  4. Para mejorar la seguridad y la eficiencia de ejecución de las sentencias SQL, Myibatis también introduce el concepto de preprocesamiento;

cuatro resumen

La optimización del rendimiento es un tema que no se puede evitar en el proceso de desarrollo de programas. Este artículo se centra en dos aspectos del código y el diseño, desde el hardware de la CPU hasta el contenedor de JVM, desde el diseño de caché hasta el preprocesamiento de datos, y demuestra completamente la dirección de implementación y los detalles de implementación. de optimización del rendimiento. El proceso de elaboración no persiguió todos los aspectos en todas las direcciones, pero se dieron algunos casos basados ​​en escenarios para ayudar a comprender y pensar, y desempeñar un papel en la atracción del jade.

Autor: JD Retail Liu Huiqing

Fuente de contenido: comunidad de desarrolladores de JD Cloud

{{o.nombre}}
{{m.nombre}}

Supongo que te gusta

Origin my.oschina.net/u/4090830/blog/8805083
Recomendado
Clasificación