Bootstrap

多线程篇-9--锁的使用及分类(可重入锁,读写锁,锁降级,死锁,LockSupport,乐观锁,悲观锁,分段锁等)

1、锁的概述

Java 中,锁是一种用于控制多线程并发访问共享资源的机制。合理的锁机制可以确保线程安全,避免数据竞争和不一致的问题。 Java 提供了多种锁机制,包括内置锁(即 synchronized 关键字)、显式锁(如 ReentrantLock)、读写锁(如 ReentrantReadWriteLock)等。

2、Lock锁和synchronized的区别

1、synchronized是一个关键字,可以直接应用于方法或代码块。Lock 是一个接口,提供了比 synchronized更丰富的锁操作。

2、synchronized当同步代码块或方法执行完毕或抛出异常时,锁会自动释放。Lock需要手动获取和释放锁,通常在 try-finally 块中使用,确保锁在任何情况下都能被释放。

3、synchronized锁是非公平的,即等待时间最长的线程不一定最先获得锁。ReentrantLock可以选择是否使用公平锁。公平锁确保等待时间最长的线程最先获得锁。
Lock lock = new ReentrantLock(true); // 公平锁

4、synchronized锁的粒度是对象级别的,即一个对象的多个同步方法之间会相互阻塞。Lock可以更细粒度地控制锁,允许多个锁实例,从而减少不必要的阻塞。

5、条件变量不一样,synchronized内使用Object类的wait和notify方法;Lock提供了Condition接口,通过await和signal方法实现线程等待唤醒机制。

Lock lock = new ReentrantLock();
     Condition condition = lock.newCondition();

     try {
         lock.lock();
         // 等待条件
         condition.await();
         // 通知条件
         condition.signal();
     } catch (InterruptedException e) {
         // 处理中断异常
     } finally {
         lock.unlock();
     }

6、synchronized而言,获取锁的线程和等待获取锁的线程都是不可中断的;Lock可以通过灵活的机制控制是否可被中断。

Lock可中断获取锁示例:
如下的代码中,通过lock.lockInterruptibly()可中断的获取锁,那么被中断时会直接中断抛出异常;如果是lock.lock()获取锁,那么就和synchronized一样,任然会继续执行。

import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class LockExample {

    private final Lock lock = new ReentrantLock();

    public void method() throws InterruptedException {
        try {
            System.out.println("Thread " + Thread.currentThread().getName() + " is trying to acquire the lock...");
            lock.lockInterruptibly(); // 可中断地获取锁,被中断时直接抛出中断异常
            System.out.println("Thread " + Thread.currentThread().getName() + " got the lock.");
            Thread.sleep(10000); // 模拟长时间操作
        } catch (InterruptedException e) {
            System.out.println("Thread " + Thread.currentThread().getName() + " was interrupted.");
            throw e;
        } finally {
            lock.unlock();
        }
    }

    public static void main(String[] args) {
        LockExample example = new LockExample();

        Thread t1 = new Thread(() -> {
            try {
                example.method();
            } catch (InterruptedException e) {
                System.out.println("Thread " + Thread.currentThread().getName() + " was interrupted.");
            }
        });

        Thread t2 = new Thread(() -> {
            try {
                example.method();
            } catch (InterruptedException e) {
                System.out.println("Thread " + Thread.currentThread().getName() + " was interrupted.");
            }
        });

        t1.start();
        t2.start();

        // 让主线程等待一段时间,确保t1已经进入同步代码块
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        // 中断t2线程
        t2.interrupt();
    }
}

3、常用锁

ReentrantLock 和 ReentrantReadWriteLock 是 Java 中提供的两种显式锁机制,它们提供了比 synchronized 更灵活的锁控制。

(1)、ReentrantLock可重入锁

ReentrantLock是一个可重入的互斥锁,允许多个线程在不同的时间点上持有同一个锁,但同一时间只有一个线程可以持有锁。
它提供了比 synchronized 更多的功能,例如公平锁、非公平锁、尝试加锁等。

1、主要特性

可重入:一个线程可以多次获取同一个锁,而不会被自己阻塞。(注意:可重入即多次获取同一个锁上锁,获取多少次就要解锁多少次,不然锁就无法释放。)
公平锁和非公平锁:可以选择是否按照请求锁的顺序来分配锁。(构造时指定)
尝试加锁:可以尝试获取锁,如果不能立即获取到锁,可以选择不阻塞而是返回一个布尔值。(如果返回ture标识抢到了锁)
锁中断:可以中断正在等待锁的线程。(lockInterruptibly上锁方式)

2、主要方法
  • lock():获取锁。
  • unlock():释放锁。
  • tryLock():尝试获取锁,如果立即可用则返回 true(已经抢到了锁,无需在用lock方法),否则返回 false
  • tryLock(long time, TimeUnit unit):尝试在指定时间内获取锁,如果超时则返回 false
  • lockInterruptibly():尝试获取锁,如果不能立即获取到锁并且当前线程被中断,则抛出 InterruptedException
  • newCondition():创建一个与锁绑定的条件对象。

注意:
(1)、lock() 方法:会阻塞当前线程,一直抢锁,直到锁可用为止。
(2)、tryLock() 方法:非阻塞尝试,如果锁当前可用,则立即返回 true;如果锁不可用,则立即返回 false,不会阻塞当前线程。
(3)、tryLock(long time, TimeUnit unit) 方法:带尝试在指定的时间内获取锁,如果在指定时间内锁可用,则返回 true;如果超时仍未获取到锁,则返回 false
(4)、lockInterruptibly()方法:可中断式的抢锁,这种方式抢到锁,被其他线程中断后会抛出中断异常。上面其他方式的抢锁,被中断时不会抛出异常。

