Java 并发编程的几种常见的锁

·4045·9 分钟
AI摘要: Java的线程安全主要体现在三个方面:原子性、可见性和有序性。数据的线程安全可以通过三种方式来保证:事务管理、锁机制和版本控制。

自并发编程诞生以来,线程安全一直是一个棘手的问题。Java提供了多种锁机制来平衡安全性和性能。

Java的线程安全主要体现在三个方面:

  • 原子性:同一时刻只能有一个线程处理共享资源

  • 可见性:一个线程对共享资源的修改能及时被其他线程看到

  • 有序性:指令执行的顺序符合程序的逻辑顺序

数据的线程安全可以通过三种方式来保证:

  • 事务管理:确保一系列操作要么全部成功,要么全部失败

  • 锁机制:确保同一时刻只有一个线程能修改共享资源

  • 版本控制:通过乐观锁,在更新数据时记录版本号,用于并发冲突检测

image-20241114143549763

synchronized

这是Java中最基本的锁,有四个级别:无锁、偏向锁、轻量级锁和重量级锁。

  • 无锁:在单线程情况下,即使使用了synchronized关键字,JVM也会自动优化,不触发加锁和解锁

  • 偏向锁:针对多线程环境中锁不存在竞争的情况。当一个线程获得锁后,会记录该线程ID。该线程再次访问synchronized代码块时,不会触发锁操作,相当于无锁

  • 轻量级锁:当多个线程开始竞争同一资源时,偏向锁升级为轻量级锁。这是一种自旋锁,线程获取不到锁时会持续尝试,而不是阻塞。适用于竞争不激烈、等待时间短的情况

  • 重量级锁:如果竞争继续加剧,等待时间变长,持续自旋会浪费CPU资源,此时升级为重量级锁。获取不到锁的线程会被阻塞

ReentrantLock

Synchronized是Java中最基本的锁,而JUC包提供了更多功能丰富的锁和接口,特别是在locks子包中。其中一个重要的锁是ReentrantLock

ReentrantLock是可重入锁,也称为递归锁

ReadWriteLock

读写锁适用于读操作远多于写操作的场景。它允许多个读线程同时访问共享资源,但只允许一个写线程进行写操作。

乐观锁和悲观锁

悲观锁假定数据很可能会被其他线程修改,因此在访问共享资源前就加锁。synchronizedReentrantLock都是悲观锁的实现。

乐观锁假定数据不太可能被其他线程修改,因此不会锁定资源。它通过比较-替换这一原子操作来修改数据。具体来说,更新数据时会检查数据版本是否一致。如果版本相同,说明数据未被其他线程修改,当前线程可以成功更新。如果版本不同,说明数据已被修改,当前线程的修改失败,需要重新尝试

乐观锁通常使用CAS操作、版本号或时间戳来实现

  • CAS操作:比较预期值,如果相同则更新

  • 版本号机制:类似CAS,但使用版本号作为预期值

  • 时间戳:类似CAS,但使用时间戳作为预期值

CAS操作的源码示例:


 public class AtomicInteger extends Number implements java.io.Serializable {

     //存储整数值,volatile保证可视性

     private volatile int value;

     //Unsafe用于实现对底层资源的访问

     private static final Unsafe unsafe = Unsafe.getUnsafe();



     //valueOffset是value在内存中的偏移量

     private static final long valueOffset;

     //通过Unsafe获得valueOffset

     static {

         try {

             valueOffset = unsafe.objectFieldOffset(AtomicInteger.class.getDeclaredField("value"));

         } catch (Exception ex) { throw new Error(ex); }

     }



     public final boolean compareAndSet(int expect, int update) {

         return unsafe.compareAndSwapInt(this, valueOffset, expect, update);

     }



     public final int getAndIncrement() {

         for (;;) {

             int current = get();

             int next = current + 1;

             // 这里是关键,Compare成功则返回,否则在for循环中重试

             if (compareAndSet(current, next))

                 return current;

         }

     }

 }

乐观锁的一个主要问题是,如果更新失败,可能会导致长时间的自旋重试。因此,使用时需要谨慎评估共享资源的并发竞争程度。

乐观锁的常见实现包括:StampedLock的乐观读锁数据库中的CAS机制

自旋锁

线程在等待锁资源时会持续检查锁是否可用,而不是进入阻塞状态。

在许多场景中,同步资源的锁定时间很短。为这短暂的时间切换线程状态可能得不偿失,因为线程挂起和恢复的开销可能更大。自旋锁避免了线程上下文切换的开销,适合等待时间短的场景。但过度自旋会浪费CPU资源。

  • 自旋锁和乐观锁

    自旋锁和乐观锁都使用CAS机制,但各有侧重。乐观锁不锁定同步资源,只在更新时使用CAS。自旋锁则是要锁定资源,但锁定失败时不阻塞,而是使用CAS不断尝试。

原子类

线程局部变量

ThreadLocal是Java中解决线程安全的另一种方法,适用于每个线程需要自己的数据副本且这些数据不需要跨线程共享的场景。也就是说,多个线程不需要修改和共享同一份数据。


[ 主线程(Thread A) ]           [ 线程B (Thread B) ]



       |                                 |

       |                                 |

[ ThreadLocalMap ]                  [ ThreadLocalMap ]

       |                                 |

       |                                 |

+-----------------------+         +-----------------------+

| key: ThreadLocal1     |         | key: ThreadLocal1     |

| value: 5              |         | value: 10             |

+-----------------------+         +-----------------------+

| key: ThreadLocal2     |         | key: ThreadLocal2     |

| value: "Hello"        |         | value: "World"        |

+-----------------------+         +-----------------------+

从上图可以看出,每个Thread内部都包含一个自己的ThreadLocalMap。当我们需要使用共享资源的副本时,比如ThreadLocal1,就会在该线程的ThreadLocalMap中设置对应的key-value对。

简而言之,ThreadLocalMap负责存储ThreadLocal变量及其值的映射关系。

公平锁和非公平锁

这两种锁主要区别在于多个线程竞争锁时是否需要排队。

  • 公平锁:多个线程按请求顺序获取锁,如在队列中排队,确保先请求的线程先获得锁。常见的公平锁有:ReentrantLock(可以配置为公平锁)

  • 非公平锁:锁的获取不按顺序,如果锁空闲,线程可以直接获取,不管是否有其他线程在等待。这种方式通常可以提高性能,但可能导致某些线程长期获取不到锁而"饥饿"。常见的非公平锁有Synchronized

  • 为什么非公平锁的性能更好

    在公平锁中,如果线程获取锁失败,会进入阻塞状态,即从运行态切换到休眠态。被唤醒时又会从休眠态转为运行态。每次状态转换都涉及内核态和用户态的切换。

    而在非公平锁中,线程使用CAS尝试获取锁,如果成功就直接运行,避免了进入阻塞状态的过程,减少了内核态和用户态的切换次数。

共享锁和排它锁

这两种锁主要区别在于是否允许多个线程同时持有锁。

前面讨论的SynchronizedReentrantLock都是排它锁,同一时刻只允许一个线程访问。而读写锁中的读锁是共享锁,允许多个读线程同时持有锁。

Kaggle学习赛初探