Java并发—synchronized关键字的应用

目录

1、synchronized适用场景

2、synchronized的原理

3、synchronized的锁升级

4、synchronized的注意事项

5、总结


synchronized 是 Java 中用于实现线程同步的关键字。它可以在方法级别或代码块级别使用,以确保同一时刻只有一个线程可以访问被同步的代码段。synchronized 通过内部锁机制来实现线程间的互斥访问

synchronized 关键字可以在方法级和代码块级使用:

①可以将 synchronized 用在方法级别上,这种情况下锁定的对象是当前对象(this)

public class MyClass {

    public synchronized void myMethod() {
        // synchronized 代码
    }

}

②也可以将 synchronized 关键字用在代码块级别上,这种情况下锁定的对象可以是当前对象(this),也可以是任意一个对象 

public class MyClass {

    private final Object lock = new Object(); // 定义一个对象作为锁

    public void myMethod() {
        synchronized (lock) {
            // synchronized 代码
        }
    }

}

🌰:多线程访问以下这段代码时,count结果会是多少呢?

public class SynchronizedTest implements Runnable{

    public static int count = 0;

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

    public void addCount(){
        int i = 0;
        while (i++ < 1000) {
            count++;
        }
    }

    public static void main(String[] args) throws Exception{
        SynchronizedTest obj = new SynchronizedTest();
        Thread t1 = new Thread(obj);
        Thread t2 = new Thread(obj);
        t1.start();
        t2.start();
        t1.join(); // 等待 t1 线程执行完毕
        t2.join(); // 等待 t2 线程执行完毕
        System.out.println(count); // 打印 count 的值
    }
}

逻辑很简单, 最终的count值一定是<2000,由于t1线程获取count的值为0,然后执行了+1操作,但还未同步至主内存,此时,t2获取主内存中count=0,然后也执行了+1操作,那最终的结果依然是count=1

为了保证count的值=2000,就需要用到synchronized关键字

1、synchronized适用场景

  • 多线程环境下需要保证代码块或方法的原子性

当需要确保一段代码在多线程环境中只被一个线程执行时,可以使用 synchronized。

  • 保证多个变量之间的一致性:

当需要保证多个变量之间的操作是一致的时候,可以使用 synchronized 来确保这些操作作为一个整体被执行。

  • 防止死锁:

虽然 synchronized 可能会导致死锁,但也可以通过合理的锁顺序和锁粒度设计来预防死锁。

  • 保证可见性和有序性:

synchronized 关键字还可以保证可见性和有序性,确保线程间的数据同步

上面都是一些很空泛的概念,有木有,因此还是需要结合🌰来深入了解synchronized

掌声欢迎👏👏👏,🌰登场!

在上文中Java并发—CAS的原理及应用场景-CSDN博客讲了利用CAS来实现抢票功能,没看过上文,也没关系,有兴趣可以看看,哈哈哈……

CAS操作多个变量时,会比较复杂,虽然保证多变量的一致性更新,但可能需要多次 CAS 操作和自旋重试,可能会消耗更多的 CPU 资源,那如果使用synchronized关键字实现上述功能呢


还是以之前的案例🌰:假设线程A和线程B,都在尝试购买不同活动的门票

public class Ticket {
    private int remainingTickets = 100; // 剩余票数
    private int eventId = 1; // 活动 ID

    public synchronized void buyTicket(int ticketsToBuy, int eventId) {
        if (remainingTickets >= ticketsToBuy) {
            this.eventId = eventId; // 更新活动 ID
            System.out.println(Thread.currentThread().getName() + " 成功购买 " + ticketsToBuy + " 张票");
            remainingTickets -= ticketsToBuy;
            System.out.println("剩余票数: " + remainingTickets);
        } else {
            System.out.println(Thread.currentThread().getName() + " 购票失败,剩余票数不足");
        }
    }
}

模拟抢票 

// 创建 TicketWithCAS 对象
TicketWithCAS ticketWithCAS = new TicketWithCAS();
 
// 创建线程模拟购票
Thread thread1 = new Thread(() -> { ticketWithCAS.buyTicket(10, 1);}, "Thread 1");
 
Thread thread2 = new Thread(() -> { ticketWithCAS.buyTicket(20, 2);}, "Thread 2");
 
Thread thread3 = new Thread(() -> { ticketWithCAS.buyTicket(30, 3);}, "Thread 3");
 