3、普通使用锁代码示例
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class ReentrantLockExample {
    private final Lock lock = new ReentrantLock();

    public void method() {
        lock.lock();    // 上锁
        try {
            // 只有一个线程可以进入这个代码块
            System.out.println("Method is running by " + Thread.currentThread().getName());
        } finally {
            lock.unlock();   // 解锁,建议放到finally中,一定要解锁。
        }
    }

    public static void main(String[] args) {
        ReentrantLockExample example = new ReentrantLockExample();

        Thread t1 = new Thread(() -> example.method(), "Thread 1");
        Thread t2 = new Thread(() -> example.method(), "Thread 2");

        t1.start();
        t2.start();
    }
}
4、可重入特性代码示例

利用可重入的特性处理业务

import java.util.concurrent.locks.ReentrantLock;

public class LockOptimizationExample {
    private final ReentrantLock lock = new ReentrantLock();
    private int count = 0;

    public void increment() {
        lock.lock();
        try {
            count++;
        } finally {
            lock.unlock();
        }
    }

    public static void main(String[] args) {
        LockOptimizationExample example = new LockOptimizationExample();

        Thread thread1 = new Thread(() -> {
            for (int i = 0; i < 1000; i++) {
                example.increment();  //  thread1线程会执行1000次increment方法,即会获取1000次锁资源,同时完全释放也需要解锁1000次
            }
        });

        Thread thread2 = new Thread(() -> {
            for (int i = 0; i < 1000; i++) {
                example.increment();
            }
        });

        thread1.start();
        thread2.start();

        try {
            thread1.join();
            thread2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        System.out.println("Final count: " + example.count);
    }
}

(2)、ReentrantReadWriteLock读写锁

ReentrantReadWriteLock是读写锁,允许多个读线程同时访问资源,但写线程独占资源。这种锁机制适用于读多写少的场景,可以显著提高并发性能。
定义一个ReentrantReadWriteLock读写锁对象,实际上可以使用两把锁,分别是这个对象的读锁和写锁。

1、主要特性

读锁:允许多个读线程同时访问资源。
写锁:只允许一个写线程访问资源,写锁优先于读锁。
可重入:读锁和写锁都是可重入的,一个线程可以多次获取同一个锁。
公平锁和非公平锁:可以选择是否按照请求锁的顺序来分配锁。

理解一下:
读写锁中有有读锁和写锁两种锁,都是可重入的,但是两种锁是不允许同时存在的。读锁允许多个线程同时占用,但是写锁只能允许一个线程占有。
简单说,读锁存在时,可以存在多个线程共有,新的线程想抢占写锁,必须所有的读锁线程都释放读锁后,新的写锁的线程才能抢到锁开始工作。反之一样,写锁(只会有一个线程)存在时,新的读锁线程是无法获取到读锁的,只有当写锁的线程释放锁后,读锁的线程才能开始抢到读锁,读取数据。
但是在同一个线程中,可以先获取到写锁,在获取读锁,可以同时拥有两把锁。通常都是锁降级情况下才会这么使用。

2、主要方法
  • readLock():获取读锁。
  • writeLock():获取写锁。
  • newCondition():创建一个与锁绑定的条件对象。

示例代码

import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;

public class ReadWriteLockExample {
    private final ReadWriteLock lock = new ReentrantReadWriteLock();   // 定义一个读写锁对象
    private final int[] data = new int[100];

    public void readData() {
        lock.readLock().lock();   // 读锁上锁,允许其他的读线程持有,但会阻塞写线程,直到所有的读锁都释放后,写锁可被持有使用
        try {
            for (int i = 0; i < data.length; i++) {
                System.out.println("Reading data[" + i + "] = " + data[i]);
            }
        } finally {
            lock.readLock().unlock();  // 释放读锁
        }
    }

    public void writeData(int index, int value) {
        lock.writeLock().lock();   // 写锁,仅允许一个线程持有,会阻塞读线程持有。写锁释放后,读线程才能抢占读锁进行数据读取。
        try {
            data[index] = value;
            System.out.println("Writing data[" + index + "] = " + value);
        } finally {
            lock.writeLock().unlock();    // 释放写锁
        }
    }

    public static void main(String[] args) {
        ReadWriteLockExample example = new ReadWriteLockExample();

        Thread reader1 = new Thread(() -> example.readData(), "Reader 1");  // reader1和reader2都能抢到锁都能执行
        Thread reader2 = new Thread(() -> example.readData(), "Reader 2");
        Thread writer = new Thread(() -> example.writeData(0, 10), "Writer");  // writer需要等待两个读线程都释放读锁后才能抢到写锁,开始执行

        reader1.start();
        reader2.start();
        writer.start();
    }
}
3、锁降级(写锁降级为读锁)

锁降级,实际上是在读写锁使用中的一个概念。把写锁降级为读锁的过程。关键在于利用 ReentrantReadWriteLock 的可重入特性。可以在保持线程安全的前提下,将写锁降级为读锁,确保在多线程环境下的数据一致性。

具体步骤如下:
1、获取写锁:线程首先获取写锁,确保在写操作期间没有其他线程可以读取或写入数据。
2、获取读锁:在保持写锁的情况下,在获取读锁。由于同一个线程可以多次获取同一个锁,这里不会出现问题。
3、释放写锁:在保持读锁的情况下,先释放写锁。此时,写锁被释放,但读锁仍然被持有,允许其他读线程访问数据。
4、继续读操作:在释放写锁后,当前线程可以继续执行读操作,确保数据的一致性和完整性。
5、释放读锁:最后释放读锁,完成整个过程。

锁降级代码示例:

import java.util.concurrent.locks.ReentrantReadWriteLock;

public class WriteToReadLockDowngradeExample {
    private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock();  // 定义读写锁
    private final int[] data = new int[100];

