synchronized何如保证多线程的运行安全
synchronized关键字是一个用于同步访问共享资源的机制,它可以确保并发编程中的三个关键要素:原子性、可见性和有序性。下面将分别解释这三个要素以及synchronized是如何保证它们的。
1. 原子性(Atomicity)
原子性是指一个操作或者多个操作要么全部执行完成,要么全部不执行,不会出现部分执行的情况。在Java中,synchronized通过在同一时间只允许一个线程访问被锁定的代码块或方法来保证原子性。当一个线程进入synchronized代码块或方法时,其他试图访问该资源的线程将被阻塞,直到第一个线程释放锁。
2. 可见性(Visibility)
可见性是指一个线程对共享变量的修改对其他线程是可见的。在Java中,synchronized通过内存屏障(Memory Barrier)来保证可见性。当一个线程释放synchronized锁时,它会将本地缓存中的变量值刷新到主内存中,当其他线程获取synchronized锁时,它会从主内存中读取最新的变量值,从而保证了可见性。
3. 有序性(Ordering)
有序性是指程序执行的顺序按照代码的先后顺序执行。在并发编程中,由于编译器优化和处理器乱序执行等因素,程序的实际执行顺序可能与代码的编写顺序不一致。synchronized通过禁止指令重排来保证有序性。当一个线程进入synchronized代码块或方法时,它会看到一个一致的程序执行顺序,即按照代码编写的顺序执行。
需要注意的是,虽然synchronized可以保证原子性、可见性和有序性,但它并不是解决所有并发问题的银弹。过度使用synchronized可能导致性能问题,如锁竞争和死锁。因此,在使用synchronized时,需要仔细考虑并发访问的模式和锁的范围,以确保在保证正确性的同时获得良好的性能。
一、synchronized原理
synchronized是Java中的内置锁,通过对象头中的锁状态标志位和锁记录(Lock Record)来实现。每个Java对象都有一个对象头,其中包含了锁状态标志位、指向锁记录的指针等信息。
Java 对象在内存中布局分为三部分:对象头、实例数据和对齐填充。对象头中包含了关于锁的信息。具体来说,对象头中有一个 Mark Word,用于存储对象的哈希码、分代年龄、锁状态等信息。
当一个线程尝试获取某个对象的synchronized锁时,如果该对象的锁状态为无锁状态,则JVM会在当前线程的栈帧中创建一个锁记录(Lock Record),并将其与对象的对象头关联起来。此时,该线程就成功获取了对象的锁,可以执行同步代码块。
如果其他线程也尝试获取该对象的锁,则会被阻塞,直到持有锁的线程释放锁。当持有锁的线程执行完同步代码块后,会将锁状态设置为无锁状态,并唤醒等待队列中的一个线程,使其获取锁并执行同步代码块。
二、锁升级过程
在Java 6之前,synchronized的实现是基于重量级锁的,即当线程尝试获取锁失败时,会被阻塞并导致用户态和内核态的切换,性能开销较大。从Java 6开始,JVM对synchronized的实现进行了优化,引入了锁升级的过程,包括无锁状态、偏向锁、轻量级锁和重量级锁四种状态。
java中的锁升级是一个为了提高并发性能而设计的过程。在Java 6及之后的版本中,synchronized关键字的实现不再是单一的重量级锁,而是引入了偏向锁、轻量级锁和重量级锁等多种锁状态,这些状态可以根据竞争情况动态转换。
无锁状态:
对象刚开始时处于无锁状态,也就是没有任何线程持有该对象的锁。
偏向锁:
为了减少无竞争情况下的锁开销,JVM引入了偏向锁。当一个线程首次访问同步代码块时,它会在对象头和当前线程的栈帧中记录偏向的线程ID。这样,在后续的执行中,如果仍然是同一个线程访问该同步代码块,JVM就可以判断出来,并允许该线程无锁地执行同步代码。偏向锁实际上是一种延迟加锁的机制,它的目标是消除无竞争情况下的同步原语,进一步提高程序的运行性能。
但是,当有其他线程尝试获取这个偏向锁时,偏向锁就会撤销,并尝试升级为轻量级锁。
轻量级锁:
轻量级锁是为了减少线程阻塞而设计的。当偏向锁撤销后,或者多个线程交替执行同步代码块时,锁会升级为轻量级锁。轻量级锁的加锁过程是通过CAS操作实现的,它试图将对象头的Mark Word替换为指向线程栈帧中锁记录的指针。如果成功,则当前线程获得锁;如果失败,说明存在竞争,此时会尝试自旋等待,即让当前线程空转一段时间,然后再次尝试获取锁。
如果自旋等待达到一定的次数仍然没有获取到锁,那么轻量级锁就会升级为重量级锁。
重量级锁:
重量级锁是Java中最基础的锁机制,它的实现依赖于操作系统的互斥量(Mutex)。当轻量级锁无法满足性能需求时,会升级为重量级锁。此时,未获取到锁的线程会被阻塞,并进入等待状态,直到持有锁的线程释放锁。由于重量级锁涉及到用户态和内核态的切换,因此它的性能开销相对较大。
重量级锁的实现依赖于底层的 Monitor 机制。每个对象都有一个与之关联的 Monitor,当线程尝试获取重量级锁时,会被放入 Monitor 的入口等待队列中。如果获取锁失败,线程会被阻塞并放入等待队列,直到持有锁的线程释放锁。
锁升级的过程是动态的,JVM会根据当前的竞争情况选择合适的锁策略。在无竞争或低竞争的情况下,偏向锁和轻量级锁能够显著提高程序的并发性能;而在高竞争的情况下,重量级锁则提供了可靠的线程同步机制。这种设计使得Java的synchronized关键字在不同的场景下都能表现出良好的性能。
三、优缺点
优点:
简单易用:synchronized是Java语言内置的同步机制,使用起来非常简单。
自动释放锁:当同步代码块执行完成后,JVM会自动释放锁,无需手动操作。
可重入锁:一个线程可以多次获取同一个锁,而不会导致死锁。
缺点:
无法中断等待:当一个线程在等待获取锁时,无法被中断,只能等待持有锁的线程释放锁。
无法设置等待超时:无法为等待获取锁的操作设置超时时间。
无法实现公平锁:synchronized锁是非公平的,可能会导致某些线程长时间无法获取锁。
四、synchronized升级优化的历史演进过程
版本的升级,synchronized也经历了一系列的优化和改进,以适应现代多核处理器架构和提高并发性能。以下是synchronized在同步版本的升级优化过程中的主要历史演进:
重量级锁:
在JDK 1.0和1.2版本中,synchronized的实现是基于重量级锁的。重量级锁依赖于底层操作系统的互斥量(Mutex)来实现线程同步。当一个线程尝试获取锁时,如果锁已经被其他线程持有,则该线程会被阻塞,并进入等待状态。这种实现方式在锁竞争激烈的场景下会导致线程频繁挂起和唤醒,从而带来较大的性能开销。
引入偏向锁和轻量级锁:
为了优化synchronized的性能,JDK 1.6引入了偏向锁和轻量级锁。偏向锁是一种优化策略,它假设在多数情况下,锁被同一个线程持有的时间相对较长,并且锁竞争不激烈。当一个线程首次访问同步代码块时,会在对象头和当前线程的栈帧中记录偏向的线程ID,这样后续该线程访问时无需再进行锁操作。如果发生锁竞争,偏向锁会升级为轻量级锁。
轻量级锁通过自旋等待(CAS操作)来尝试获取锁,避免线程立即挂起。如果自旋等待达到一定次数仍无法获取锁,则会升级为重量级锁。这种优化策略在锁竞争不激烈的场景下可以显著提高性能。
锁消除和锁粗化:
除了偏向锁和轻量级锁之外,JDK还引入了其他优化策略来进一步提高synchronized的性能。锁消除是一种编译器优化技术,通过逃逸分析来判断对象是否只会被一个线程访问。如果是这样的话,就可以消除对该对象的锁操作,从而避免不必要的同步开销。
锁粗化是与锁消除相反的优化策略。当一系列连续的锁操作都在同一个同步块中进行时,可以将这些锁操作合并成一个更大的同步块,以减少锁的获取和释放次数,从而提高性能。
适应性自旋和锁升级:
在后续的Java版本中,对synchronized的优化还包括适应性自旋和锁升级。适应性自旋是一种动态调整自旋等待时间的策略,根据之前的自旋成功率和锁持有者的状态来动态决定自旋的次数。这样可以在一定程度上减少无用的自旋等待,提高性能。
需要注意的是,虽然synchronized在Java版本中经历了一系列的优化和改进,但它仍然是一个重量级的同步机制。在高性能要求的场景下,可能需要考虑使用其他更轻量级的同步原语,如ReentrantLock、StampedLock等。这些同步原语提供了更细粒度的锁控制,能够更好地适应不同的并发场景。
再来看几个问题:
为什么JDK18之后,偏向锁更被标记为废弃?
在 JDK 18 中,偏向锁被标记为废弃的原因主要是基于实际的使用情况和性能分析。偏向锁的设计初衷是在无竞争或低竞争的情况下提高性能,通过减少不必要的锁操作来降低开销。然而,在实际应用中,JVM 开发者发现偏向锁并不总是能够提供预期的性能提升,有时甚至会成为性能瓶颈。
以下是一些导致偏向锁被废弃的关键因素:
复杂性:偏向锁的实现相对复杂,需要维护额外的锁状态和线程信息。这种复杂性不仅增加了开发和维护的成本,还可能引入潜在的错误和性能问题。
适用性有限:偏向锁主要适用于长时间持有锁且竞争不激烈的场景。然而,在实际应用中,很多锁的使用模式并不符合这个假设。如果锁被频繁地获取和释放,或者存在高度的竞争,偏向锁的优势就会大打折扣。
性能开销:尽管偏向锁的设计初衷是为了提高性能,但在某些情况下,它可能会导致额外的性能开销。例如,当偏向锁被撤销并升级为轻量级锁或重量级锁时,需要进行额外的锁状态转换和线程调度操作,这些操作可能会消耗大量的CPU资源。
其他同步原语的改进:随着Java并发包的不断演进,出现了更多更高效的同步原语,如 ReentrantLock、StampedLock 等。这些同步原语提供了更细粒度的锁控制,能够更好地适应不同的并发场景,因此在某些情况下可能更优于偏向锁。
基于以上考虑,JVM 开发者决定在 JDK 18中废弃偏向锁,以简化锁的实现和降低维护成本。同时,他们鼓励开发者根据具体的应用场景选择更合适的同步原语来实现并发控制。需要注意的是,即使偏向锁被废弃,它在早期版本的JDK中仍然可用,但建议在新版本中使用其他推荐的同步机制。
synchronized 的实现涉及哪些底层操作系统的支持?
synchronized 的实现涉及到了 Java 虚拟机(JVM)层面的锁机制,以及底层操作系统对线程和进程同步的支持,从操作系统角度来看涉及以下内容:
线程调度:
当线程因为竞争锁而被阻塞时,操作系统会负责线程的调度。操作系统会根据不同的调度算法(如时间片轮转、优先级调度等)来决定哪个线程应该获得 CPU 时间片并执行。
互斥量(Mutex):
重量级锁的实现通常依赖于操作系统的互斥量机制。互斥量是一种同步原语,用于保护临界区资源,确保同一时间只有一个线程可以访问临界区。
信号量(Semaphore):
有时,synchronized 的实现还可能涉及到操作系统的信号量机制。信号量是一种用于控制多个线程对共享资源访问的同步原语,它可以维护一个计数器,表示可用资源的数量。
自旋锁(Spinlock):
轻量级锁在自旋等待时,可能会使用到自旋锁。自旋锁是一种特殊的锁,当线程无法获取锁时,它会持续检查锁的状态,而不是立即被阻塞。这种策略在锁被持有时间较短,且线程切换开销较大的场景下可能更有效。
内存屏障(Memory Barrier):
为了确保线程安全,JVM 在实现 synchronized 时还需要考虑内存模型。Java 内存模型(JMM)定义了一组规则,用于确保线程间的正确同步。为了实现这些规则,JVM 可能会插入内存屏障指令,以确保指令的顺序性和可见性。
总结来说,synchronized 的实现涉及到了 Java虚拟机层面的多种锁状态和对象头信息,以及底层操作系统对线程调度和同步原语的支持。这些机制共同作用,确保了多线程环境下对共享资源的安全访问。