// 启动线程
thread1.start();
thread2.start();
thread3.start();

输出:

Thread 1 成功购买 10 张票
剩余票数: 90
Thread 2 成功购买 20 张票
剩余票数: 70
Thread 3 成功购买 30 张票
剩余票数: 40
最终剩余票数: 40
最终活动 ID: 3

Ticket 类中的 buyTicket 方法使用 synchronized 关键字来保证线程安全。这意味着在同一时刻只有一个线程可以执行 buyTicket 方法,从而保证了剩余票数和活动 ID 的一致性

使用 synchronized 更容易实现多变量的一致性,因为可以保证整个代码块的原子性

此处是用了synchronized锁住了buyTicket(),也可以synchronized锁住一个对象

 public synchronized void buyTicket(int ticketsToBuy, int eventId) {}

但使用synchronized会有些潜在的问题

(1)锁粒度过大:

整个 buyTicket 方法都被同步了,这意味着即使在执行方法中的非关键部分时,其他线程也无法访问该方法。这可能导致不必要的等待。

(2)性能问题:
当多个线程试图同时访问同一个 synchronized 方法时,它们必须等待获得锁。这会导致线程阻塞,从而降低系统的整体吞吐量。

(3)不可扩展性:
当并发需求增加时,由于 synchronized 锁的排他性,性能可能会迅速下降

那么如何优化呢?

使用更细粒度的锁:只同步关键部分,而不是整个方法。这样可以减少锁的竞争,提高并发性能。
使用 java.util.concurrent 包中的并发工具,例如: java.util.concurrent.locks.ReentrantLock 或 java.util.concurrent.atomic.AtomicInteger 等工具来减少锁的使用

public class Ticket {
    private final AtomicInteger remainingTickets = new AtomicInteger(100); // 剩余票数
    private int eventId = 1; // 活动 ID
    private final Lock lock = new ReentrantLock(); // 创建锁

    public void buyTicket(int ticketsToBuy, int eventId) {
        lock.lock();
        try {
            if (remainingTickets.get() >= ticketsToBuy) {
                this.eventId = eventId; // 更新活动 ID
                System.out.println(Thread.currentThread().getName() + " 成功购买 " + ticketsToBuy + " 张票");
                remainingTickets.addAndGet(-ticketsToBuy); // 使用原子操作更新剩余票数
                System.out.println("剩余票数: " + remainingTickets.get());
            } else {
                System.out.println(Thread.currentThread().getName() + " 购票失败,剩余票数不足");
            }
        } finally {
            lock.unlock();
        }
    }
}

优化说明:

  • 使用 ReentrantLock

        创建了一个 ReentrantLock 实例 lock,并在 buyTicket 方法中使用它来同步关键代码段。
使用 lock.lock() 和 lock.unlock() 来获取和释放锁。

  • 使用 AtomicInteger

        使用 AtomicInteger 替换了原始的 int 类型变量 remainingTickets,以避免显式的锁使用。
使用 get() 和 addAndGet() 方法来获取和更新剩余票数

什么时候synchronized会释放锁?

synchronized 锁会在多种情况下被释放,包括正常执行结束、异常结束、显式返回、显式中断、线程被中断以及线程挂起。这些机制确保了线程安全性和资源的正确释放。

public class LockReleaseExample {