    public void writeAndDowngradeToRead(int index, int value) {
        ReentrantReadWriteLock.WriteLock writeLock = lock.writeLock();   // 写锁,控制写线程执行
        ReentrantReadWriteLock.ReadLock readLock = lock.readLock();   // 读锁,控制读线程执行

        // 获取写锁
        writeLock.lock();            // 先获取写锁
        try {
            // 修改数据
            data[index] = value;
            System.out.println("Writing data[" + index + "] = " + value);

            // 在保持写锁的情况下获取读锁
            readLock.lock();              // 在获取读锁
        } finally {
            // 释放写锁,但保持读锁
            writeLock.unlock();          // 持有读锁时,释放写锁,实现锁降级
        }

        try {
            // 继续执行读操作
            System.out.println("Reading data[" + index + "] = " + data[index]);
        } finally {
            // 最后释放读锁
            readLock.unlock();      // 最后释放读锁
        }
    }

    public void readData(int index) {
        ReentrantReadWriteLock.ReadLock readLock = lock.readLock();

        readLock.lock();
        try {
            System.out.println("Reading data[" + index + "] = " + data[index]);
        } finally {
            readLock.unlock();
        }
    }

    public static void main(String[] args) {
        WriteToReadLockDowngradeExample example = new WriteToReadLockDowngradeExample();

        Thread writer = new Thread(() -> example.writeAndDowngradeToRead(0, 10), "Writer");
        Thread reader1 = new Thread(() -> example.readData(0), "Reader 1");
        Thread reader2 = new Thread(() -> example.readData(0), "Reader 2");

        writer.start();
        reader1.start();
        reader2.start();
    }
}

(3)可重入锁和读写锁对比

在这里插入图片描述

4、条件变量Condition(监视器)

Condition 是 java.util.concurrent.locks 包中的接口,用于在锁的基础上实现更复杂的线程同步机制。
(如实现synchronsized中使用wait和notify方法实现等待/通知机制,Condition是Lock接口提供的接口,也可以实现等待/同步机制)

1、主要方法

  • await():使当前线程等待,释放锁。
  • signal():唤醒一个等待的线程。
  • signalAll():唤醒所有等待的线程。

2、示例代码

import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class ConditionExample {
    private final Lock lock = new ReentrantLock();
    private final Condition condition = lock.newCondition();
    private boolean ready = false;

    public void prepare() {
        lock.lock();
        try {
            ready = true;
            condition.signalAll();
        } finally {
            lock.unlock();
        }
    }

    public void consume() {
        lock.lock();
        try {
            while (!ready) {
                condition.await();
            }
            System.out.println("Resource is ready, consuming...");
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        } finally {
            lock.unlock();
        }
    }
}

5、LockSupport工具

(1)、概述

LockSupport 是 Java 并发工具类中的一个重要工具,位于 java.util.concurrent.locks 包中。
它提供了一系列静态方法,用于精确地控制线程的挂起和唤醒操作。LockSupport 是许多高级并发工具(如 ReentrantLock、Semaphore、CountDownLatch 等)的基础。

(2)、主要方法

1、park(Object blocker)

  • 挂起当前线程,直到其他线程调用 unpark 方法或当前线程被中断。
  • blocker参数是一个对象,用于标识阻塞当前线程的原因,主要用于调试目的。

2.、parkNanos(Object blocker, long nanos)

  • 挂起当前线程最多 nanos 纳秒,或者直到其他线程调用 unpark 方法或当前线程被中断。(超时后会自动唤醒)
  • blocker 参数是一个对象,用于标识阻塞当前线程的原因,主要用于调试目的。

3、parkUntil(Object blocker, long deadline)

  • 挂起当前线程直到指定的绝对时间 deadline,或者直到其他线程调用 unpark 方法或当前线程被中断。
  • blocker 参数是一个对象,用于标识阻塞当前线程的原因,主要用于调试目的。

4、unpark(Thread thread)

  • 唤醒指定的线程。如果该线程已经被唤醒或未被挂起,则此方法没有任何效果。

5、getBlockedThread(Object blocker)

  • 返回当前被 blocker 阻塞的线程,如果没有线程被阻塞则返回 null

6、currentThread()

  • 返回当前正在执行的线程。

(3)、代码示例:

示例 1:基本的挂起和唤醒
import java.util.concurrent.locks.LockSupport;

public class LockSupportExample {
    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            System.out.println("Thread T1 is running");
            LockSupport.park("Blocker");   // t1线程挂起
            System.out.println("Thread T1 is unblocked");
        }, "T1");

        t1.start();

        try {
            Thread.sleep(1000); // 让主线程等待1秒
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        System.out.println("Unparking T1");
        LockSupport.unpark(t1);   // t1线程唤醒
    }
}

在这个示例中,线程 T1 在启动后会调用 LockSupport.park("Blocker") 挂起自己。主线程等待1秒后,调用 LockSupport.unpark(t1) 唤醒 T1

示例 2:带有超时的挂起
import java.util.concurrent.locks.LockSupport;

public class LockSupportTimeoutExample {
    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            System.out.println("Thread T1 is running");
            LockSupport.parkNanos("Blocker", 2_000_000_000L); // t1线程挂起,最多2秒后唤醒,或者期间被unpark唤醒
            System.out.println("Thread T1 is unblocked");
        }, "T1");

        t1.start();

        try {
            Thread.sleep(1000); // 让主线程等待1秒
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        System.out.println("Unparking T1");
        LockSupport.unpark(t1);  // 唤醒t1线程,实际上t1仅挂起1秒
    }
}

在这个示例中,线程 T1 在启动后会调用 LockSupport.parkNanos("Blocker", 2_000_000_000L) 挂起自己最多2秒。主线程等待1秒后,调用 LockSupport.unpark(t1) 唤醒 T1。如果 T1 在2秒内没有被唤醒,它也会自动解除挂起状态。

