Comparta el análisis de 10 puntos de conocimiento clave que debemos dominar absolutamente para aprender JVM

Prefacio

  • sistema de estructura jvm

Cada programa Java es inseparable de la máquina virtual Java, y la ejecución del programa Java se basa en una instancia específica de la máquina virtual Java. En la especificación de la máquina virtual Java, estos términos se utilizan para describir el subsistema, el área de memoria, el tipo de datos y la instrucción. Juntos, estos componentes muestran la arquitectura abstracta dentro de una máquina virtual abstracta.
La máquina virtual Java se divide principalmente en cinco módulos: subsistema cargador de clases, área de datos en tiempo de ejecución, motor de ejecución, interfaz de método local y módulo de recolección de basura. Entre ellos, el módulo de recolección de basura no requiere recolección de basura de la máquina virtual Java en la especificación de la máquina virtual Java, pero antes de la invención de la memoria ilimitada, la mayoría de las implementaciones de JVM tienen recolección de basura. El área de datos de tiempo de ejecución existirá en cada instancia de máquina virtual JAVA de alguna forma, pero la descripción de la misma en la especificación de la máquina virtual Java es bastante abstracta. Los detalles de estas estructuras de datos en tiempo de ejecución son determinados principalmente por el diseñador de la implementación específica.
Este artículo tiene un espacio limitado, solo una parte de él está cortada de mis notas para compartir. Si lo encuentra útil y necesita más conocimiento sobre JVM y otras preguntas reales de la entrevista (con análisis detallado y respuestas a la pregunta)
, haga clic aquí : qf

Inserte la descripción de la imagen aquí

1.¿En qué circunstancias se producirá el desbordamiento de la memoria de la pila?

(1) Pensando

Describa la definición de pila, luego describa por qué se desborda y luego explique los parámetros de configuración relevantes. Si está bien, puede escribir una demostración de desbordamiento de pila para el entrevistador.

(2) Mi respuesta

La pila es privada para el subproceso y su ciclo de vida es el mismo que el del subproceso. Cuando se ejecuta cada método, se crea un marco de pila para almacenar la tabla de variables locales, la pila de operandos, el enlace dinámico y la información de la lámpara de salida del método. La tabla de variables locales también contiene tipos de datos básicos, tipos de referencia de objetos

Si la profundidad de la pila solicitada por el hilo es mayor que la profundidad máxima permitida por la máquina virtual, se lanzará una excepción StackOverflowError y la llamada recursiva al método produce este resultado.

No se puede solicitar suficiente memoria para completar la expansión, o no hay suficiente memoria para crear la pila de la máquina virtual correspondiente al crear un nuevo subproceso, la máquina virtual java lanzará una excepción OutOfMemoryError. (Se iniciaron demasiados hilos)

Parámetro -Xss para ajustar el tamaño de la pila JVM

2. Modelo de memoria JVM detallado

(1) Pensando

Dibuje el diagrama del modelo de memoria JVM para el entrevistador y describa la definición y función de cada módulo, así como los posibles problemas, como el desbordamiento de la pila.

(2) Mi respuesta

Estructura de la memoria JVM

Inserte la descripción de la imagen aquí

  • Contador de programa: el indicador de número de línea del código de bytes ejecutado por el subproceso actual, que se utiliza para registrar la dirección de instrucción de bytes de la máquina virtual que se está ejecutando, y el subproceso es privado.
  • Pila virtual de Java: almacena tipos de datos básicos, referencias de objetos, salidas de métodos, etc., hilo privado.
  • Pila de métodos nativos: similar a la pila virtual, excepto que sirve métodos nativos y es privada para los subprocesos.
  • Montón de Java: la pieza más grande de memoria de Java, todas las instancias de objetos y matrices se almacenan en el montón de Java, donde se recopila el GC y se comparte por subprocesos.
  • Área de métodos: almacene la información de clase cargada, las constantes, las variables estáticas y los datos de código compilados por el compilador Just-In-Time. (Es decir, cinturón permanente), el principal objetivo del reciclaje es el reciclaje de piscinas constantes y la descarga de tipos, compartidos por cada hilo.

3. ¿Por qué debería dividirse la memoria JVM en nueva generación, generación antigua y generación persistente? ¿Por qué la nueva generación está dividida en Eden y Survivor?

(1) Pensando

