当前位置: 首页 > article >正文

并发编程---synchronized关键字,以及synchronized同步锁

文章目录

  • Synchronized 的使用
    • synchronized 在普通方法上的使用(对象锁)
    • `synchronized` 在静态方法上的使用(类锁)
    • `synchronized` 在代码块上的使用
  • JVM 中锁的优化
    • 锁的类型
    • 自旋锁与自适应自旋锁
      • 自旋锁(Spin Lock)
        • 工作原理:
        • 优点:
        • 缺点:
      • 自适应自旋锁(Adaptive Spin Lock)
        • 工作原理:
        • 优点:
        • 缺点:
      • 自旋锁 vs 自适应自旋锁
    • 无锁(No Lock)
      • 典型应用:
      • 优点:
      • 缺点:
    • 偏向锁
      • 工作原理:
      • 偏向锁的撤销
      • 优点:
      • 缺点:
    • 轻量锁(Lightweight Locking)
      • 轻量级锁加锁
      • 工作原理:
      • 优点:
      • 缺点:
    • 重量锁(Heavyweight Locking)
      • 工作原理:
      • 优点:
      • 缺点:
    • 总结


synchronized 是 Java 提供的一种内置的同步机制,用来保证多线程并发时对共享资源的访问是线程安全的。它通过获取和释放锁来实现线程之间的同步。synchronized 可以用于普通方法、静态方法以及代码块上,但其具体行为和性能特性有所不同。

Synchronized 的使用

在应用 Sychronized 关键字时需要把握如下注意点:

  • 一把锁只能同时被一个线程获取,没有获得锁的线程只能等待;
  • 每个实例都对应有自己的一把锁(this), 不同实例之间互不影响;例外:锁对象是*.class 以及 synchronized 修饰的是 static 方法的时候,所有对象公用同一把锁
  • synchronized 修饰的方法,无论方法正常执行完毕还是抛出异常,都会释放锁