示例 3:带有绝对时间的挂起
import java.util.concurrent.locks.LockSupport;

public class LockSupportAbsoluteTimeExample {
    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            System.out.println("Thread T1 is running");
            long deadline = System.currentTimeMillis() + 2000; // 2秒后
            LockSupport.parkUntil("Blocker", deadline);    // 给定绝对时间的挂起
            System.out.println("Thread T1 is unblocked");
        }, "T1");

        t1.start();

        try {
            Thread.sleep(1000); // 让主线程等待1秒
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        System.out.println("Unparking T1");
        LockSupport.unpark(t1);
    }
}

6、死锁的原因及常用避免方法

(1)、什么是死锁

死锁是指两个或多个线程在执行过程中,因争夺资源而造成的一种互相等待的现象,导致这些线程都无法继续执行。在计算机科学中,死锁是一种常见的并发问题,特别是在多线程编程中。

(2)、产生原因

死锁的发生通常满足以下四个必要条件,称为 Coffman 条件:
1、互斥条件
资源不能被共享,只能由一个线程占用。即,一个线程占用了资源后,其他线程就会被阻塞。
2、请求与保持条件
一个线程已经持有了某些资源,但又申请新的资源,而这些新资源被其他线程持有。即:多个线程都要多把锁的情况,如:A线程抢到了锁1,需要锁2,锁2已被A线程持有,需要锁1,两者相互等待。
3、不剥夺条件
已经分配给线程的资源不能被强制回收,只能在该线程使用完后主动释放。
4、循环等待条件
存在一个线程等待环路,即每个线程都在等待下一个线程持有的资源。

(3)、综述

死锁是的一种常见的并发问题,因为锁的特性(抢占锁的线程必须主动释放锁,一个锁不能被两个线程同时持有),在遇到一个线程需要多个锁的条件时,就容易发生多路线程持有锁又要抢占其他锁的情况,就容易发生死锁。

死锁示例:

public class DeadlockExample {
    private final Object lockA = new Object();
    private final Object lockB = new Object();

    public void thread1() {
        synchronized (lockA) {         // 先要A锁
            System.out.println("Thread 1: Holding lock A...");
            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("Thread 1: Waiting for lock B...");
            synchronized (lockB) {           // 再要B锁
                System.out.println("Thread 1: Holding lock A & B...");
            }
        }
    }

    public void thread2() {
        synchronized (lockB) {                // 先要B锁
            System.out.println("Thread 2: Holding lock B...");
            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("Thread 2: Waiting for lock A...");
            synchronized (lockA) {                // 再要A锁
                System.out.println("Thread 2: Holding lock A & B...");
            }
        }
    }

    public static void main(String[] args) {
        DeadlockExample example = new DeadlockExample();

        Thread t1 = new Thread(() -> example.thread1(), "Thread 1");
        Thread t2 = new Thread(() -> example.thread2(), "Thread 2");

        t1.start();   // t1和t2多路线程都需要A和B锁才能完成工作
        t2.start();
    }
}

(4)、避免方法

1、锁排序

确保所有线程在获取多个锁时,总是按照相同的顺序获取。这样可以避免循环等待条件。

代码示例

  public class LockOrderingExample {
       private final Object lock1 = new Object();
       private final Object lock2 = new Object();

       public void method1() {
           synchronized (lock1) {
               synchronized (lock2) {
                   System.out.println("Method 1 holding both locks");
               }
           }
       }

       public void method2() {
           synchronized (lock1) {
               synchronized (lock2) {
                   System.out.println("Method 2 holding both locks");
               }
           }
       }

       public static void main(String[] args) {
           LockOrderingExample example = new LockOrderingExample();

           Thread t1 = new Thread(() -> example.method1(), "Thread 1");
           Thread t2 = new Thread(() -> example.method2(), "Thread 2");

           t1.start();
           t2.start();
       }
   }
2、锁超时

使用 tryLock方法尝试获取锁,并设置超时时间。如果在指定时间内无法获取锁,则放弃尝试。(tryLock方法会返回true或false标识是否抢到锁资源,false时代码应该按照未抢到锁的情况编码)

代码示例

   import java.util.concurrent.locks.Lock;
   import java.util.concurrent.locks.ReentrantLock;
   import java.util.concurrent.TimeUnit;

   public class LockTimeoutExample {
       private final Lock lock1 = new ReentrantLock();
       private final Lock lock2 = new ReentrantLock();

       public void method1() {
           boolean locked1 = false;
           boolean locked2 = false;
           try {
               locked1 = lock1.tryLock(1, TimeUnit.SECONDS);
               locked2 = lock2.tryLock(1, TimeUnit.SECONDS);
               if (locked1 && locked2) {    // 都抢到了锁,主要业务
                   System.out.println("Method 1 holding both locks");
               } else {
                   // 未抢到锁,一般直接返回给出提示
                   System.out.println("Method 1 failed to acquire both locks");
               }
           } catch (InterruptedException e) {
               e.printStackTrace();
           } finally {
               if (locked1) {   // 都要判断和释放锁
                   lock1.unlock();
               }
               if (locked2) {
                   lock2.unlock();
               }
           }
       }

       public void method2() {
           boolean locked1 = false;
           boolean locked2 = false;
           try {
               locked1 = lock2.tryLock(1, TimeUnit.SECONDS);
               locked2 = lock1.tryLock(1, TimeUnit.SECONDS);
               if (locked1 && locked2) {
                   System.out.println("Method 2 holding both locks");
               } else {
                   System.out.println("Method 2 failed to acquire both locks");
               }
           } catch (InterruptedException e) {
               e.printStackTrace();
           } finally {
               if (locked1) {
                   lock2.unlock();
               }
               if (locked2) {
                   lock1.unlock();
               }
           }
       }

       public static void main(String[] args) {
           LockTimeoutExample example = new LockTimeoutExample();

           Thread t1 = new Thread(() -> example.method1(), "Thread 1");
           Thread t2 = new Thread(() -> example.method2(), "Thread 2");

           t1.start();
           t2.start();
       }
   }
