Optimización de sincronizado en Java

Este artículo presenta una serie de medidas de optimización de bloqueo para máquinas virtuales sincronizadas para lograr una concurrencia eficiente

La concurrencia eficiente es un elemento de mejora importante después de actualizar de JDK5 a JDK6. El equipo de desarrollo de la máquina virtual HotSpot invirtió muchos recursos en JDK6 para implementar varias técnicas de optimización de bloqueo, como Giro adaptativo, Eliminación de bloqueo de bloqueo, Engrosamiento de bloqueo, Bloqueo ligero, Biased Bloqueo, etc., todas estas tecnologías son para compartir datos de manera más eficiente entre subprocesos y resolver problemas de competencia, mejorando así la eficiencia de ejecución del programa.

Spinlock y giro adaptativo

En muchas aplicaciones, los datos compartidos se bloquean solo por un breve período de tiempo y no vale la pena suspender y reanudar los hilos durante este período de tiempo.

El bloqueo de giro se refiere a: después de que el subproceso A adquiere con éxito el bloqueo, cuando el subproceso B solicita el bloqueo, el subproceso B que solicita el bloqueo ejecuta un ciclo ocupado (giro), sin renunciar al tiempo de ejecución del procesador, observe el subproceso A que se mantiene el candado Si el candado se liberará pronto. El tiempo de espera de giro tiene un cierto límite.Si el giro excede el número limitado de veces y aún no logra obtener el bloqueo, se debe usar el método tradicional para suspender el hilo.

Giro adaptativo significa que el tiempo de giro ya no es fijo, sino que está determinado por el tiempo de giro anterior en la misma cerradura y el estado del propietario de la cerradura.

Cuando discutimos la sincronización de exclusión mutua anteriormente, mencionamos que el mayor impacto de la sincronización de exclusión mutua en el rendimiento es la implementación del bloqueo. Las operaciones de suspensión de subprocesos y reanudación de subprocesos deben transferirse al modo kernel para completarse. Estas operaciones mejoran la concurrencia. el rendimiento de la máquina virtual de Java Trae mucha presión. Al mismo tiempo, el equipo de desarrollo de la máquina virtual también notó que en muchas aplicaciones, el estado de bloqueo de los datos compartidos solo durará un corto período de tiempo y no vale la pena suspender y reanudar los hilos durante este período de tiempo. . La gran mayoría de las computadoras personales y servidores son sistemas de procesador multiprocesador (núcleo).Si la máquina física tiene más de un procesador o núcleo de procesador, se pueden ejecutar dos o más subprocesos en paralelo al mismo tiempo. lock later "espera un rato", pero no renuncia al tiempo de ejecución del procesador, para ver si el subproceso que mantiene el bloqueo lo liberará pronto. Para hacer que el subproceso espere, solo necesitamos dejar que el subproceso ejecute un ciclo ocupado (giro), esta técnica se denomina bloqueo de giro.

Spinlock se introdujo en JDK1.4.2, pero está desactivado de forma predeterminada y se puede activar utilizando el parámetro -XX:+UseSpinning. Se ha activado de forma predeterminada en JDK6. La espera de giro no puede reemplazar el bloqueo, y mucho menos los requisitos para la cantidad de procesadores. Aunque la espera de giro en sí misma evita la sobrecarga de la conmutación de subprocesos, consume tiempo del procesador, por lo que si el bloqueo está ocupado por un corto tiempo, el efecto de la espera de giro será ser muy bueno Por el contrario, si el bloqueo está ocupado durante mucho tiempo, entonces el hilo giratorio solo consumirá recursos del procesador en vano y no hará ningún trabajo valioso, lo que traerá problemas de rendimiento. Por lo tanto, el tiempo de espera de giro debe tener un cierto límite.Si el giro excede el número limitado de veces y aún no logra obtener el bloqueo, se debe usar el método tradicional para suspender el hilo. El valor predeterminado del número de giros es diez, y los usuarios también pueden usar el parámetro -XX:PreBlockSpin para cambiarlo ellos mismos.