Permítanme hablar del montón de JAVA, la división de la nueva generación, y luego hablar de la conversión entre ellos, la configuración de algunos parámetros entre ellos (como: -XX: NewRatio, -XX: SurvivorRatio, etc.), y luego explicar por qué se divide de esta manera. Es mejor agregar un poco de comprensión.

(2) Mi respuesta

1) División del área de memoria compartida

  • Área de memoria compartida = zona persistente + montón
  • Zona persistente = área de método + otro
  • Montón de Java = vieja generación + nueva generación
  • Generación joven = Eden + S0 + S1

2) Configuración de algunos parámetros

  • Por defecto, la relación de la generación joven (Joven) a la generación anterior (Vieja) es 1: 2, que se puede configurar a través del parámetro -XX: NewRatio.
  • Por defecto, Edem: de: a = 8: 1: 1 (se puede configurar con el parámetro -XX: SurvivorRatio)

La cantidad de veces que se copia el objeto en el área Survivor es 15 (correspondiente al parámetro de máquina virtual -XX: + MaxTenuringThreshold)

3) ¿Por qué se divide en Eden y Survivor? ¿Por qué hay dos áreas de Survivor?

Si no hay ningún Superviviente, cada vez que se realice una GC menor en el área del Edén, los objetos supervivientes se enviarán a la generación anterior. La generación anterior se llena rápidamente, lo que activa Major GC. El espacio de memoria de la generación anterior es mucho más grande que el de la nueva generación. Se necesita mucho más tiempo para realizar un GC completo que el GC menor, por lo que debe dividirse en Eden y Survivor.

La importancia de Survivor es reducir los objetos enviados a la generación anterior, reduciendo así la ocurrencia de Full GC. La preselección de Survivor garantiza que solo los objetos que puedan sobrevivir en la generación joven después de 16 Minor GC se enviarán a la generación anterior. Años.

La mayor ventaja de establecer dos áreas de Supervivientes es resolver la fragmentación. El objeto recién creado en Edén se somete a una GC Menor, y el objeto superviviente en Edén se moverá al primer espacio de superviviente S0, y Edén se vaciará; espera a Edén Cuando el área está llena, el Minor GC se activará nuevamente, y los objetos supervivientes en Eden y S0 se copiarán y enviarán al segundo espacio superviviente S1 (este proceso es muy importante, porque este algoritmo de copia asegura que S0 y Eden en S1 Los objetos activos de dos partes ocupan un espacio de memoria contiguo para evitar la fragmentación)

4. ¿Qué es un proceso GC completo en JVM? ¿Cómo se promueve a los sujetos a la vejez?

(1) Pensando

Primero, describa la división de memoria del montón de Java, luego explique el GC menor, el GC mayor y el GC completo, y describa el proceso de conversión entre ellos.

(2) Mi respuesta

Montón de Java = vieja generación + nueva generación

Generación joven = Eden + S0 + S1

Cuando el espacio en el área de Eden está lleno, la máquina virtual Java activa un Minor GC para recolectar la nueva generación de basura, y los objetos supervivientes se transferirán al área de Survivor.

Los objetos grandes (objetos Java que requieren una gran cantidad de espacio de memoria contigua, como cadenas muy largas) ingresan directamente al estado anterior;

Si el sujeto nació en el Edén y sobrevivió al primer CG menor, y es acomodado por Superviviente, la edad se establece en 1 y la edad es +1 después de cada CG menor. Si la edad excede un cierto límite (15), será Promovido a la vejez. Es decir, el objeto sobreviviente a largo plazo entra en el estado anterior.

La generación anterior está llena y no puede acomodar más objetos. Después de la GC menor, generalmente se realiza la GC completa. La GC completa limpia todo el montón de memoria, incluidas la generación joven y la generación anterior.

La GC mayor ocurre en la GC de la vejez. La limpieza del área antigua a menudo va acompañada de al menos una GC menor, que es más de 10 veces más lenta que la GC menor.

5. ¿Sabes qué tipo de recolectores de basura, sus ventajas y desventajas, se enfocan en cms y G1, incluyendo principios, procesos, ventajas y desventajas?

(1) Pensando

Asegúrese de recordar a los recolectores de basura típicos, especialmente cms y G1, sus principios y diferencias, y los algoritmos de recolección de basura involucrados.

(2) Mi respuesta