3、使用 tryLock 方法抢锁

尝试获取锁,如果无法获取则立即返回,而不是无限期等待。
注:
(1)、lock() 方法:会阻塞当前线程,一直抢锁,直到锁可用为止。
(2)、tryLock() 方法:非阻塞尝试,如果锁当前可用,则立即返回 true;如果锁不可用,则立即返回 false,不会阻塞当前线程。
(3)、tryLock(long time, TimeUnit unit) 方法:带尝试在指定的时间内获取锁,如果在指定时间内锁可用,则返回 true;如果超时仍未获取到锁,则返回 false

4、死锁检测和恢复

通过定期检测系统中的死锁情况,并采取措施恢复,例如中断一个或多个线程,释放部分资源。

5、减少锁的范围

尽量减少锁的作用范围,只在必要的地方使用锁,减少锁的竞争。

7、锁分类

(1)、内置锁和显式锁

在 Java 中,锁分为两种主要类型:内置锁(也称为同步锁或内置同步)和显式锁(也称为显示锁)。

1、内置锁

(1)、概述
内置锁是通过 synchronized 关键字实现的(它提供了方法级和代码块级的锁)。Java 虚拟机(JVM)提供了内置的锁机制,使得多线程编程更加简单和直观。

(2)、特点
1、自动释放:当同步代码块或方法执行完毕后,内置锁会自动释放。
2、可重入:同一个线程可以多次获取同一个内置锁,不会导致死锁。(即synchronized 方法中可以在使用synchronized 代码块)
3、阻塞等待:如果一个线程尝试获取已被其他线程持有的内置锁,它会阻塞,直到锁可用。
4、不可中断:等待获取内置锁的线程不能被中断。
5、无超时:没有提供超时机制,等待线程会一直等待,直到锁可用。

示例代码

public class SynchronizedExample {
    private final Object lock = new Object();

    public void synchronizedMethod() {
        synchronized (lock) {
            // 同步代码块
            System.out.println("Synchronized block executed by " + Thread.currentThread().getName());
        }
    }

    public synchronized void synchronizedMethod2() {
        // 同步方法
        System.out.println("Synchronized method executed by " + Thread.currentThread().getName());
    }

    public static void main(String[] args) {
        SynchronizedExample example = new SynchronizedExample();

        Thread t1 = new Thread(() -> example.synchronizedMethod(), "Thread 1");
        Thread t2 = new Thread(() -> example.synchronizedMethod2(), "Thread 2");

        t1.start();
        t2.start();
    }
}
2、显示锁

(1)、概述
显式锁是通过 java.util.concurrent.locks.Lock 接口及其实现类(如 ReentrantLock)提供的。显式锁提供了比内置锁更多的功能和灵活性。

(2)、特点
1、手动释放:需要手动调用 lock() 方法获取锁,调用 unlock() 方法释放锁。
2、可重入:支持可重入,同一个线程可以多次获取同一个锁。
3、可中断:等待获取锁的线程可以被中断。
4、超时机制:提供 tryLock(long time, TimeUnit unit) 方法,允许在指定时间内尝试获取锁。
5、公平锁:可以选择是否使用公平锁,公平锁确保线程按顺序获取锁。
6、条件变量:支持条件变量,可以实现更复杂的同步机制。

(3)、使用方式
1、获取锁

  lock.lock();

2、释放锁

  lock.unlock();

3、尝试获取锁

  if (lock.tryLock()) {
      try {
          // 执行临界区代码
      } finally {
          lock.unlock();
      }
  }

4、带超时的尝试获取锁

  try {
      if (lock.tryLock(2, TimeUnit.SECONDS)) {
          try {
              // 执行临界区代码
          } finally {
              lock.unlock();
          }
      } else {
          // 超时未能获取到锁
      }
  } catch (InterruptedException e) {
      // 处理中断异常
  }
3、内置锁和显式锁对比

在这里插入图片描述

总结

内置锁:即使用synchronized方式,简单易用,适合大多数基本的同步需求。
显式锁:即使用Lock接口的锁,功能强大,灵活性高,适合复杂的同步需求,如中断等待、超时机制、公平锁等。

(2)、乐观锁和悲观锁

乐观锁和悲观锁是两种不同的并发控制策略,它们在处理多线程环境下的数据竞争问题时采用了不同的方法

1、乐观锁
(1)、概述

乐观锁假设最好的情况,认为每次访问数据时不会有其他线程在修改数据,因此在访问数据时不加锁。如果在更新数据时发现数据已经被其他线程修改,则重新尝试更新操作,直到成功为止。
通常使用版本号和重试机制来实现乐观锁。

(2)、特点

非阻塞性:在读取数据时不加锁,提高了并发性能。
版本号:通常使用版本号或时间戳来判断数据是否被修改。
重试机制:如果数据被修改,需要重新读取数据并重试更新操作。

(3)、应用场景

读多写少:当读操作远多于写操作时,乐观锁可以显著提高并发性能。
低冲突:当数据冲突概率较低时,乐观锁是更好的选择。

(4)、示例代码
import java.util.concurrent.atomic.AtomicInteger;

public class OptimisticLockExample {
    private int counter = 0;
    private AtomicInteger version = new AtomicInteger(0);

    public boolean increment() {
        int currentVersion = version.get();
        while (true) {   // 重试机制
            int currentValue = counter;
            int newValue = currentValue + 1;
            if (version.compareAndSet(currentVersion, currentVersion + 1)) {
                counter = newValue;
                System.out.println("Counter incremented to " + counter + " by " + Thread.currentThread().getName());
                return true;   // 设置成功就结束
            } else {
                // 版本号不匹配,数据已被其他线程修改,重试
                currentVersion = version.get();
            }
        }
    }