Sin embargo, ya sea el valor predeterminado o el número de giros especificado por el usuario, es el mismo para todos los bloqueos en toda la máquina virtual Java. La optimización del bloqueo de giro en JDK6 introduce un giro adaptativo. Adaptativo significa que el tiempo de giro ya no es fijo, sino que está determinado por el tiempo de giro anterior en la misma cerradura y el estado del propietario de la cerradura.

  • Si la espera de giro acaba de adquirir con éxito el bloqueo en el mismo objeto de bloqueo, y el subproceso que mantiene el bloqueo se está ejecutando, entonces la máquina virtual pensará que es probable que este giro vuelva a tener éxito y luego permitirá que la espera de giro continúe Relativamente más tiempo tiempo, como una duración de 100 bucles ocupados.
  • 另一方面,如果对于某个锁,自旋很少成功获得过锁,那在以后要获取这个锁时将有可能直接省略掉自旋过程,以避免浪费处理器资源。

有了自适应自旋,随着程序运行时间的增长及性能监控信息的不断完善,虚拟机对程序锁的状况预测就会越来越精准,虚拟机就会变得越来越“聪明”了。

锁消除

锁消除是指虚拟机即时编译器在运行时,对一些代码要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除。锁消除的主要判定依据来源于逃逸分析的数据支持,如果判断到一段代码中,在堆上的所有数据都不会逃逸出去被其他线程访问到,那就可以把它们当作栈上数据对待,认为它们是线程私有的,同步加锁自然就无须再进行。


也许读者会有疑问,变量是否逃逸,对于虚拟机来说是需要使用复杂的过程间分析才能确定的,但是程序员自己应该是很清楚的,怎么会在明知道不存在数据争用的情况下还要求同步呢?这个问题的答案是:有许多同步措施并不是程序员自己加入的,同步的代码在 Java 程序中出现的频繁程度也许超过了大部分读者的想象。我们来看看如代码清单13-6所示的例子,这段非常简单的代码仅仅是输出三个字符串相加的结果,无论是源代码字面上, 还是程序语义上都没有进行同步。

// 代码清单13-6 一段看起来没有同步的代码
public String concatString(String s1, String s2, String s3) {
    return s1 + s2 + s3;
}

我们也知道,由于 String 是一个不可变的类,对字符串的连接操作总是通过生成新的 String 对象来进行的,因此 Javac 编译器会对 String 连接做自动优化。

  • 在 JDK5 之前,字符串加法会转化为 StringBuffer 对象的连续 append() 操作。即代码清单13-6所示的代码可能会变成代码清单13-7所示的样子。
  • 在 JDK5 及以后的版本中,会转化为 StringBuilder 对象的连续 append() 操作。
// 代码清单13-7 Javac转化后的字符串连接操作
public String concatString(String s1, String s2, String s3) {
    StringBuffer sb = new StringBuffer();
    sb.append(s1);
    sb.append(s2);
    sb.append(s3);
    return sb.toString();
}

现在大家还认为这段代码没有涉及同步吗?每个 StringBuffer.append() 方法中都有一个同步块,锁就是 sb 对象。虚拟机观察 sb 变量,经过逃逸分析后会发现它的动态作用域被限制在 concatString() 方法内部。也就是 sb 的所有引用都永远不会逃逸到 concatString() 方法之外,其他线程无法访问到它,所以这里虽然有锁,但是可以被安全地消除掉。在解释执行时这里仍然会加锁,但在经过服务端编译器的即时编译之后,这段代码就会忽略所有的同步措施而直接执行。

锁粗化

锁粗化指的是:如果虚拟机探测到有一串零碎的操作都对同一个对象加锁,那么虚拟机将会把加锁同步的范围扩展(粗化)到整个操作序列的外部。

原则上,我们在编写代码的时候,总是推荐将同步块的作用范围限制得尽量小:只在共享数据的实际作用域中才进行同步,这样是为了使得需要同步的操作数量尽可能变少,即使存在锁竞争,等待锁的线程也能尽可能快地拿到锁。

大多数情况下,上面的原则都是正确的,但是如果一系列的连续操作都对同一个对象反复加锁和解锁,甚至加锁操作是出现在循环体之中的,那即使没有线程竞争,频繁地进行互斥同步操作也会导致不必要的性能损耗。

代码清单13-7所示连续的 append() 方法就属于这类情况。如果虚拟机探测到有这样一串零碎的操作都对同一个对象加锁,将会把加锁同步的范围扩展(粗化)到整个操作序列的外部,以代码清单13-7为例,就是扩展到第一个 append() 操作之前直至最后一个 append() 操作之后,这样只需要加锁一次就可以了。

轻量级锁

轻量级锁的设计初衷是在没有多线程竞争的情况下,通过使用 CAS(Compare And Swap)操作来进行线程同步,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。