    public static void main(String[] args) {
        SharedResource resource = new SharedResource();

        Thread thread1 = new Thread(() -> {
            resource.increment();
        });

        Thread thread2 = new Thread(() -> {
            resource.increment();
        });

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

    static class SharedResource {
        private int counter = 0;

        public synchronized void increment() {
            try {
                // 正常执行结束
                counter++;

                // 异常结束
                if (counter == 5) {
                    throw new RuntimeException("An exception occurred.");
                }

                // 显式返回
                if (counter == 3) {
                    return;
                }

                // 显式中断
                if (counter == 4) {
                    break; // 这里不能直接使用 break,因为这不是循环。但在实际应用中,你可以使用其他逻辑来模拟这种情况。
                }

                // 线程被中断
                if (counter == 2) {
                    Thread.sleep(1000); // 如果线程在这里被中断,锁会被释放
                }

                // 线程挂起
                if (counter == 1) {
                    wait(); // 调用 wait() 会使线程释放锁并进入等待状态
                }
            } catch (InterruptedException e) {
                // 处理中断异常
                e.printStackTrace();
            }
        }
    }
}

但是不会主动释放锁,就可能会造成死锁

死锁是指两个或多个线程互相等待对方持有的锁,从而导致所有线程都无法继续执行的状态

导致死锁的一些常见情况

(1)嵌套锁
如果一个线程持有了多个锁,并且在释放锁之前又尝试获取其他锁,而其他线程也在等待这些锁,就可能形成死锁

例如,线程 A 持有锁 X 并尝试获取锁 Y,而线程 B 持有锁 Y 并尝试获取锁 X

解决:尽量减少同时持有多个锁的情况,或者使用更高级的并发工具如 ReentrantLocktryLock 方法来尝试获取锁,并设置超时时间

(2)锁顺序不一致
如果多个线程按照不同的顺序获取锁,也可能导致死锁。

例如,线程 A 先获取锁 X 后获取锁 Y,而线程 B 先获取锁 Y 后获取锁 X。

解决:确保所有线程按照相同的顺序获取锁

(3)无限期等待
如果线程在获取锁时没有设置超时时间,可能会导致无限期等待,进而引发死锁

解决:使用 tryLock 方法尝试获取锁,并设置超时时间,如果在指定时间内未能获取到锁,则放弃尝试,或者使用 SemaphoreCyclicBarrier 等并发工具类来管理锁的获取和释放

🌰嵌套锁造成的死锁

public class DeadlockExample {

    private static final Object lock1 = new Object();
    private static final Object lock2 = new Object();

    public static void main(String[] args) {
        Thread thread1 = new Thread(() -> {
            synchronized (lock1) {
                System.out.println("Thread 1: Holding lock 1...");

                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }

                System.out.println("Thread 1: Waiting for lock 2...");
                synchronized (lock2) {
                    System.out.println("Thread 1: Holding lock 1 & lock 2...");
                }
            }
        });

        Thread thread2 = new Thread(() -> {
            synchronized (lock2) {
                System.out.println("Thread 2: Holding lock 2...");

                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }

                System.out.println("Thread 2: Waiting for lock 1...");
                synchronized (lock1) {
                    System.out.println("Thread 2: Holding lock 2 & lock 1...");
                }
            }
        });

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

在这个示例中,thread1 先获取 lock1,然后尝试获取 lock2;而 thread2 则先获取 lock2,然后尝试获取 lock1。由于两个线程都在等待对方释放锁,所以形成了死锁

那使用 ReentrantLock tryLock 方法,并设置了超时时间,这样即使无法获取锁,线程也不会陷入无限期等待,从而避免了死锁的发生

private static final ReentrantLock lock1 = new ReentrantLock();

boolean hasLock1 = lock1.tryLock(1, java.time.TimeUnit.SECONDS);

2、synchronized的原理

Synchronized的底层实现原理是完全依赖JVM虚拟机的

当对一段代码使用 synchronized 关键字修饰后,确实会绑定上对应锁对象的监视器对象。在 Java 中每个 Java 对象都对应一个监视器(Monitor)对象,该监视器对象中包含了一个计数器等待队列。下面我将详细解释这一过程

监视器对象(Monitor Object)

监视器对象是 Java 中用于实现同步的关键组件。当一个线程试图进入一个由 synchronized 修饰的代码块或方法时,它会尝试获取该代码块或方法所绑定的对象的监视器锁。监视器对象中包含以下主要组成部分:

(1)计数器:
记录了当前持有锁的线程的数量。当线程首次获取锁时,计数器设为 1;如果同一个线程多次获取锁,则计数器递增

(2)等待队列:
存储了等待获取锁的所有线程。当线程无法立即获取锁时,会被加入到等待队列中,等待锁的释放

获取和释放锁的过程

  • 获取锁:
    • 当一个线程试图进入一个由 synchronized 修饰的代码块或方法时,它会检查锁对象的监视器对象中的计数器。
    • 如果计数器为 0,表示锁未被任何线程持有,线程可以获取锁,并将计数器设为 1。
    • 如果计数器不为 0,表示锁已被其他线程持有,当前线程会被加入到等待队列中,直到锁被释放。
  • 释放锁:
    • 当持有锁的线程离开了 synchronized 代码块或方法时,它会释放锁。
    • 释放锁时,计数器递减。如果计数器变为 0,表示锁已完全释放,等待队列中的线程可以尝试获取锁

当使用 synchronized 关键字修饰一段代码时,该代码块会绑定到一个监视器对象。监视器对象中包含一个计数器和等待队列,用于跟踪锁的持有情况和等待获取锁的线程。通过这种方式,synchronized 关键字确保了线程之间的互斥访问,从而保证了数据的一致性和线程的安全性

3、synchronized的锁升级

为了提高性能,Java 虚拟机对 synchronized 锁进行了优化,引入了锁升级的概念。

锁升级是指 synchronized 锁从一种状态升级到另一种状态的过程,以适应不同的并发需求,从而减少锁带来的性能开销

例如:在这个例子中,increment 和 getCount 方法都是同步的。当多个线程尝试同时访问这些方法时,JVM 会根据锁升级的策略来决定使用哪种类型的锁

public class Counter {
    private int count = 0;