    public static void main(String[] args) {
        OptimisticLockExample example = new OptimisticLockExample();

        Thread t1 = new Thread(() -> example.increment(), "Thread 1");
        Thread t2 = new Thread(() -> example.increment(), "Thread 2");

        t1.start();
        t2.start();
    }
}
(5)、乐观锁的问题
1、读取脏数据

如果一个线程读取了数据,而在该线程准备更新数据之前,另一个线程已经修改了数据,那么第一个线程可能会基于过时的数据进行操作,导致数据不一致。

读取脏数据代码示例

import java.util.concurrent.atomic.AtomicInteger;

public class OptimisticLockExample {
    private int counter = 0;      // 普通int类型
    private AtomicInteger version = new AtomicInteger(0);

    public int read() {
        return counter;
    }

    public boolean increment() {
        while (true) {
            int currentValue = counter.get();
            int newValue = currentValue + 1;
            if (counter.compareAndSet(currentValue, newValue)) {
                System.out.println("Counter incremented to " + newValue + " by " + Thread.currentThread().getName());
                return true;
            }
        }
    }

    public static void main(String[] args) {
        OptimisticLockExample example = new OptimisticLockExample();

        Thread t1 = new Thread(() -> {
            int oldValue = example.read();   // t1先读取了值,直接睡眠1s
            try {
                Thread.sleep(1000); // 模拟长时间操作
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            example.increment(); // 直接睡眠1s后再去更新(但是这睡眠的1秒内有其他线程更新了值可能造成问题)
        }, "Thread 1");

        Thread t2 = new Thread(() -> example.increment(), "Thread 2");

        t1.start();
        t2.start();
    }
}

在这个示例中,Thread 1 读取了初始值 0,然后休眠1秒钟。在这期间,Thread 2counter 增加到 1。当 Thread 1 唤醒并尝试增加 counter 时,它仍然基于过时的数据 0 进行操作,这可能导致数据不一致。

改进示例:
使用 Atomic类避免读取脏数据

import java.util.concurrent.atomic.AtomicInteger;

public class OptimisticLockExample {
    private AtomicInteger counter = new AtomicInteger(0);   // 使用原子类

    public int read() {
        return counter.get();
    }

    public boolean increment() {
        while (true) {
            int currentValue = counter.get();
            int newValue = currentValue + 1;
            if (counter.compareAndSet(currentValue, newValue)) {
                System.out.println("Counter incremented to " + newValue + " by " + Thread.currentThread().getName());
                return true;
            }
        }
    }

    public static void main(String[] args) {
        OptimisticLockExample example = new OptimisticLockExample();

        Thread t1 = new Thread(() -> {
            int oldValue = example.read();
            try {
                Thread.sleep(1000); // 模拟长时间操作
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            example.increment(); // 基于最新的数据进行更新
        }, "Thread 1");

        Thread t2 = new Thread(() -> example.increment(), "Thread 2");

        t1.start();
        t2.start();
    }
}

在这个改进的示例中,counter 使用 AtomicInteger 类型,确保读取和更新操作的原子性。即使 Thread 1 在读取数据后休眠一段时间,它在更新时仍然会基于最新的数据进行操作,避免了读取脏数据的问题。

2、重试次数过多

在高并发环境下,如果多个线程频繁尝试更新同一个数据,可能会导致大量的重试操作,从而降低系统的整体性能。

3、ABA问题

乐观锁通常使用版本号或时间戳来判断数据是否被修改。然而,如果一个数据从A变为B,再变回A,版本号或时间戳可能不会发生变化,这会导致乐观锁误判数据未被修改。

(6)、乐观锁问题常用的解决方案:

1、使用悲观锁:在高并发或数据敏感的场景下,可以考虑使用悲观锁来确保数据的一致性。
2、减少读取和更新之间的延迟:尽量减少读取和更新之间的间隔,以减少数据被其他线程修改的可能性。
3、使用 CAS 操作:使用 Atomic 类提供的 compareAndSet 方法来确保原子性操作,避免中间数据被修改。
4、版本号或时间戳:使用版本号或时间戳来记录数据的修改次数,确保在更新时检查版本号或时间戳的一致性。
5、事务管理:在数据库操作中,可以使用事务管理来确保数据的一致性。事务具有 ACID(原子性、一致性、隔离性、持久性)特性,可以有效防止数据不一致的问题。

2、悲观锁
(1)、概述

悲观锁假设最坏的情况,认为每次访问数据时都可能有其他线程在修改数据,因此在访问数据前总是先加锁。
这种方式确保了数据的一致性和完整性,但可能会导致性能下降,因为频繁的加锁和解锁操作会增加开销。

(2)、特点

互斥性:同一时间只有一个线程可以访问数据。
阻塞等待:如果一个线程已经持有锁,其他线程必须等待。
线程安全:确保数据的一致性和完整性。

(3)、应用场景

写多读少:当写操作频繁且并发度较高时,悲观锁可以确保数据的一致性。
数据敏感:当数据对一致性要求非常高时,悲观锁是更好的选择。

(4)、示例代码

使用 ReentrantLock 实现悲观锁:

import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class PessimisticLockExample {
    private final Lock lock = new ReentrantLock();
    private int counter = 0;

    public void increment() {
        lock.lock();
        try {
            counter++;
            System.out.println("Counter incremented to " + counter + " by " + Thread.currentThread().getName());
        } finally {
            lock.unlock();
        }
    }

    public static void main(String[] args) {
        PessimisticLockExample example = new PessimisticLockExample();

        Thread t1 = new Thread(() -> example.increment(), "Thread 1");
        Thread t2 = new Thread(() -> example.increment(), "Thread 2");

        t1.start();
        t2.start();
    }
}
3、对比总结

悲观锁:假设最坏的情况,每次访问数据时都加锁,确保数据的一致性和完整性。适用于写多读少的场景。

乐观锁:假设最好的情况,每次访问数据时不加锁,使用版本号或时间戳来判断数据是否被修改。适用于读多写少的场景。但是在高并发或数据敏感的场景下,可能会导致读写不一致的问题,这种情况下还是使用悲观锁比较好,毕竟数据安全才是最重要的。

(3)、独享锁和共享锁

1、独享锁

独享锁(Exclusive Lock)也称为排他锁或写锁。当一个线程获取了独享锁后,其他线程不能获取该锁,直到当前线程释放锁。独享锁确保在同一时间只有一个线程可以访问资源,常用于写操作。
如:ReentrantLock 或ReentrantReadWriteLock.WriteLock(读写锁的写锁)

2、共享锁

共享锁(Shared Lock)也称为读锁。当一个线程获取了共享锁后,其他线程也可以获取该锁,但不能获取独享锁。共享锁允许多个线程同时读取资源,常用于读操作。
如:ReentrantReadWriteLock.ReadLock(读写锁的读锁)

(4)、可重入锁

可重入锁又名递归锁,是指在同一个线程在外层方法获取锁的时候,在进入内层方法可以再次获取锁。简单来说就是同一个线程可以重复加锁,每次加锁的时候count值加1,每次释放锁的时候count减1,直到count为0,其他的线程才可以再次获取。

(5)、公平锁和非公平锁

1、公平锁

按照请求锁的顺序来分配锁,确保每个线程都能按顺序获得锁。

2、非公平锁

不保证锁的获取顺序,允许插队,可能提高吞吐量但可能导致某些线程长期等待。

示例代码

import java.util.concurrent.locks.ReentrantLock;

public class FairLockExample {
    private final ReentrantLock fairLock = new ReentrantLock(true);  // true为公平锁