synchronized 在普通方法上的使用(对象锁)

  • 锁住的是当前对象实例:当 synchronized 修饰一个普通实例方法时,它锁住的是 当前实例对象(this,即方法所属的对象实例。每个对象实例有一个独立的锁。
  • 作用:多个线程访问同一个对象的实例方法时,只有一个线程能够获取该对象实例的锁,确保同一时刻只有一个线程可以执行该方法,避免多个线程同时修改实例变量导致数据不一致的问题。

示例:

public class SynchronizedObjectLock implements Runnable {
    static SynchronizedObjectLock instance = new SynchronizedObjectLock();

    @Override
    public void run() {
        method();
    }

    // 这个方法被 synchronized 修饰,意味着它是同步的,只有一个线程可以访问
    public synchronized void method() {
        // 输出当前线程的名称
        System.out.println("我是线程 " + Thread.currentThread().getName());

        try {
            // 模拟处理过程,当前线程休眠 3 秒钟
            Thread.sleep(3000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        // 输出当前线程结束
        System.out.println(Thread.currentThread().getName() + " 结束");
    }

    public static void main(String[] args) {
        // 创建两个线程,两个线程共享同一个对象实例 instance
        Thread t1 = new Thread(instance);
        Thread t2 = new Thread(instance);

        // 启动线程
        t1.start();
        t2.start();
    }
}

执行过程:

  1. 线程启动:main() 方法中,我们创建了两个线程 t1t2,它们都会调用 instancemethod() 方法。
  2. method() 被同步:由于 method()synchronized 修饰,它是一个 对象锁。意味着这两个线程 必须共享同一个对象锁,并且 同一时刻只有一个线程可以进入 method()
  3. 线程执行
    • t1 第一个获得锁并执行 method() 时,t2 会被阻塞,直到 t1 执行完并释放锁。
    • t1 执行完后,t2 才能进入 method() 方法。

输出结果:

我是线程Thread-0
Thread-0结束
我是线程Thread-1
Thread-1结束

synchronized 在静态方法上的使用(类锁)

  • 锁住的是类的 Class 对象:当 synchronized 修饰一个静态方法时,它锁住的是 类的 Class 对象,而不是对象实例(this)。因此,所有实例共享同一个锁。
  • 作用:静态方法属于类,而不是对象,所以多个线程访问同一个类的静态方法时,只有一个线程能获得类级别的锁,确保静态方法在同一时刻只有一个线程执行。

示例:

public class SynchronizedObjectLock implements Runnable {
    static SynchronizedObjectLock instance1 = new SynchronizedObjectLock();
    static SynchronizedObjectLock instance2 = new SynchronizedObjectLock();

    @Override
    public void run() {
        method();
    }

    // synchronized用在静态方法上,默认的锁就是当前所在的Class类,所以无论是哪个线程访问它,需要的锁都只有一把
    public static synchronized void method() {
        System.out.println("我是线程" + Thread.currentThread().getName());
        try {
            Thread.sleep(3000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(Thread.currentThread().getName() + "结束");
    }

    public static void main(String[] args) {
        Thread t1 = new Thread(instance1);
        Thread t2 = new Thread(instance2);
        t1.start();
        t2.start();
    }
}

执行过程:

  1. 线程 t1 启动后,它会进入 method() 方法并获取类锁,进入方法并休眠 3 秒。
  2. 线程 t2 启动后,由于 method() 是静态方法并且使用了 synchronized,线程 t2 必须等待线程 t1 执行完并释放类锁才能进入 method() 方法。

输出结果:

我是线程Thread-0
Thread-0结束
我是线程Thread-1
Thread-1结束

synchronized 在代码块上的使用

  • 锁住特定的对象:当 synchronized 修饰一个代码块时,可以通过指定锁对象来控制锁的粒度。这样可以锁住特定的对象,而不是整个方法。通常使用 synchronized 来锁住一些共享的资源或者代码块,防止多个线程同时访问导致线程安全问题。
  • 锁对象:可以选择任何对象作为锁对象,通常是实例对象(this)或自定义的锁对象。

示例 1:

public class SynchronizedObjectLock implements Runnable {
    static SynchronizedObjectLock instance = new SynchronizedObjectLock();

    @Override
    public void run() {
        // 同步代码块形式——锁为this,两个线程使用的锁是一样的,线程1必须要等到线程0释放了该锁后,才能执行
        synchronized (this) {
            System.out.println("我是线程" + Thread.currentThread().getName());
            try {
                Thread.sleep(3000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println(Thread.currentThread().getName() + "结束");
        }
    }

    public static void main(String[] args) {
        Thread t1 = new Thread(instance);
        Thread t2 = new Thread(instance);
        t1.start();
        t2.start();
    }
}

执行过程:

  1. 初始化:创建了一个 SynchronizedObjectLock 的实例 instance,并将其作为 Runnable 实现传递给两个线程(t1t2)。
  2. 线程启动t1t2 分别调用 start() 方法启动两个线程。
  3. 同步代码块:线程执行 run() 方法时,进入 synchronized (this) 块。这里的 this 表示当前的 SynchronizedObjectLock 对象,因此 t1t2 线程对同一个 instance 实例加锁。
  4. 线程执行:由于 t1t2 都在争夺同一个对象的锁,它们会按照顺序一个一个地获取锁。第一个获取锁的线程会执行 System.out.println("我是线程" + Thread.currentThread().getName()) 打印出自己的线程名,然后进入 try 块,执行 Thread.sleep(3000) 来模拟一个耗时的操作。在 Thread.sleep(3000) 期间,该线程会持有锁,并且阻塞自己 3 秒钟,其他线程在此期间无法获取锁。
  5. 线程交替执行:假设 t1 先获得锁并执行,t2 会被阻塞,直到 t1 释放锁。t1 执行完毕后,释放锁,t2 获得锁,执行相同的代码块,最终打印出 t2 的线程信息。

输出结果:

我是线程Thread-0
Thread-0结束
我是线程Thread-1
Thread-1结束

示例 2:

public class SynchronizedObjectLock implements Runnable {
    static SynchronizedObjectLock instance = new SynchronizedObjectLock();
    // 创建2把锁
    Object block1 = new Object();
    Object block2 = new Object();

    @Override
    public void run() {
        // 这个代码块使用的是第一把锁,当他释放后,后面的代码块由于使用的是第二把锁,因此可以马上执行
        synchronized (block1) {
            System.out.println("block1锁,我是线程" + Thread.currentThread().getName());
            try {
                Thread.sleep(3000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("block1锁,"+Thread.currentThread().getName() + "结束");
        }

        synchronized (block2) {
            System.out.println("block2锁,我是线程" + Thread.currentThread().getName());
            try {
                Thread.sleep(3000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("block2锁,"+Thread.currentThread().getName() + "结束");
        }
    }

    public static void main(String[] args) {
        Thread t1 = new Thread(instance);
        Thread t2 = new Thread(instance);
        t1.start();
        t2.start();
    }
}

执行过程:

  1. 初始化

    • 创建了一个 SynchronizedObjectLock 的实例 instance,并将其作为 Runnable 实现传递给两个线程(t1t2)。

    • SynchronizedObjectLock 类中,定义了两个锁对象 block1block2,分别对应两个不同的同步代码块。

  2. 线程启动

    • t1t2 通过 start() 启动,开始执行 run() 方法。
  3. 同步代码块

    • 线程首先执行第一个同步代码块,synchronized (block1),此时线程会争夺 block1 锁。假设 t1 先获得了 block1 锁,它就会执行这个代码块中的代码,输出:

      block1锁,我是线程Thread-0
      
    • t1 会在 Thread.sleep(3000) 期间持有 block1 锁 3 秒钟,这时,t2 线程如果到达此代码块,则会被阻塞,直到 t1 释放 block1 锁。

  4. 第二个同步代码块

    • 同时,由于 block1block2 是两个不同的锁对象,当 t1 执行完第一个同步代码块并释放了 block1 锁后,t2 可以继续执行第一个同步代码块,但它会进入 synchronized (block2) 这一段代码。

    • block2 锁是独立的,因此 t2 不会被 t1block1 锁阻塞。t2 进入第二个同步代码块后会输出:

      block2锁,我是线程Thread-1
      

输出结果:

block1锁,我是线程Thread-0
block1锁,Thread-0结束
block2锁,我是线程Thread-0  // 可以看到当第一个线程在执行完第一段同步代码块之后,第二个同步代码块可以马上得到执行,因为他们使用的锁不是同一把
block1锁,我是线程Thread-1
block2锁,Thread-0结束
block1锁,Thread-1结束
block2锁,我是线程Thread-1
block2锁,Thread-1结束

示例 3:

public class SynchronizedObjectLock implements Runnable {
    static SynchronizedObjectLock instance1 = new SynchronizedObjectLock();
    static SynchronizedObjectLock instance2 = new SynchronizedObjectLock();

    @Override
    public void run() {
        // 所有线程需要的锁都是同一把
        synchronized(SynchronizedObjectLock.class){
            System.out.println("我是线程" + Thread.currentThread().getName());
            try {
                Thread.sleep(3000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println(Thread.currentThread().getName() + "结束");
        }
    }

    public static void main(String[] args) {
        Thread t1 = new Thread(instance1);
        Thread t2 = new Thread(instance2);
        t1.start();
        t2.start();
    }
}

执行过程:

  1. 创建实例

    • 创建了两个 SynchronizedObjectLock 的实例:instance1instance2

    • 但是,重要的是,尽管这两个实例是不同的对象,但同步块是通过 SynchronizedObjectLock.class 来锁定的。

  2. 同步的本质

    • synchronized(SynchronizedObjectLock.class) 锁定的是 SynchronizedObjectLock 类的 Class 对象,而不是 instance1instance2 的实例对象。

    • 这意味着,所有线程无论是通过 instance1 还是 instance2 启动,只要它们访问到这个同步代码块,都将争夺同一个类对象锁。因此,无论线程使用哪个实例,都会竞争 SynchronizedObjectLock.class 这一把类锁。

  3. 线程启动

    • 线程 t1 使用 instance1 启动,线程 t2 使用 instance2 启动。

    • 尽管它们操作的是不同的实例(instance1instance2),但是因为同步的锁是类的锁 SynchronizedObjectLock.class,所以它们会相互阻塞。

  4. 执行过程

    • 当线程 t1 执行时,它会首先获取 SynchronizedObjectLock.class 锁,然后进入同步代码块。此时,t2 不能进入同步代码块,必须等待 t1 完成释放锁后才能进入。

    • 线程 t1 会输出:

      我是线程Thread-0
      

      然后 Thread.sleep(3000) 阻塞 3 秒钟。

    • t1 执行完毕并释放类锁后,t2 才可以获取锁,进入同步代码块,输出:

      我是线程Thread-1
      

      然后再阻塞 3 秒钟,完成后输出:

      Thread-1结束
      

输出结果:

我是线程Thread-0
Thread-0结束
我是线程Thread-1
Thread-1结束

JVM 中锁的优化

锁的类型

在 Java SE 1.6 里 Synchronied 同步锁,一共有四种状态:无锁偏向锁轻量级锁重量级锁,它会随着竞争情况逐渐升级。锁可以升级但是不可以降级,目的是为了提供获取锁和释放锁的效率。

锁膨胀方向: 无锁 → 偏向锁 → 轻量级锁 → 重量级锁 (此过程是不可逆的)

自旋锁与自适应自旋锁

自旋锁(Spin Lock)

自旋锁是一种基于忙等待(busy-waiting)机制的锁。在自旋锁中,当一个线程试图获取锁时,如果锁已经被其他线程占用,它会 反复检查锁的状态,而不是进入阻塞状态。这种机制叫做“自旋”,因为线程一直在执行而没有进入休眠。

工作原理:
  • 线程获取锁时,如果锁未被占用,线程就可以成功获取锁并执行任务。
  • 如果锁已经被占用,线程不会被挂起,而是不断地检查锁的状态(自旋),直到锁被释放。
  • 如果自旋的时间很长,线程仍然没有获得锁,它可能会转为 阻塞 状态(例如进入操作系统的调度队列)。
优点:
  • 在锁争用不激烈的情况下,自旋锁的开销小,避免了线程的上下文切换和系统调用,性能高效。
  • 对于临界区非常短的情况,自旋锁可以大幅减少因线程阻塞而产生的开销。
缺点:
  • 自旋消耗 CPU 资源:如果线程自旋等待时间过长,CPU 资源会被浪费,降低系统效率。
  • 如果锁竞争激烈,线程自旋的时间会很长,造成性能问题。

示例:自旋锁实现

public class SpinLockExample {
    // 使用 volatile 修饰 lock,确保多线程间的可见性
    private volatile boolean lock = false;

    // 锁定方法
    public void lock() {
        while (true) {
            // 如果锁没有被占用,尝试获取锁
            if (!lock) {
                // 将 lock 设置为 true,表示当前线程已占有锁
                lock = true;
                break;  // 获取锁成功,退出循环
            }
        }
    }

    // 解锁方法
    public void unlock() {
        // 将 lock 设置为 false,释放锁
        lock = false;
    }

    public static void main(String[] args) throws InterruptedException {
        // 创建 SpinLockExample 实例
        SpinLockExample spinLock = new SpinLockExample();

        // 任务:获取锁并执行工作,之后释放锁
        Runnable task = () -> {
            // 获取锁
            spinLock.lock();
            System.out.println(Thread.currentThread().getName() + " obtained the lock");
            try {
                // 模拟任务处理,休眠 100ms
                Thread.sleep(100);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            // 释放锁
            spinLock.unlock();
            System.out.println(Thread.currentThread().getName() + " released the lock");
        };

        // 创建并启动两个线程,执行 task 任务
        Thread t1 = new Thread(task);
        Thread t2 = new Thread(task);

        t1.start();  // 启动线程 t1
        t2.start();  // 启动线程 t2
    }
}

在这个例子中,当一个线程尝试获取锁时,它会自旋等待,直到锁被释放。这个实现是一个非常基础的自旋锁。

自适应自旋锁(Adaptive Spin Lock)

工作原理:
  • 自适应自旋锁会根据 锁竞争的历史(如上一次自旋失败的次数)调整自旋的时间。当锁竞争较少时,自旋时间较短;而当锁竞争较激烈时,自旋时间会适当增加。
  • JVM 或操作系统会根据硬件、操作系统调度和锁的特性调整自旋次数。
  • 在大多数现代 JVM 中,操作系统和 JVM 本身会做自适应优化。比如,HotSpot JVM 使用了自适应自旋机制,称为 Adaptive Spin Lock,它根据前一段时间自旋的情况调整自旋的次数,通常是基于硬件的缓存和上下文切换的成本进行调优。
优点:
  • 更智能的自旋:根据当前的锁竞争情况动态调整自旋时间,提高了性能。
  • 降低了 CPU 的浪费:如果锁竞争较少,自旋时间较短;如果锁竞争激烈,则减少自旋转而进入阻塞状态。
缺点:
  • 需要较复杂的实现,可能比简单的自旋锁稍微消耗更多的资源。
  • 对系统的硬件和 JVM 的支持要求较高。

示例:自适应自旋锁的实现

public class AdaptiveSpinLockExample {
    // 使用 volatile 修饰 lock,确保多线程间的可见性
    private volatile boolean lock = false;
    
    // 最大自旋次数,当自旋次数超过此阈值时,进行休眠
    private static final int MAX_SPIN_COUNT = 100;

    // 锁定方法
    public void lock() {
        int spinCount = 0;  // 用于记录自旋次数
        while (true) {
            // 如果锁没有被占用,尝试获取锁
            if (!lock) {
                // 获取锁,设置 lock 为 true
                lock = true;
                break;  // 获取锁成功,退出循环
            } else {
                spinCount++;  // 自旋次数加 1
                // 如果自旋次数超过最大自旋次数,进行休眠来减少 CPU 占用
                if (spinCount > MAX_SPIN_COUNT) {
                    try {
                        // 休眠 1 毫秒,避免过多的 CPU 占用
                        Thread.sleep(1);
                    } catch (InterruptedException e) {
                        e.printStackTrace();  // 捕获并处理 InterruptedException
                    }
                }
            }
        }
    }

    // 解锁方法
    public void unlock() {
        // 释放锁,将 lock 设置为 false
        lock = false;
    }

    public static void main(String[] args) throws InterruptedException {
        // 创建 AdaptiveSpinLockExample 实例
        AdaptiveSpinLockExample adaptiveLock = new AdaptiveSpinLockExample();

        // 任务:获取锁并执行工作,之后释放锁
        Runnable task = () -> {
            adaptiveLock.lock();  // 获取锁
            System.out.println(Thread.currentThread().getName() + " obtained the lock");
            try {
                // 模拟任务处理,休眠 100 毫秒
                Thread.sleep(100);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            adaptiveLock.unlock();  // 释放锁
            System.out.println(Thread.currentThread().getName() + " released the lock");
        };

        // 创建并启动两个线程,执行 task 任务
        Thread t1 = new Thread(task);
        Thread t2 = new Thread(task);

        t1.start();  // 启动线程 t1
        t2.start();  // 启动线程 t2
    }
}

在这个例子中,自适应自旋锁会根据自旋的次数来决定是否进行线程休眠,从而减少 CPU 资源浪费。如果自旋次数过多,则线程会进入休眠,避免一直占用 CPU。

自旋锁 vs 自适应自旋锁

自旋锁

  • 总是进行固定次数的自旋。
  • 适用于锁竞争非常小、锁的持有时间很短的场景。
  • 自旋时间不变,可能导致不必要的 CPU 资源浪费。

自适应自旋锁

  • 动态调整自旋时间,基于锁的竞争情况和历史自旋情况来决定自旋的次数。
  • 适用于锁竞争情况较为复杂的场景,可以减少不必要的自旋带来的性能损失。
  • 自适应自旋锁通过调节自旋次数和引入适当的线程休眠,能够更高效地利用 CPU 资源。

总结:

  • 自旋锁 适合于 低竞争短时间锁持有 的场景,通过不断尝试获取锁来避免线程的上下文切换,但长时间的自旋会浪费大量 CPU 资源。
  • 自适应自旋锁 在自旋的基础上加入了动态调节的机制,能够根据实际的锁竞争情况来调整自旋次数,避免了过多的自旋等待和 CPU 浪费,是一种更智能的自旋机制。

无锁(No Lock)

无锁 机制指的是 没有使用任何锁 来保证线程安全,而是通过其他并发控制机制来实现线程同步。比如,利用原子操作(AtomicIntegerAtomicLong 等)来保证数据的原子性和一致性。

典型应用:

  • CAS(Compare and Swap):原子操作 compareAndSet,通过对内存进行比较并更新的方式实现无锁操作。
  • 无锁数据结构:如无锁队列、无锁栈等。

优点:

  • 性能最高,不需要线程争抢锁资源。
  • 不会发生死锁。

缺点:

  • 适用于某些场景,如计数器、栈等,对于复杂的共享资源操作,难以实现。

偏向锁

当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程 ID,以后该线程在进入和退出同步块时不需要进行 CAS 操作来加锁和解锁。只需要简单的测试一下对象头的 Mark Word 里是否存储着指向当前线程的偏向锁。如果成功,表示线程已经获取到了锁。

偏向锁是一种优化技术,它在单线程访问锁的情况下,不会加锁。当一个线程访问一个对象时,它会认为自己“偏向”该对象,并且后续不再加锁,直到发生竞争时才会升级为轻量锁。

img

工作原理:

  • 偏向锁的初衷是 减少不必要的锁操作,当一个线程获得锁时,JVM 会将锁标记为偏向锁,并且在该线程后续访问该对象时,JVM 不会进行加锁操作。
  • 如果没有其他线程竞争,偏向锁会一直存在。
  • 当另一个线程尝试访问该对象时,偏向锁会被撤销,升级为轻量锁。

偏向锁的撤销

偏向锁使用了一种等待竞争出现才会释放锁的机制。所以当其他线程尝试获取偏向锁时,持有偏向锁的线程才会释放锁。但是偏向锁的撤销需要等到全局安全点(就是当前线程没有正在执行的字节码)。它会首先暂停拥有偏向锁的线程,让你后检查持有偏向锁的线程是否活着。如果线程不处于活动状态,直接将对象头设置为无锁状态。如果线程活着,JVM 会遍历栈帧中的锁记录,栈帧中的锁记录和对象头要么偏向于其他线程,要么恢复到无锁状态或者标记对象不适合作为偏向锁。

img

优点:

  • 在单线程访问的情况下,避免了加锁的开销,提高了性能。

缺点:

  • 如果多个线程竞争锁,偏向锁会被撤销,锁会升级为轻量锁或重量锁,带来额外的开销。

偏向锁示例:

public class BiasedLockingExample {
    // 定义一个静态的锁对象,所有线程将竞争这个锁
    private static Object lock = new Object();

    public static void main(String[] args) throws InterruptedException {
        // 创建线程 t1,它尝试获取锁并执行同步代码块
        Thread t1 = new Thread(() -> {
            // 使用 synchronized 关键字获取锁
            synchronized (lock) {
                // 当线程 t1 获取到锁后,执行以下代码
                System.out.println("Thread 1 holds the lock.");
            }
            // 同步代码块执行完毕后,锁会自动释放
        });

        // 创建线程 t2,它同样尝试获取锁并执行同步代码块
        Thread t2 = new Thread(() -> {
            // 使用 synchronized 关键字获取锁
            synchronized (lock) {
                // 当线程 t2 获取到锁后,执行以下代码
                System.out.println("Thread 2 holds the lock.");
            }
            // 同步代码块执行完毕后,锁会自动释放
        });

        // 启动线程 t1
        t1.start();
        // 启动线程 t2
        t2.start();

        // 使用 join() 方法等待线程 t1 和 t2 执行完毕
        t1.join();
        t2.join();

        // 主线程在所有子线程执行完毕后继续执行
        System.out.println("Main thread finished.");
    }
}

在上面的例子中,lock 最初会处于偏向锁状态,如果线程 t1 一直持有锁,那么就不会有加锁开销,直到 t2 争夺该锁时,才会升级为轻量锁。

轻量锁(Lightweight Locking)

轻量锁是一种用于减少线程竞争时加锁的开销的优化技术。轻量锁通过 自旋锁 机制实现,适用于没有线程竞争或线程竞争非常短的情况下。

如果要理解轻量级锁,那么必须先要了解 HotSpot 虚拟机中对象头的内存布局。上面介绍 Java 对象头也详细介绍过。在对象头中(Object Header)存在两部分。第一部分用于存储对象自身的运行时数据,HashCodeGC Age锁标记位是否为偏向锁。等。一般为 32 位或者 64 位(视操作系统位数定)。官方称之为 Mark Word,它是实现轻量级锁和偏向锁的关键。 另外一部分存储的是指向方法区对象类型数据的指针(Klass Point),如果对象是数组的话,还会有一个额外的部分用于存储数据的长度。

轻量级锁加锁

在线程执行同步块之前,JVM 会先在当前线程的栈帧中创建一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的 Mark Word 的拷贝(JVM 会将对象头中的 Mark Word 拷贝到锁记录中,官方称为 Displaced Mark Ward)这个时候线程堆栈与对象头的状态如图:

img

如上图所示:如果当前对象没有被锁定,那么锁标志位为 01 状态,JVM 在执行当前线程时,首先会在当前线程栈帧中创建锁记录 Lock Record 的空间用于存储锁对象目前的 Mark Word 的拷贝。

然后,虚拟机使用 CAS 操作将标记字段 Mark Word 拷贝到锁记录中,并且将 Mark Word 更新为指向 Lock Record 的指针。如果更新成功了,那么这个线程就拥用了该对象的锁,并且对象 Mark Word 的锁标志位更新为(Mark Word 中最后的 2bit)00,即表示此对象处于轻量级锁定状态,如图:

img

如果这个更新操作失败,JVM 会检查当前的 Mark Word 中是否存在指向当前线程的栈帧的指针,如果有,说明该锁已经被获取,可以直接调用。如果没有,则说明该锁被其他线程抢占了,如果有两条以上的线程竞争同一个锁,那轻量级锁就不再有效,直接膨胀为重量级锁,没有获得锁的线程会被阻塞。此时,锁的标志位为 10.Mark Word 中存储的指向重量级锁的指针。

轻量级解锁时,会使用原子的 CAS 操作将 Displaced Mark Word 替换回到对象头中,如果成功,则表示没有发生竞争关系。如果失败,表示当前锁存在竞争关系。锁就会膨胀成重量级锁。两个线程同时争夺锁,导致锁膨胀的流程图如下:

img

因为自旋会消耗 CPU,为了避免无用的自旋(比如获得锁的线程被阻塞住了),一旦锁升级成重量级锁,就不会再恢复到轻量级锁状态。当锁处于这个状态下,其他线程视图获取锁时,都会被阻塞住,当持有锁的线程释放锁之后会唤醒这些线程,被唤醒的线程就会进行新一轮的夺锁之争。

工作原理:

  • 当一个线程第一次尝试获取锁时,JVM 会在对象的 锁记录 中保存线程的信息(即锁标识符)。如果没有其他线程竞争,线程会直接获得锁。
  • 如果该对象已经被其他线程持有,JVM 会让当前线程 自旋等待,如果自旋成功,则会继续执行。如果自旋失败,则会升级为重量锁。

优点:

  • 当只有一个线程持有锁时,性能几乎不受影响。
  • 减少了线程上下文切换的开销。

缺点:

  • 线程竞争时的性能较低,可能导致 CPU 浪费。
  • 适用于线程竞争较少的场景。

轻量锁示例:

public class LightweightLockExample {
    // 定义一个静态的锁对象,所有线程将竞争这个锁
    private static Object lock = new Object();

    public static void main(String[] args) throws InterruptedException {
        // 创建线程 t1,它尝试获取锁并执行同步代码块
        Thread t1 = new Thread(() -> {
            // 使用 synchronized 关键字获取锁
            synchronized (lock) {
                // 当线程 t1 获取到锁后,执行以下代码
                System.out.println("Thread 1 holds the lock.");

                // 模拟任务执行,线程 t1 休眠 100 毫秒
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    // 如果线程在休眠期间被中断,打印异常信息
                    e.printStackTrace();
                }
            }
            // 同步代码块执行完毕后,锁会自动释放
        });

        // 创建线程 t2,它同样尝试获取锁并执行同步代码块
        Thread t2 = new Thread(() -> {
            // 使用 synchronized 关键字获取锁
            synchronized (lock) {
                // 当线程 t2 获取到锁后,执行以下代码
                System.out.println("Thread 2 holds the lock.");
            }
            // 同步代码块执行完毕后,锁会自动释放
        });

        // 启动线程 t1
        t1.start();
        // 启动线程 t2
        t2.start();

        // 使用 join() 方法等待线程 t1 和 t2 执行完毕
        t1.join();
        t2.join();

        // 主线程在所有子线程执行完毕后继续执行
        System.out.println("Main thread finished.");
    }
}

在上面的例子中,lock 对象初始是轻量锁。线程 t1 先获取锁并在执行时进行了睡眠,线程 t2 会在 t1 释放锁之前自旋,最终成功获得锁。

重量锁(Heavyweight Locking)

重量锁通常也叫做互斥锁,它是最传统的锁机制。当线程竞争激烈时,JVM 会将轻量锁升级为重量锁,以此保证线程安全。重量锁依赖于操作系统的 互斥量(mutex) 来实现同步。

工作原理:

  • 当线程在获取轻量锁时,如果发生竞争,会导致锁的升级。
  • 锁的升级会引发阻塞操作,即当前线程会被挂起,直到能够获得锁。
  • JVM 会使用 操作系统的原子操作 来控制锁的获取。

优点:

  • 适用于竞争非常激烈的场景,能够保证线程安全。
  • 通过操作系统的调度机制来处理锁竞争,具有较强的可靠性。

缺点:

  • 锁的升级会带来线程上下文切换的开销,导致性能下降。
  • 如果发生频繁的锁竞争,会严重影响程序性能。

重量锁示例:

public class HeavyweightLockExample {
    // 定义一个静态的锁对象,所有线程将竞争这个锁
    // 由于锁对象是静态的,因此它在类加载时就被创建,并且所有线程共享同一个锁
    private static final Object lock = new Object();

    public static void main(String[] args) throws InterruptedException {
        // 创建多个线程,模拟高并发竞争
        // threadCount 表示要创建的线程数量
        int threadCount = 100;
        Thread[] threads = new Thread[threadCount];

        // 启动多个线程来竞争锁
        for (int i = 0; i < threadCount; i++) {
            // 创建线程,每个线程都会执行相同的任务
            threads[i] = new Thread(() -> {
                // 使用 synchronized 关键字来获取锁
                // 只有获取到锁的线程才能进入同步块
                synchronized (lock) {
                    // 模拟一些任务的执行,休眠一段时间让线程持有锁更长
                    try {
                        // 每个线程持有锁100毫秒
                        Thread.sleep(100);
                    } catch (InterruptedException e) {
                        // 如果线程在休眠期间被中断,打印异常堆栈信息
                        e.printStackTrace();
                    }
                    // 线程获取锁后打印当前线程的名称
                    System.out.println(Thread.currentThread().getName() + " holds the lock.");
                }
                // 同步块结束后,线程释放锁,其他线程可以竞争获取锁
            });
            // 启动线程
            threads[i].start();
        }

        // 等待所有线程执行完毕
        // 使用 join() 方法确保主线程等待所有子线程执行完毕后再继续执行
        for (Thread t : threads) {
            t.join();
        }

        // 所有线程执行完毕后,打印结束信息
        System.out.println("All threads finished.");
    }
}
  • 多个线程竞争锁:这个示例创建了 100 个线程(可以调整 threadCount 的值),它们都尝试获取相同的 lock 锁。通过增加线程数,我们增加了锁竞争的情况。
  • Thread.sleep():每个线程在获取锁后,都会休眠 100 毫秒。这样做的目的是让每个线程持有锁的时间较长,从而增加了锁的持有时间,促使锁从轻量级锁升级为重量级锁。
  • 锁的升级:在高并发情况下,当多个线程竞争同一个锁并且线程持有锁的时间较长时,Java 会将该锁从轻量级锁升级为重量级锁。这意味着如果有线程已经持有该锁,其他线程将需要进入 阻塞状态,而不是像轻量级锁那样通过自旋的方式竞争锁。重量级锁会导致 线程阻塞上下文切换,这就带来了性能损失。

总结

锁类型解释特点适用场景
无锁通过原子操作(如 CAS)避免加锁,避免线程同步开销。- 无锁操作是通过硬件支持的原子操作实现的。
- 没有锁的开销。
- 适用于高并发、读多写少的场景。
偏向锁一种优化的锁机制,当线程访问锁时,会首先尝试将锁标记为偏向当前线程。- 适用于单线程长时间持有锁的场景。
- 在没有竞争的情况下,锁不会发生竞争。
- 当一个线程多次访问同一个锁时,性能提升明显。
轻量锁线程在进入临界区时,采用自旋锁的方式避免阻塞,只有当竞争激烈时,才会升级为重量锁。- 线程尝试使用自旋来竞争锁,只有在锁竞争激烈时才会升级为重量锁。- 适用于竞争较轻的场景。
重量锁线程在竞争激烈时,采用传统的阻塞锁机制,性能开销较大。- 线程需要进入内核态来进行锁操作,性能开销较大。
- 使用操作系统的互斥量来实现。
- 适用于高竞争场景,锁的持有时间较长。
  • 无锁:完全避免锁的开销,适合高并发场景。

  • 偏向锁:适用于单线程持有锁的场景,可以减少不必要的竞争。

  • 轻量锁:适用于锁竞争较小的场景,使用自旋减少线程切换的开销。

  • 重量锁:用于锁竞争激烈的场景,牺牲性能以确保正确性。
    |
    |------------|-------------------------------------------------------------|--------------------------------------------------------------|----------------------------------------|
    | 无锁 | 通过原子操作(如 CAS)避免加锁,避免线程同步开销。 | - 无锁操作是通过硬件支持的原子操作实现的。
    - 没有锁的开销。 | - 适用于高并发、读多写少的场景。 |
    | 偏向锁 | 一种优化的锁机制,当线程访问锁时,会首先尝试将锁标记为偏向当前线程。 | - 适用于单线程长时间持有锁的场景。
    - 在没有竞争的情况下,锁不会发生竞争。 | - 当一个线程多次访问同一个锁时,性能提升明显。 |
    | 轻量锁 | 线程在进入临界区时,采用自旋锁的方式避免阻塞,只有当竞争激烈时,才会升级为重量锁。 | - 线程尝试使用自旋来竞争锁,只有在锁竞争激烈时才会升级为重量锁。 | - 适用于竞争较轻的场景。 |
    | 重量锁 | 线程在竞争激烈时,采用传统的阻塞锁机制,性能开销较大。 | - 线程需要进入内核态来进行锁操作,性能开销较大。
    - 使用操作系统的互斥量来实现。 | - 适用于高竞争场景,锁的持有时间较长。 |

  • 无锁:完全避免锁的开销,适合高并发场景。

  • 偏向锁:适用于单线程持有锁的场景,可以减少不必要的竞争。

  • 轻量锁:适用于锁竞争较小的场景,使用自旋减少线程切换的开销。

  • 重量锁:用于锁竞争激烈的场景,牺牲性能以确保正确性。


http://www.kler.cn/a/547332.html

相关文章:

  • Vue2官网教程查漏补缺学习笔记 - Part2深入了解组件 - 4插槽5动态组件异步组件6处理边界情况
  • 面试经典150题——位运算
  • 零基础学习人工智能
  • 2024问题总结
  • Redis 04章——持久化
  • SPA 收入支出/技师提成自动统计系统——东方仙盟
  • 红黑树:高效平衡二叉树的奥秘
  • Unity嵌入到Winform
  • shell命令脚本(2)——条件语句
  • Ansys Zemax | 使用衍射光学器件模拟增强现实 (AR) 系统的出瞳扩展器 (EPE):第 1 部分
  • 跨平台键鼠共享免费方案--Deskflow!流畅体验用MacBook高效控制Windows设备
  • Java 多线程编程与单例模式
  • 【C语言】程序环境与预处理
  • C++模拟实现二叉搜索树
  • 「软件设计模式」桥接模式(Bridge Pattern)
  • 基于JavaWeb开发的Java+Spring+vue+element实现旅游信息管理平台系统
  • CF 137B.Permutation(Java 实现)
  • CAS单点登录(第7版)20.用户界面
  • 【SLAM】在 ubuntu 18.04 arm 中以ROS环境编译与运行ORB_SLAM3
  • 网络安全防护:开源WAF雷池SafeLine本地部署与配置全流程