    public synchronized void increment() {
        count++;
    }

    public synchronized int getCount() {
        return count;
    }
}

锁升级的过程是从偏向锁到轻量级锁再到重量级锁。以下是锁升级的详细步骤:

无锁状态:

初始状态下,对象没有被任何线程锁定。

偏向锁

  • 当一个线程首次访问一个对象的同步代码块时,如果该对象之前没有被锁定过,JVM 将会尝试获取偏向锁。
  • 偏向锁是一种优化措施,它假设大多数情况下只有一个线程会访问同步代码块。
  • 偏向锁会将线程 ID 记录在对象的 Mark Word 中,表明该线程拥有偏向锁。
  • 如果后续请求锁的线程与当前持有偏向锁的线程相同,则可以直接进入同步代码块而无需进一步的同步操作。

轻量级锁

  • 当有第二个线程尝试获取锁时,偏向锁会被撤销,并升级为轻量级锁。
  • 轻量级锁仍然不需要操作系统级别的互斥锁,而是通过 CAS(Compare and Swap)操作来实现。
  • 如果 CAS 操作成功,则轻量级锁被持有;如果 CAS 失败,则会尝试自旋等待,直到锁可用。
  • 如果自旋等待超过一定次数,或者锁竞争加剧,轻量级锁将会升级为重量级锁。

重量级锁

  • 重量级锁是基于操作系统级别的互斥锁实现的,性能较低。
  • 当轻量级锁无法满足需求时(例如,自旋等待超过了阈值),就会升级为重量级锁。
  • 重量级锁使用 JVM 内部的 Monitor 对象来实现,这通常涉及到操作系统层面的线程阻塞和唤醒。

锁升级的好处

  • 减少锁的竞争:通过使用偏向锁和轻量级锁,可以减少锁的竞争,尤其是在只有一个线程访问的情况下。
  • 减少上下文切换:轻量级锁可以避免线程频繁地进行上下文切换,从而提高性能。
  • 减少操作系统级别的锁开销:轻量级锁和偏向锁都在 JVM 层面实现,避免了操作系统级别的锁带来的开销

锁升级的限制

  • 锁升级是单向的:锁的状态一旦升级,就不能降级。这意味着一旦锁升级到了重量级锁,它就不会再变回轻量级锁或偏向锁。
  • 锁升级的条件:锁升级的具体条件和行为取决于具体的 JVM 实现,但一般而言,当锁的竞争加剧时,锁会自动升级

4、synchronized的注意事项

使用 synchronized 时,需要注意以下几点:

  • synchronized 关键字只能用于方法和代码块内部,不能用于类和接口。

  • synchronized 锁定的对象是当前对象(this)或指定的对象,要注意锁对象不应该是一个字符串或者数字等常量,因为这样可能导致死锁情况。

  • synchronized 的开销很大,每次加锁和释放锁都需要进行系统调用,需要注意性能问题。

  • synchronized 仅能解决单 JVM 内的线程同步问题,对于多线程分布式环境,需要考虑分布式锁的解决方案

  •  使用synchronized锁时尽量缩小范围以保证性能。能不锁方法就不锁方法,推荐尽量使用synchronized代码块来降低锁的范围

5、总结

synchronized 关键字是 Java 中用来确保线程安全的基本机制,特别是在需要保证多个变量之间的一致性时。通过使用 synchronized,可以锁定一个对象,从而确保同一时刻只有一个线程可以访问该对象

可以将 synchronized 用于方法级别和代码块级别,要注意锁定的对象应该是一个合适的对象,不能是一个常量,使用 synchronized 可以简化并发控制逻辑,但需要注意其性能开销和潜在的死锁风险

  • 22
    点赞
  • 11
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值