轻量级锁可以提高带有同步但无竞争的程序性能,但它是一个带有效益权衡(Trade Off) 性质的优化,也就是说它并非总是对程序运行有利。轻量级锁能提升程序同步性能的依据是 “对于绝大部分的锁,在整个同步周期内都是不存在竞争的” 这一经验法则。

  • 如果没有竞争,轻量级锁便通过 CAS 操作成功避免了使用互斥量的开销;
  • 但如果确实存在锁竞争,除了互斥量的本身开销外,还额外发生了 CAS 操作的开销。

因此在有竞争的情况下,轻量级锁反而会比传统的重量级锁更慢。

轻量级锁是 JDK6 时加入的新型锁机制,它名字中的 “轻量级” 是相对于使用操作系统互斥量来实现的传统锁而言的, 因此传统的锁机制就被称为“重量级”锁。不过,需要强调一点,轻量级锁并不是用来代替重量级锁的,轻量级锁设计的初衷是在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。

Mark Word

要理解轻量级锁,以及后面会讲到的偏向锁的原理和运作过程,必须要对 HotSpot 虚拟机对象的内存布局(尤其是对象头部分)有所了解。HotSpot 虚拟机的对象头(Object Header)分为两部分:

  • 第一部分用于存储对象自身的运行时数据,如哈希码(HashCode)、GC 分代年龄(Generational GC Age)等。这部分数据的长度在 32 位和 64 位的 Java 虚拟机中分别会占用 32 个或 64 个比特,官方称它为 “Mark Word”。这部分是实现轻量级锁和偏向锁的关键。
  • 另外一部分用于存储指向方法区对象类型数据的指针(Class Pointer、类型指针),虚拟机通过这个指针来确定这个对象是哪个类的实例。如果是数组对象,还会有一个额外的部分用于存储数组长度。

由于对象头信息是与对象自身定义的数据无关的额外存储成本,考虑到 Java 虚拟机的空间使用效率,Mark Word 被设计成一个非固定的动态数据结构,以便在极小的空间内存储尽量多的信息。它会根据对象的状态复用自己的存储空间。例如在 32 位的 HotSpot 虚拟机中:

  • 对象未被锁定的状态下,Mark Word 的 32 个比特空间里的 25 个比特将用于存储对象哈希码,4 个比特用于存储对象分代年龄,2 个比特用于存储锁标志位,还有 1 个比特固定为 0(这表示未进入偏向模式)。
  • 对象除了未被锁定的正常状态外,还有轻量级锁定、重量级锁定、GC 标记、可偏向等几种不同状态,这些状态下对象头的存储内容如下表所示。

image-20230506113339025.png

工作过程

我们简单回顾了对象的内存布局后,接下来就可以介绍轻量级锁的工作过程了:在代码即将进入同步块的时候,如果此同步对象没有被锁定(锁标志位为“01”状态),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间, 用于存储锁对象目前的 Mark Word 的拷贝(官方为这份拷贝加了一个 Displaced 前缀,即 Displaced Mark Word),这时候线程堆栈与对象头的状态如图13-3所示。

图13-3轻量级锁 CAS 操作之前堆栈与对象的状态

image-20230506113351530.png


然后, 虚拟机将使用 CAS 操作尝试把对象的 Mark Word 更新为指向锁记录(Lock Record)的指针。

  • 如果这个更新操作成功了,即代表该线程拥有了这个对象的锁,并且对象 Mark Word 的锁标志位(Mark Word 的最后两个比特)将转变为 “00”,表示此对象处于轻量级锁定状态。这时候线程堆栈与对象头的状态如图13-4所示。
  • 如果这个更新操作失败了,那就意味着至少存在一条线程与当前线程竞争获取该对象的锁。虚拟机首先会检查对象的 Mark Word 是否指向当前线程的栈帧,如果是,说明当前线程已经拥有了这个对象的锁,那直接进入同步块继续执行就可以了,否则(对象的 Mark Word 不是指向当前线程的栈帧)就说明这个锁对象已经被其他线程抢占了。如果出现两条以上的线程争用同一个锁的情况,那轻量级锁就不再有效,必须要膨胀为重量级锁,锁标志的状态值变为“10”,此时 Mark Word 中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也必须进入阻塞状态。

图13-4轻量级锁 CAS 操作之后堆栈与对象的状态

image-20230506113414917.png


