Java Multithreading - Sincronizado

seguridad del hilo

El uso de subprocesos múltiples puede mejorar la eficiencia del programa, pero también trae algunos problemas adicionales.

ejemplo

 private static int counter = 0;
 ​
 // t1和t2线程分别对静态变量counter进行自增和自减
 public static void main(String[] args) throws InterruptedException {
     Thread t1 = new Thread(() -> {
         for (int i = 0; i < 5000; i++) {
             counter++;
         }
     }, "t1");
     Thread t2 = new Thread(() -> {
         for (int i = 0; i < 5000; i++) {
             counter--;
         }
     }, "t2");
     t1.start();
     t2.start();
     t1.join();
     t2.join();
     log.debug("{}", counter);
 }
复制代码

Esperamos que el código anterior imprima el resultado después de ejecutarlo 0, pero puede que no sea así 0, pero un número positivo, un número negativo, 0es posible. Esto muestra que las variables compartidas de lectura y escritura de subprocesos múltiples tendrán problemas de subprocesos múltiples. Analicémoslo en detalle.

analizar

En java, el autoincremento y el autodecremento no son operaciones atómicas y se descomponen en bytecodes de la siguiente manera:

 // i++
 getstatic i // 获取静态变量i的值
 iconst_1 // 准备常量1
 iadd // 自增
 putstatic i // 将修改后的值存入静态变量i
     
 // i--
 getstatic i // 获取静态变量i的值
 iconst_1 // 准备常量1
 isub // 自减
 putstatic i // 将修改后的值存入静态变量i
复制代码

Permítanme hablar sobre esto primero Java内存模型, hay uno 主内存, cada subproceso 主内存copiará los datos en su propia 工作内存operación al leer datos y escribirá datos al escribir datos 主内存. como sigue:

image.png

A continuación, encuentre la causa del error a través del diagrama de secuencia:

image.pngDescubrimos que dado que el incremento y la disminución no son 原子性operaciones, es posible que ocurra un cambio de contexto en cualquier paso de la operación de instrucción, que es lo que causa el error.

Cuando varios subprocesos acceden a un recurso compartido:

  • No habrá problemas de seguridad de subprocesos durante las operaciones de lectura.
  • Al leer y escribir, si no es atómico, habrá problemas de seguridad de subprocesos
  • El bloque de código que crea el problema de seguridad de subprocesos se llama临界区

solución sincronizada

synchronizedes una palabra clave en Java que representa un bloque de código sincronizado, lo que garantiza que 临界区el bloque de código permanezca atómico. Como sigue:

 private static int counter = 0;
 private static final Object lock = new Object();
 ​
 // t1和t2线程分别对静态变量counter进行自增和自减
 public static void main(String[] args) throws InterruptedException {
     Thread t1 = new Thread(() -> {
         for (int i = 0; i < 5000; i++) {
             synchronized(lock){
                 counter++;
             }
         }
     }, "t1");
     Thread t2 = new Thread(() -> {
         for (int i = 0; i < 5000; i++) {
             synchronized(lock){
                 counter--;
             }
         }
     }, "t2");
     t1.start();
     t2.start();
     t1.join();
     t2.join();
     log.debug("{}", counter);
 }
复制代码

El proceso de ejecución anterior se refiere al siguiente diagrama de secuencia:

image.png

  • Solo tiene sentido usar sincronizado para monitorear el mismo bloqueo de objeto
  • sincronizado se puede definir en métodos miembro, y el bloqueo de objetos que monitorea es este
  • sincronizado se puede definir en un método estático, y el bloqueo de objeto que supervisa es el objeto de clase actual (como Test.class)