1) Varios recolectores de basura

  • Colector en serie: un recolector de un solo subproceso Al recolectar basura, debe detener el mundo y usar el algoritmo de copia.
  • Colector ParNew: La versión multiproceso del colector Serial también necesita detener el mundo y copiar el algoritmo.
  • Recopilador de búsqueda paralela: el recopilador de nueva generación, el recopilador del algoritmo de replicación, el recopilador de múltiples subprocesos simultáneos, el objetivo es lograr un rendimiento controlable. Si la máquina virtual se ejecuta durante un total de 100 minutos, de los cuales la basura tarda 1 minuto, el rendimiento es del 99%.
  • Serial Old Collector: es la versión antigua del Serial Collector, un recopilador de un solo subproceso, que utiliza un algoritmo de clasificación de etiquetas.
  • Parallel Old Collector: es la versión anterior de Parallel Scavenge Collector, que utiliza un algoritmo de clasificación de marcas y subprocesos múltiples.
  • Recolector CMS (Concurrent Mark Sweep): Es un recolector que tiene como objetivo obtener el menor tiempo de pausa de recuperación. Algoritmo de eliminación de marcas, proceso de operación: marcado inicial, marcado concurrente, remarcado, retiro concurrente, el final de la recolección generará una gran cantidad de basura espacial .
  • Recopilador G1: Implementación del algoritmo de clasificación de notas, el proceso de operación incluye principalmente lo siguiente: nota inicial, nota concurrente, nota final, nota de cribado. No se generan desechos espaciales y las pausas se pueden controlar con precisión.

2) La diferencia entre el colector CMS y el colector G1

El colector CMS es el colector de la vieja generación y se puede utilizar con los colectores Serial y ParNew de nueva generación;

La gama de colección del colector G1 es para las generaciones viejas y jóvenes, y no necesita ser utilizada en conjunto con otros coleccionistas;

El recopilador de CMS es un recopilador cuyo objetivo es minimizar el tiempo de pausa;

El recolector G1 puede predecir el tiempo de pausa de la recolección de basura

El recolector de CMS utiliza el algoritmo "mark-sweep" para la recolección de basura, que es propenso a la fragmentación de la memoria.

El recopilador G1 utiliza el algoritmo "mark-define" para integrar el espacio y reducir la fragmentación del espacio de la memoria.

6. ¿Cuánto sabe sobre el conocimiento relevante del modelo de memoria JVM, como reordenamiento, barrera de memoria, suceso anterior, memoria principal, memoria de trabajo?

(1) Pensando

Primero dibuje un diagrama del modelo de memoria Java, combinado con un ejemplo de volátil, para explicar qué son el reordenamiento y las barreras de la memoria. Es mejor escribir las siguientes instrucciones de demostración para el entrevistador.

(2) Mi respuesta

1) Diagrama del modelo de memoria de Java:

Inserte la descripción de la imagen aquí

El modelo de memoria de Java estipula que todas las variables se almacenan en la memoria principal. Cada hilo tiene su propia memoria de trabajo. La memoria de trabajo del hilo almacena una copia de la memoria principal de las variables utilizadas en el hilo. Todas las operaciones deben realizarse en la memoria de trabajo y la memoria principal no se puede leer ni escribir directamente. Diferentes subprocesos no pueden acceder directamente a las variables en la memoria de trabajo del otro La transmisión de variables entre subprocesos requiere sincronización de datos entre su propia memoria de trabajo y la memoria principal.

2) Reordenar pedidos.

Aquí, primero mira un fragmento de código

public class PossibleReordering {
    
    
          static int x = 0, y = 0 ;
          static int a = 0, b = 0 ;
          public static void sain(String{
    
    } args) throws Int erruptedException {
    
    
                    Thread one = new Thread(new Runnable() [
                               public void run() {
    
    
                                         a = 1;
                                         x = b;
                                }
]);
Thread other = new Thread(new Runnable() [
            public void run() {
    
    
                      b = 1;
                      y = a; 
            }
]);
one.start() ; other.start(),
one.join() ; oher.join();
Systen.out.println("("+x+","+y+")");
 

El resultado de la operación puede ser (1,0), (0,1) o (1,1), o puede ser (0,0). Porque, en el funcionamiento real, es posible que las instrucciones de código no se ejecuten estrictamente en el orden de las declaraciones de código. La mayoría de los microprocesadores modernos adoptarán métodos de ejecución desordenada (OoOE u OOE). Cuando las condiciones lo permitan, ejecutarán directamente las instrucciones subsiguientes que actualmente son capaces de ejecución inmediata, evitando la búsqueda. Espera provocada por los datos necesarios para la siguiente instrucción 3. A través de la tecnología de ejecución fuera de orden, el procesador puede mejorar en gran medida la eficiencia de ejecución. Y este es el reordenamiento del orden.

3) Barrera de memoria La barrera de memoria, también llamada barrera de memoria, es una instrucción de la CPU que se utiliza para controlar el reordenamiento y los problemas de visibilidad de la memoria en condiciones específicas.