上面描述的是轻量级锁的加锁过程,它的解锁过程也同样是通过 CAS 操作来进行的,如果对象的 Mark Word 仍然指向线程的锁记录,那就用 CAS 操作把对象当前的 Mark Word 和线程中复制的 Displaced Mark Word 替换回来。

  • 假如能够替换成功,那整个同步过程就顺利完成了;
  • 如果替换失败,则说明有其他线程尝试过获取该锁,就要在释放锁的同时,唤醒被挂起的线程。

轻量级锁能提升程序同步性能的依据是 “对于绝大部分的锁,在整个同步周期内都是不存在竞争的” 这一经验法则。

  • 如果没有竞争,轻量级锁便通过 CAS 操作成功避免了使用互斥量的开销;
  • 但如果确实存在锁竞争,除了互斥量的本身开销外,还额外发生了 CAS 操作的开销。

因此在有竞争的情况下,轻量级锁反而会比传统的重量级锁更慢。

偏向锁

偏向锁的目的是:消除数据在无竞争情况下的同步原语,进一步提高程序的运行性能。

偏向锁中的“偏”的意思是这个锁会偏向于第一个获得它的线程。如果虚拟机启用了偏向锁,那么当锁对象第一次被线程获取的时候,虚拟机将会把对象头中的标志位设置为 “01”、把偏向模式设置为 “1”,表示进入偏向模式。同时使用 CAS 操作把获取到这个锁的线程的 ID 记录在对象的 Mark Word 之中。如果 CAS 操作成功,持有偏向锁的线程以后每次进入这个锁相关的同步块时,虚拟机都可以不再进行任何同步操作(例如加锁、解锁及对 Mark Word 的更新操作等)。

偏向锁可以提高带有同步但无竞争的程序性能,但它同样是一个带有效益权衡(Trade Off) 性质的优化,也就是说它并非总是对程序运行有利。如果程序中大多数的锁都总是被多个不同的线程访问,那偏向模式就是多余的。

偏向锁也是 JDK6 中引入的一项锁优化措施,它的目的是消除数据在无竞争情况下的同步原语,进一步提高程序的运行性能。如果说轻量级锁是在无竞争的情况下使用 CAS 操作去消除同步使用的互斥量,那偏向锁就是在无竞争的情况下把整个同步都消除掉,连 CAS 操作都不去做了。

偏向锁中的“偏”,就是偏心的“偏”、偏袒的“偏”。偏向锁中的“偏”的意思是这个锁会偏向于第一个获得它的线程,如果在接下来的执行过程中,该锁一直没有被其他的线程获取,则持有偏向锁的线程将永远不需要再进行同步。


如果读者理解了前面轻量级锁中关于对象头 Mark Word 与线程之间的操作过程,那偏向锁的原理就会很容易理解。

假设当前虚拟机启用了偏向锁(启用参数 -XX:+UseBiased Locking,这是自 JDK6 起 HotSpot 虚拟机的默认值),那么当锁对象第一次被线程获取的时候,虚拟机将会把对象头中的标志位设置为 “01”、把偏向模式设置为 “1”,表示进入偏向模式。同时使用 CAS 操作把获取到这个锁的线程的 ID 记录在对象的 Mark Word 之中。如果 CAS 操作成功,持有偏向锁的线程以后每次进入这个锁相关的同步块时,虚拟机都可以不再进行任何同步操作(例如加锁、解锁及对 Mark Word 的更新操作等)。

一旦出现另外一个线程去尝试获取这个锁的情况,偏向模式就马上宣告结束。根据锁对象目前是否处于被锁定的状态决定是否撤销偏向(偏向模式设置为 “0”),撤销后标志位恢复到未锁定(标志位为 “01”)或轻量级锁定(标志位为 “00”)的状态,后续的同步操作就按照上面介绍的轻量级锁那样去执行。

偏向锁、轻量级锁的状态转换及对象 Mark Word 的关系如图13-5所示。

图13-5偏向锁、轻量级锁的状态转换及及对象 Mark Word 的关系

image-20230506113447788.png


细心的读者看到这里可能会发现一个问题:当对象进入偏向状态的时候,Mark Word 大部分的空间(23个比特) 都用于存储持有锁的线程 ID 了,这部分空间占用了原有存储对象哈希码的位置,那原来对象的哈希码怎么办呢?