Cuándo manejar la seguridad de subprocesos

  1. variables miembro y variables estáticas

    Existen problemas de seguridad de subprocesos cuando varios subprocesos leen y escriben variables miembro y variables estáticas. No hay problemas de seguridad con solo lectura.

  2. variable local

    La mayoría de las variables de tipo básico y tipo de referencia en las variables locales son seguras para subprocesos, pero hay excepciones (nadie debería escribir código como este), de la siguiente manera:

     class ThreadSafe {
         public final void method1(int loopNumber) {
             ArrayList<String> list = new ArrayList<>();
             for (int i = 0; i < loopNumber; i++) {
                 method2(list);
                 method3(list);
             }
         }
         
         private void method2(ArrayList<String> list) {
             list.add("1");
         }
     ​
         // 局部变量传给其他方法, 逻辑居然是新起线程读写?
         // 或者子类重写是新起线程来读写?
         private/public void method3(ArrayList<String> list) {
             // list.remove(0);
             new Thread(() -> {
                 list.remove(0);
             }).start();
         }
     }
     ​
     class ThreadSafeSubClass extends ThreadSafe {
         // 重写新起一个线程来读写
         @Override
         public void method3(ArrayList<String> list) {
             new Thread(() -> {
                 list.remove(0);
             }).start();
         }
     }
    复制代码

principio sincronizado

encabezado de objeto

java中的对象包含一个对象头,其主要保存Mark Wordklass Word两部分,数组额外多一个array length。 其中Mark Word含有锁的信息,结构为

image.png Normal(正常状态),Biased(偏向锁状态),Lightweight Locaked(轻量级锁状态),Heavyweight Locked(重量级锁状态),Marked for GC(垃圾回收状态)

Monitor(管程)

操作系统在面对 进程/线程 间同步使用 semaphore(信号量)和 mutex(互斥量)同步原语。程序员直接使用其时,非常不方便并且容易出错。所以在 semaphore 和 mutex 的基础上,提出了更高层次的同步原语 monitor。

操作系统本身并不支持 monitor 机制,实际上,monitor 是属于编程语言的范畴,当你想要使用 monitor 时,先了解一下语言本身是否支持 monitor 原语,例如 C 语言它就不支持 monitor,Java 语言支持 monitor

Monitor包含了Owner(指属于哪个线程),EntryList(指未获取锁阻塞的线程列表),WaitSet(指未满足条件,需要等待的线程列表,如调用了 wait() 的线程)等信息。

重量级锁加锁流程

当 t2线程 使用synchronized给锁对象上锁时,锁对象的 Mark Word (状态变成Heavyweight Locked - 重量级锁状态)会指向一个 Monitor, 其 Owner 指向了 t2线程。因Owner已有指向,当 t3,t4线程 想再来获取锁时,会进入 EntryList 列表阻塞等待,当 t2线程 执行完后会释放锁并唤醒 t3,t4线程 来竞争锁。

image.png 如果线程可能需要某种条件才能继续执行,如 t1线程,其可能会调用类似 wait() 的手段来等待条件,这时线程就会进入 WaitSet 等待并且释放锁,当条件满足后被唤醒(如 notifyAll() ),然后进入 EntryList 来竞争锁。

上述讲的就是 synchronized 重量级锁的加锁过程,见名知意,重量级锁的性能比较差。所以就有了轻量级锁来优化。

轻量级锁加锁流程

如果一个锁对象被多个线程要加锁,但加锁的时间是错开的(没有竞争),那么就可以使用轻量级锁来优化。

当线程使用 synchronized 对锁对象加锁时,其栈帧中会创建一个锁记录(Lock Record)区域,其包含锁记录的地址信息和对象引用,对象引用指向锁对象,其尝试使用 CAS(比较并交换)机制来替换锁对象的 Mark Word(状态改为 Lightweight Locaked - 轻量级锁状态)为锁记录地址信息,锁记录的地址信息则换为锁对象的 Mark Word。当 CAS 替换成功,表示由线程持有轻量级锁。