    public void method() {
        fairLock.lock();
        try {
            System.out.println("Method is running by " + Thread.currentThread().getName());
        } finally {
            fairLock.unlock();
        }
    }
}

(6)、分段锁

1、概述

分段锁(Segmented Locking)是一种优化锁机制的技术,通过将数据分成多个段(segments),每个段使用独立的锁来减少锁的竞争,从而提高并发性能。如并发容器ConcurrentHashMap实际就是通过分段锁的形式来实现高效的并发操作。

分段锁特别适用于大型数据结构,如哈希表或集合,其中多个线程可以同时访问不同的段而不相互干扰。

2、实现原理

(1)、当创建一个ConcurrentHashMap对象时(如map),其内部实际上创建了多个Segment(简单理解为HashMap)对象。
(2)、在操作map对象时,会根据操作的key获取其中的一个Segment对象(可以是hashCode在求余,保证这个key一定会存到这个分段中)
(3)、操作时实际上只对获取到的这个Segment上锁,并没有对整个map上锁,从而实现了可并发又保障了数据安全。

分段锁实现哈希表代码示例

下面是一个简单的分段锁实现哈希表的示例。我们将哈希表分成多个段,每个段有一个独立的锁。

import java.util.concurrent.locks.ReentrantLock;

public class SegmentedHashTable<K, V> {
    private static final int DEFAULT_SEGMENT_SIZE = 16;   // 默认分段数量
    private final Segment<K, V>[] segments;    // 分段数组

    public SegmentedHashTable(int segmentSize) {
        this.segments = new Segment[segmentSize];   // 创建分段数组
        for (int i = 0; i < segmentSize; i++) {
            segments[i] = new Segment<>();
        }
    }

    public V get(K key) {
        int hash = key.hashCode();
        int segmentIndex = hash % segments.length;     // 根据key找到对应的分段
        return segments[segmentIndex].get(key);
    }

    public void put(K key, V value) {
        int hash = key.hashCode();
        int segmentIndex = hash % segments.length;
        segments[segmentIndex].put(key, value);
    }

    public V remove(K key) {
        int hash = key.hashCode();
        int segmentIndex = hash % segments.length;
        return segments[segmentIndex].remove(key);
    }

    private static class Segment<K, V> {
        private final ReentrantLock lock = new ReentrantLock();
        private final java.util.Map<K, V> map = new java.util.HashMap<>();

        public V get(K key) {
            lock.lock();     // 分段内上锁
            try {
                return map.get(key);
            } finally {
                lock.unlock();   // 分段内解锁
            }
        }

        public void put(K key, V value) {
            lock.lock();
            try {
                map.put(key, value);
            } finally {
                lock.unlock();
            }
        }

        public V remove(K key) {
            lock.lock();
            try {
                return map.remove(key);
            } finally {
                lock.unlock();
            }
        }
    }

    public static void main(String[] args) {
        SegmentedHashTable<String, String> table = new SegmentedHashTable<>(DEFAULT_SEGMENT_SIZE);

        Thread t1 = new Thread(() -> table.put("key1", "value1"), "Thread 1");
        Thread t2 = new Thread(() -> table.put("key2", "value2"), "Thread 2");
        Thread t3 = new Thread(() -> table.put("key3", "value3"), "Thread 3");

        t1.start();
        t2.start();
        t3.start();

        try {
            t1.join();
            t2.join();
            t3.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        System.out.println(table.get("key1"));
        System.out.println(table.get("key2"));
        System.out.println(table.get("key3"));
    }
}

解释下:
1、分段初始化

  • SegmentedHashTable 构造函数初始化多个 Segment 对象,每个 Segment 对象包含一个 ReentrantLock 和一个 HashMap
  • segments 数组的大小可以通过参数 segmentSize 指定,默认为16。

2、获取段索引