在 Java 语言里面一个对象如果计算过哈希码,就应该一直保持该值不变(强烈推荐但不强制,因为用户可以重载hashCode() 方法按自己的意愿返回哈希码),否则很多依赖对象哈希码的 API 都可能存在出错风险。而作为绝大多数对象哈希码来源的 Object::hashCode() 方法,返回的是对象的一致性哈希码(Identity Hash Code),这个值是能强制保证不变的,它通过在对象头中存储计算结果来保证第一次计算之后,再次调用该方法取到的哈希码值永远不会再发生改变。 因此,当一个对象已经计算过一致性哈希码后,它就再也无法进入偏向锁状态了;而当一个对象当前正处于偏向锁状态, 又收到需要计算其一致性哈希码请求时,它的偏向状态会被立即撤销,并且锁会膨胀为重量级锁。在重量级锁的实现中, 对象头指向了重量级锁的位置,代表重量级锁的 ObjectMonitor 类里有字段可以记录非加锁状态(标志位为“01”)下的Mark Word,其中自然可以存储原来的哈希码。

注意, 这里说的计算请求应来自于对Object::hashCode()或者System::identityHashCode(Object)方法的调用, 如果重写了对象的hashCode()方法, 计算哈希码时并不会产生这里所说的请求。


偏向锁可以提高带有同步但无竞争的程序性能,但它同样是一个带有效益权衡(Trade Off) 性质的优化,也就是说它并非总是对程序运行有利。如果程序中大多数的锁都总是被多个不同的线程访问,那偏向模式就是多余的。在具体问题具体分析的前提下,有时候使用参数-XX:-UseBiasedLocking 来禁止偏向锁优化反而可以提升性能。

完整的过程

假设当前虚拟机启用了偏向锁,那么当锁对象第一次被线程获取的时候,虚拟机将会把对象头中的标志位设置为 “01”、把偏向模式设置为 “1”,表示进入偏向模式。同时使用 CAS 操作把获取到这个锁的线程的 ID 记录在对象的 Mark Word 之中。如果 CAS 操作成功,持有偏向锁的线程以后每次进入这个锁相关的同步块时,虚拟机都可以不再进行任何同步操作(例如加锁、解锁及对 Mark Word 的更新操作等)。

如果锁对象目前处于偏向模式,那么一旦出现另外一个线程去尝试获取这个锁的情况,偏向模式就马上宣告结束。根据锁对象目前是否处于被锁定的状态决定撤销偏向后,锁对象处于什么状态。

  • 如果锁对象目前处于被锁定的状态,那么一旦出现另外一个线程去尝试获取这个锁的情况,偏向模式就马上宣告结束,锁对象转换到轻量级锁定状态,后续的同步操作就按照轻量级锁那样去执行。
  • 如果锁对象目前处于未被锁定的状态,那么一旦出现另外一个线程去尝试获取这个锁的情况,偏向模式就马上宣告结束,锁对象转换到未被锁定、不可偏向状态。

对象转换到轻量级锁定状态。虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的 Mark Word 的拷贝。然后,虚拟机将使用 CAS 操作尝试把对象的 Mark Word 更新为指向锁记录(Lock Record)的指针。

  • 如果这个更新操作成功了,即代表该线程拥有了这个对象的锁,并且对象 Mark Word 的锁标志位将转变为 “00”,表示此对象处于轻量级锁定状态。
  • 如果这个更新操作失败了,那就意味着至少存在一条线程与当前线程竞争获取该对象的锁。虚拟机首先会检查对象的 Mark Word 是否指向当前线程的栈帧:
    • 如果是(对象的 Mark Word 指向当前线程的栈帧),说明当前线程已经拥有了这个对象的锁,那直接进入同步块继续执行就可以了;
    • 否则(对象的 Mark Word 不是指向当前线程的栈帧)就说明这个锁对象已经被其他线程抢占了,那么当前线程 B 执行一个忙循环(自旋),不放弃处理器的执行时间,看看持有锁的线程 A 是否会很快就释放锁。
      • 如果持有锁的线程 A 很快就释放了锁,那么当前线程 B 成功获取锁。
      • 如果线程 B 自旋超过了限定的次数仍然没有成功获得锁,那轻量级锁就不再有效,必须要膨胀为重量级锁,锁标志的状态值变为“10”,此时 Mark Word 中存储的就是指向重量级锁(互斥量)的指针。当前线程继续等待锁,并进入阻塞状态。持有锁的线程 A 释放锁的同时,唤醒被挂起的线程。被唤醒的线程就会进行新一轮的竞争,尝试获取这个锁。

574de66511ca577d030e9fe52a818df8.png

参考资料

第13章 线程安全与锁优化 13.3 锁优化

Supongo que te gusta

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