image.png 当 CAS 替换失败,有两种情况,一是已经被其它线程持有锁,表示有竞争了,那么会升级为 重量级锁流程;

  • 当 Thread-1 进行轻量级加锁时,Thread-0 已经对该对象加了轻量级锁

image.png

  • 这时 Thread-1 加轻量级锁失败,进入 重量级锁流程

image.png 二是锁重入,那么再加一条锁记录,其地址信息为空,对象引用也指向锁对象。

image.png 当解锁时,如果发现锁记录的地址信息为空,那么会删除这条锁记录。 如果锁记录的地址信息不为空,则使用 CAS 将锁对象的 Mark Word 恢复,成功则解锁成功;失败则说明已经进行了锁升级那么进入重量级锁解锁流程。

轻量级锁在没有竞争时,每次重入还是需要 CAS 操作,其实没有必要,那么就有了偏向锁的优化。

偏向锁加锁流程

当线程对锁对象加锁时,第一次使用 CAS 操作将线程ID设置到锁对象的 Mark Word中(状态改为Biased - 偏向锁状态),后续如果发现还是这个线程来加锁,那么就不用 CAS 操作。如果有别的线程来加锁,那么会升级为轻量级锁。

  • 偏向锁默认开启,但是延迟的,大概程序运行2-3秒后正式开启。可以使用参数 -XX:BiasedLockingStartupDelay=0 来禁用延迟
  • 当调用锁对象的 hashCode() 时,会撤销偏向锁,因为偏向锁状态的 Mark Word没有存储 hashCode的区域。轻量级锁会在锁记录中记录 hashCode,重量级锁会在 Monitor中记录 hashCode
  • 当使用类似 wait()/notify() 时,会撤销偏向锁和轻量级锁,因为其机制只有在重量级锁中有实现
  • 偏向锁不会主动解锁释放,偏向锁重偏向一次之后不可再次重偏向。批量重偏向和批量撤销是针对类的优化,和对象无关。

锁升级流程总结

一个对象A刚开始实例化的时候,没有任何线程来访问它的时候。它是可偏向的,意味着,它现在认为只可能有一个线程来访问它,所以当第一个线程T1来访问它的时候,它会偏向T1,此时,对象A持有偏向锁。

T1在修改对象头成为偏向锁的时候使用 CAS 操作,并将对象头中的 ThreadID 改成自己的ID,之后再次访问这个对象时,只需要对比 ID,不需要再使用 CAS 在进行操作。

一旦有第二个线程T2访问这个对象,因为偏向锁不会主动释放,所以T2可以看到对象时偏向状态,这时表明在这个对象上已经存在竞争了。检查原来持有该对象锁的线程T1是否依然存活,如果挂了,则可以将对象变为无锁状态,然后重新偏向新的线程,如果原来的线程依然存活,则马上执行那个线程的操作栈,检查该对象的使用情况,如果仍然需要持有偏向锁, 则偏向锁升级为轻量级锁,(偏向锁就是这个时候升级为轻量级锁的)。如果不存在使用了,则可以将对象恢复成无锁状态,然后重新偏向。

轻量级锁认为竞争存在,但是竞争的程度很轻,一般两个线程对于同一个锁的操作都会错开,或者说稍微等待一下,即自旋,另一个线程就会释放锁。 但是当自旋超过一定的次数,或者一个线程在持有锁,一个在自旋,又有第三个来访时,轻量级锁膨胀为重量级锁,重量级锁使除了拥有锁的线程以外的线程都阻塞,防止CPU空转。

常见线程安全的类

String,Integer,StringBuffer,Random,Vector,Hashtable,juc包下的类

  • String,Integer等保证线程安全的原因是不可变的,虽然其有改变值的方法,但其实都是新的对象。
  • StringBuffer,Random,Vector,Hashtable等都是使用 synchronized来保证线程安全,但只是单个方法线程安全,方法组合使用还是不安全的。

Supongo que te gusta

Origin juejin.im/post/7078128095206588447
Recomendado
Clasificación