  • getputremove 方法通过计算键的哈希码来确定对应的段索引。
  • hash % segments.length 计算出键应该属于哪个段。

3、段操作

  • Segment 类中的 getputremove 方法在操作 HashMap 时都会先获取段的锁,操作完成后释放锁。

(7)偏向锁、轻量级锁和重量级锁

在Java的并发编程中,锁的实现经历了从重量级锁到轻量级锁再到偏向锁的演进过程。这些锁机制的设计目的是为了在不同的并发场景下提供更高的性能和更低的开销。
在Java中,可以通过 synchronized 关键字或 ReentrantLock 类来实现。JVM会根据实际情况决定使用哪一种机制,无需我们在代码上处理。

1、重量级锁(Heavyweight Lock)

重量级锁是传统的锁机制,通常使用操作系统提供的互斥锁(Mutex)来实现。
特点
(1)、阻塞等待:如果一个线程已经持有锁,其他线程必须等待,直到锁被释放。
(2)、上下文切换:重量级锁会导致线程的上下文切换,开销较大。
(3)、互斥性:同一时间只有一个线程可以持有锁。

2、轻量级锁(Lightweight Lock)

轻量级锁是重量级锁的一种优化机制。
轻量级锁在多线程竞争不激烈的情况下,可以避免重量级锁的开销。轻量级锁通过自旋锁(Spin Lock)来实现,线程在等待锁时不会立即阻塞,而是通过自旋(循环等待)来尝试获取锁。
特点
(1)、自旋等待:线程在等待锁时不会立即阻塞,而是通过自旋(循环等待)来尝试获取锁。
(2)、减少上下文切换:轻量级锁减少了线程的上下文切换,提高了性能。
(3)、适用场景:适用于多线程竞争不激烈的场景。

3、偏向锁(Biased Locking)

偏向锁是进一步优化轻量级锁的一种机制。
偏向锁假设在多线程竞争不激烈的场景下,大部分情况下只有一个线程访问某个对象。因此,偏向锁会将锁偏向于第一个访问该对象的线程,减少不必要的锁操作。
特点
(1)、偏向于一个线程:偏向锁会将锁偏向于第一个访问对象的线程,减少锁的开销。
(2)、减少锁的开销:偏向锁减少了锁的获取和释放的开销,提高了性能。
(3)、适用场景:适用于多线程竞争不激烈的场景,特别是大部分情况下只有一个线程访问对象的场景。
理解一下:
以过安检示例,看门大爷对于新员工会喊停下来并检查证件后才放行。之后熟悉了,看到该员工脸就直接放行了。
Jvm实现重量级锁,轻量级锁,偏向锁也是类似的过程。第一次都是以重量级锁方式处理;频繁该线程请求该资源后就变成轻量级锁,直到变为偏向锁。

4、锁的升级过程

(1)、无锁状态:对象没有任何锁。
(2)、偏向锁:当一个线程第一次访问对象时,JVM会尝试将锁偏向于该线程。
(3)、轻量级锁:如果多个线程竞争同一个对象的锁,偏向锁会升级为轻量级锁。
(4)、重量级锁:如果竞争激烈,轻量级锁会升级为重量级锁。

5、总结

(1)、重量级锁:传统的锁机制,通过操作系统提供的互斥锁实现,适用于高并发写操作和数据敏感的场景。
(2)、轻量级锁:通过自旋锁实现,适用于多线程竞争不激烈的场景,减少上下文切换的开销。
(3)、偏向锁:进一步优化轻量级锁,适用于多线程竞争不激烈的场景,特别是大部分情况下只有一个线程访问对象的场景。

实际上作为程序员我们要做的就是正常上锁解锁就行,无需关心这三种状态机制的切换,这个是JVM会自动处理的。

(8)、自旋锁

1、概述

自旋锁(Spin Lock)是一种同步机制,当一个线程试图获取已经被其他线程持有的锁时,它不会立即进入阻塞状态,而是通过循环不断地尝试获取锁。如果在短时间内能够获取到锁,自旋锁可以避免线程的上下文切换开销,从而提高性能。缺点是循环会消耗CPU。

2、自旋锁和tryLock方法

自旋锁是一种锁机制,通过让线程在等待锁时不断循环(自旋)来尝试获取锁。
tryLock 方法本身不是自旋锁,但它可以被用来实现自旋锁的行为。如带有参数调用tryLock(时间)方法,就类似实现了自旋锁的功能。

示例代码
下面是一个简单的自旋锁实现示例,使用 volatile 关键字确保可见性和 Thread.yield() 方法让出CPU时间片。

public class SpinLock {
    private volatile int state = 0; // 0 表示未锁定,1 表示已锁定

    public void lock() {
        while (true) {
            if (state == 0 && compareAndSet(0, 1)) {
                // 成功获取锁就结束
                break;
            }
            // 自旋等待
            Thread.yield();    // 让出CPU时间片,避免CPU占用过高
        }
    }

    public void unlock() {
        state = 0; // 释放锁
    }

    private boolean compareAndSet(int expect, int update) {
        // 模拟 CAS 操作
        if (state == expect) {
            state = update;
            return true;
        }
        return false;
    }

    public static void main(String[] args) {
        SpinLock spinLock = new SpinLock();

        Thread t1 = new Thread(() -> {
            spinLock.lock();
            try {
                Thread.sleep(1000); // 模拟耗时操作
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("Thread 1 released the lock");
            spinLock.unlock();
        }, "Thread 1");

        Thread t2 = new Thread(() -> {
            spinLock.lock();
            System.out.println("Thread 2 acquired the lock");
            spinLock.unlock();
        }, "Thread 2");

        t1.start();
        t2.start();
    }
}

学海无涯苦作舟!!!

;