  • Barrera LoadLoad: Para tales declaraciones Load1; LoadLoad; Load2, antes de que se acceda a los datos a leer por Load2 y las operaciones de lectura subsiguientes, se garantiza que los datos a leer por Load1 han sido leídos.
  • Barrera StoreStore: para tales declaraciones Store1; StoreStore; Store2, antes de que se ejecuten Store2 y las operaciones de escritura posteriores, se garantiza que las operaciones de escritura de Store1 sean visibles para otros procesadores.
  • Barrera LoadStore: Para tales declaraciones Load1; LoadStore; Store2, antes de que Store2 y las operaciones de escritura subsiguientes se eliminen, se garantiza que los datos que debe leer Load1 han sido leídos.
  • Barrera StoreLoad: para tales declaraciones Store1; StoreLoad; Load2, antes de que se ejecuten Load2 y todas las operaciones de lectura posteriores, se garantiza que las escrituras de Store1 sean visibles para todos los procesadores. Su techo es la más grande de las cuatro barreras. En la mayoría de las implementaciones de procesadores, esta barrera es una barrera universal, que tiene las funciones de las otras tres barreras de memoria.

4) El principio de pasar antes

  • Principio de suceder antes de un solo hilo: en el mismo hilo, escriba las operaciones que siguen a suceder antes antes de las operaciones anteriores. El principio de pasar antes de la cerradura: la operación de desbloqueo de la misma cerradura ocurre antes de la operación de cerradura de esta cerradura.
  • Principio de suceder antes de volátiles: la operación de escritura de una variable volátil ocurre antes de cualquier operación en esta variable (por supuesto, también incluye operaciones de escritura).
  • El principio de transitividad de suceder-antes: si una operación ocurre-antes de la operación B, la operación B ocurre-antes de la operación C, entonces ocurre una operación-antes de la operación C.
  • El principio de suceder antes del inicio del hilo: el método de inicio del mismo hilo ocurre antes que otros métodos de este hilo.
  • El principio de suceder antes de la interrupción del hilo: La llamada al método de interrupción del hilo sucede antes es el código que detecta el envío de la interrupción del hilo interrumpido.
  • El principio de suceder antes de la terminación del hilo: todas las operaciones en el hilo ocurren antes de la detección de la terminación del hilo.
  • El principio de suceder antes de la creación de objetos: la inicialización de un objeto se completa antes de su llamada al método finalize.

7. Hable brevemente sobre el cargador de clases que conoce, ¿puede romper la delegación de los padres y cómo?

(1) Pensando

Primero explique qué es un cargador de clases, puede hacer un dibujo para el entrevistador y luego hablar sobre el significado de la existencia del cargador de clases, hablar sobre el modelo de delegación principal y, finalmente, explicar cómo romper el modelo de delegación principal.

(2) Mi respuesta

  1. ¿Qué es un cargador de clases?

El cargador de clases carga el archivo de clase en la memoria JVM de acuerdo con el nombre completo especificado y lo convierte en un objeto de clase.

  • Bootstrap ClassLoader: está implementado por lenguaje C ++ (para HotSpot), y es responsable de cargar la biblioteca de clases almacenada en el directorio <JAVA_HOME> \ lib o la ruta especificada por el parámetro -Xbootclasspath en la memoria.
  • Otros cargadores de clases: implementados por lenguaje Java, heredados de la clase abstracta ClassLoader.

Como:

  • Extension ClassLoader: Responsable de cargar todas las bibliotecas de clases en el directorio <JAVA_HOME> \ lib \ ext o la ruta especificada por la variable de sistema java.ext.dirs.

Cargador de clases de aplicación (Cargador de clases de aplicación). Responsable de cargar la biblioteca de clases especificada en la ruta de clases del usuario, podemos usar este cargador de clases directamente. En general, si no tenemos un cargador de clases personalizado, este cargador se usa por defecto.

2) Modelo de delegación parental

El proceso de trabajo del modelo de delegación principal es que si un cargador de clases recibe una solicitud de carga de clases, primero no intentará cargar la clase por sí mismo, sino que delegará la solicitud al cargador de clases principal para completarla. Esto es cierto para todos los cargadores de clases Solo cuando el cargador principal no puede encontrar la clase especificada en su rango de búsqueda (es decir, ClassNotFoundException), el cargador secundario intentará cargarla por sí mismo.

Diagrama del modelo de delegación de padres:

Inserte la descripción de la imagen aquí

3) ¿Por qué necesita un modelo de delegación principal?

Aquí, primero piénselo, si no hay delegación parental, ¿puede el usuario definir un java.lang.Object con el mismo nombre, java.lang.String con el mismo nombre, y ponerlo en ClassPath, luego entre las clases El resultado de la comparación y la unicidad de la clase no se pueden garantizar, entonces, ¿por qué necesita un modelo de delegación parental? Evitar múltiples copias del mismo byte en la memoria

4) ¿Cómo romper el modelo de delegación parental?

Romper el mecanismo de delegación principal requiere no solo heredar la clase ClassLoader, sino también reescribir los métodos loadClass y findClass.

8. Cuéntame sobre los principales parámetros de JVM que conoces.

(1) Pensando

Puede hablar sobre la configuración de la pila, relacionada con el recolector de basura y la información auxiliar relacionada.

(2) Mi respuesta

1) Configuración de pila relacionada

java -Xmx3550m -Xms3550m -Xmn2g -Xss128k

  • XX: MaxPermSize = 16m

  • XX: NewRatio = 4

  • XX: SurvivorRatio = 4

  • XX: MaxTenuringThreshold = 0

  • Xmx3550m: el tamaño máximo de pila es 3550 m.

  • Xms3550m: establezca el tamaño de pila inicial en 3550 m.

  • Xmn2g: establece el tamaño de la generación joven en 2g.

  • Xss128k: el tamaño de pila de cada subproceso es 128k.

  • XX: MaxPermSize: establece el tamaño de generación persistente en 16 m

  • XX: NewRatio = 4: establece la proporción de la generación joven (incluidas las áreas de Edén y dos Supervivientes) y la generación anterior (excluida la generación permanente).

  • XX: SurvivorRatio = 4: establece la proporción de tamaño del área de Edén al área de Superviviente en la generación joven. Establecido en 4, la proporción de dos áreas de Supervivientes por un área de Edén es 2: 4, y un área de Supervivientes ocupa 1/6 de toda la generación joven.

  • XX: MaxTenuringThreshold = 0: establece la edad máxima de la basura. Si se establece en 0, el objeto de la generación joven no pasa por el área de Superviviente y entra directamente en la generación anterior.

2) relacionado con el recolector de basura

  • XX: + UseParallelGC -XX: ParallelGCThreads = 20 -XX: + UseConcMarkSweepGC

  • XX: CMSFullGCsBeforeCompaction = 5 -XX: + UseCMSCompactAtFullCollection :

  • XX: + UseParallelGC: seleccione el recolector de basura como recolector paralelo.

  • XX: ParallelGCThreads = 20: Configure el número de subprocesos del colector paralelo

  • XX: + UseConcMarkSweepGC: establece la generación anterior en recopilación simultánea.

  • XX: CMSFullGCsBeforeCompaction: Debido a que el recopilador concurrente no comprime ni organiza el espacio de memoria, producirá "fragmentación" después de ejecutarse durante un período de tiempo, lo que reduce la eficiencia operativa. Este valor establece cuántas veces se ejecutará el GC para comprimir y organizar el espacio de memoria.

  • XX: + UseCMSCompactAtFullCollection: activa la compresión para la generación anterior. Puede afectar el rendimiento, pero puede eliminar la fragmentación.

3) Información auxiliar relacionada

  • XX: + PrintGC -XX: + PrintGCDetails

  • XX: + PrintGC Formulario de salida:

[GC 118250K-> 113543K (130112K), 0.0094143 segundos] [GC completo 121376K-> 10414K (130112K), 0.0650971 segundos]

  • XX: + Formato de salida PrintGCDetails:

[GC [DefNew: 8614K-> 781K (9088K), 0.0123035 s] 118250K-> 113543K (130112K), 0.0124633 s]

[GC [DefNew: 8614K-> 8614K (9088K), 0.0000665 s] [Tenured: 112761K-> 10414K (121024K), 0.0433488 secs] 121376K-> 10414K (130112K), 0.0436268 sec

9. ¿Cómo imprimir la información de la pila de hilos?

(1) Pensando

Puede hablar sobre los comandos jps, top, jstack y luego cooperar con la solución de problemas en línea para responderlos.

(2) Mi respuesta

Ingrese jps para obtener el número de proceso.

top -Hp pid Obtiene el rendimiento de CPU que consume mucho tiempo de todos los subprocesos en este proceso

Comando jstack pid para ver el estado de la pila del proceso java actual

O jstack -l> /tmp/output.txt para imprimir la información de la pila en un archivo txt.

Puede utilizar el posicionamiento de pila de hilos rápidos

10. ¿Cuál es la diferencia entre referencia fuerte, referencia suave, referencia débil y referencia fantasma?

(1) Pensando

Permítanme hablar primero sobre la definición de los cuatro tipos de referencias. Puede hablar sobre ello junto con el código, o puede extenderlo para hablar sobre el uso de referencias débiles en ThreadLocalMap.

(2) Mi respuesta

1) Referencias fuertes

Por lo general, un nuevo objeto es una referencia fuerte, como Object obj = new Object (); Incluso en el caso de memoria insuficiente, la JVM preferiría lanzar un error OutOfMemory que reclamar este objeto.

2) referencias blandas

Si un objeto tiene solo referencias suaves, el espacio de memoria es suficiente, el recolector de basura no lo reclamará; si el espacio de memoria es insuficiente, se reclamará la memoria de estos objetos.

SoftReference softRef = new SoftReference (str); // referencia suave

3) Referencias débiles

Los objetos con referencias débiles tienen un ciclo de vida más corto. En el proceso del subproceso del recolector de basura que escanea el área de memoria bajo su jurisdicción, una vez que se encuentra un objeto con solo referencias débiles, su memoria se recuperará independientemente de si el espacio de memoria actual es suficiente.

String str=new String("abc");
 
WeakReference<String> abcWeakRef = new WeakReference<String>(str);
 
str=null;
 
System.gc();

4) Referencias fantasmas

Si un objeto contiene solo referencias fantasmas, entonces es lo mismo que sin ninguna referencia, y el recolector de basura puede recolectarlo en cualquier momento. Las referencias fantasma se utilizan principalmente para rastrear las actividades de los objetos que recicla el recolector de basura.

Conclusión

Una característica muy importante del lenguaje Java es su independencia de la plataforma. El uso de la máquina virtual Java es la clave para lograr esta característica. Si un lenguaje general de alto nivel se va a ejecutar en diferentes plataformas, al menos debe compilarse en diferentes códigos de destino. Después de la introducción de la máquina virtual del lenguaje Java, no es necesario volver a compilar el lenguaje Java cuando se ejecuta en diferentes plataformas. Modo de uso del lenguaje Java La máquina virtual Java protege la información relacionada con la plataforma específica, de modo que el compilador del lenguaje Java solo necesita generar el código objeto (código de bytes) que se ejecuta en la máquina virtual Java, y se puede utilizar en múltiples plataformas sin modificaciones. correr. Cuando la máquina virtual Java ejecuta el código de bytes, interpreta el código de bytes en instrucciones de la máquina para su ejecución en una plataforma específica.
Recientemente, es el mejor momento para encontrar un trabajo. He recopilado algunas preguntas de entrevistas de los principales fabricantes y los datos más recientes de este año (2020). A continuación, se muestran algunas capturas de pantalla de los datos (todos los datos se han integrado en los documentos y en formato PDF comprimido y empaquetado) .
Si tiene un amigo que lo necesita, puede hacer clic aquí para obtener información, código: qf

Inserte la descripción de la imagen aquí
Inserte la descripción de la imagen aquí

Supongo que te gusta

Origin blog.csdn.net/SpringBoot_/article/details/108752564
Recomendado
Clasificación