Java多线程基础详解(三)

线程同步

接下来我将会通过一个简单的问题案例来为大家讲解线程同步相关的知识。

问题背景

多个用户同时操作一个银行账户。每次取款400元,取款前先检查余额是否足够。如果不够,放弃取款。

问题分析

  • 使用多线程实现,不同的线程模拟不同的用户
  • 因为多个线程共享同一个银行账户,使用实现Runnable接口的方式创建多线程

代码展示

/**
 * 银行账户类
 */
public class Account {

    //默认设置账初始余额为600
    private Integer balance = 600;

    public Account() {
    }

    public Account(Integer balance) {
        this.balance = balance;
    }

    //取款,每次只能取400
    public void withdrawal(){
        this.balance = this.balance -400;
    }

    //查看账户余额
    public int checkTheBalance(){
        return this.balance;
    }
}
/**
 * 取款线程类
 */
public class AccountRunnable implements Runnable{

    private Account account = new Account();

    @Override
    public void run() {


        //如果账户余额大于等于400,可以取款,否则余额不足,取款失败
        if(account.checkTheBalance() >= 400){
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            account.withdrawal();
            System.out.println(Thread.currentThread().getName()+"取款成功,账户剩余金额为:"+account.checkTheBalance());
        }else {
            System.out.println(Thread.currentThread().getName()+"取款失败,余额不足,账户剩余金额为:"+account.checkTheBalance());
        }

    }
}

/**
 * 测试类
 */
public class Test {
    public static void main(String[] args) {

        Runnable runnable = new AccountRunnable();

        Thread thread1 = new Thread(runnable);
        thread1.setName("thread1");
        Thread thread2 = new Thread(runnable);
        thread2.setName("thread2");

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


    }
}

运行结果:

thread2取款成功,账户剩余金额为:200
thread1取款成功,账户剩余金额为:200

这里解释一下为什么两个线程结果都是成功,而且剩余金额都是 200。

这是因为两个线程都可以进入临界区,同时取款,导致余额被重复取出,出现了并发问题。具体来说,线程1 和线程2 都读取了账户余额为 600,然后都进行了取款操作,每次取款操作将账户余额减去 400,最终账户余额被减到了 200。因此,最后输出的结果都是 200。这就是多线程并发问题的典型表现。

这有点丢失修改的味道了,感兴趣的可以搜一下丢失修改。

上面的情况属于丢失修改(Lost Update)问题。具体来说,多个线程同时对同一个银行账户进行取款操作,会导致并发问题,因为多个线程同时去更新账户余额,可能会覆盖掉其他线程的修改,从而导致账户余额不正确,违背了银行账户的业务规则。在上述情况中,多个线程都同时读取了账户余额为 600,然后进行了取款操作,每次取款操作将账户余额减去 400,最终账户余额被减到了 200,但是没有出现 -200 的情况,因为两个线程都是在同一时间读取账户余额为 600,没有读取到其他线程的修改。因此,这个问题属于丢失修改(Lost Update)问题。

如果注释掉Thread.sleep(),运行结果为:

thread1取款成功,账户剩余金额为:200
thread2取款失败,余额不足,账户剩余金额为:200

使用Thread.sleep()的目的在于模拟线程切换,当一个线程判断完余额后,不是立刻取款,而是让出CPU,这样另外一个线程获取CPU,并且进行余额的判断,这样两个线程都能通过余额判断完成取款操作,可是账户初始余额只够取款一次。

线程安全问题就这样产生了,如果想要保证线程安全,判断余额和取款的语句必须被一个线程执行完才能让另外一个线程执行。

解决方案

当多个线程访问同一个数据时,容易出现线程安全问,需要让线程同步,保证数据安全。

线程同步

当两个或两个以上线程访问同一资源时,需要某种方式来确保资源在某一时刻只被一个线程使用

线程同步实现方案
  • 同步代码块
    synchronized (obj){ }

  • 同步方法
    private synchronized void method(int amt) {}

  • Lock锁

方案一:使用同步代码块实现线程同步

只需要更改 AccountRunnable 类,更改后的代码如下:

/**
 * 取款线程类
 */
public class AccountRunnable implements Runnable{

    private Account account = new Account();

    @Override
    public void run() {
		//同步代码块
        synchronized (account){
            //如果账户余额大于等于400,可以取款,否则余额不足,取款失败
            if(account.checkTheBalance() >= 400){
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                account.withdrawal();
                System.out.println(Thread.currentThread().getName()+"取款成功,账户剩余金额为:"+account.checkTheBalance());
            }else {
                System.out.println(Thread.currentThread().getName()+"取款失败,余额不足,账户剩余金额为:"+account.checkTheBalance());
            }
        }

    }
}

运行结果:

thread1取款成功,账户剩余金额为:200
thread2取款失败,余额不足,账户剩余金额为:200
方案二:使用同步方法实现线程同步

只需要更改 AccountRunnable 类,更改后的代码如下:

/**
 * 取款线程类
 */
public class AccountRunnable implements Runnable{

    private Account account = new Account();

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

    //同步方法
    public synchronized void withdraw(){
        //如果账户余额大于等于400,可以取款,否则余额不足,取款失败
        if(account.checkTheBalance() >= 400){
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            account.withdrawal();
            System.out.println(Thread.currentThread().getName()+"取款成功,账户剩余金额为:"+account.checkTheBalance());
        }else {
            System.out.println(Thread.currentThread().getName()+"取款失败,余额不足,账户剩余金额为:"+account.checkTheBalance());
        }
    }
}

运行结果:

thread1取款成功,账户剩余金额为:200
thread2取款失败,余额不足,账户剩余金额为:200
synchronized关键字初步介绍

这里只是初步介绍,在后续文章中我会详细介绍,后续文章完成后我会将连接更新到这里。

synchronized 翻译成中文是同步的的意思,主要解决的是多个线程之间访问资源的同步性,可以保证被它修饰的方法或者代码块在任意时刻只能有一个线程执行。

synchronized加锁场景
  1. 作用于非静态方法,锁住的是对象实例(this),每一个对象实例有一个锁。方案二同步方法就是通过这种方式实现的。
public synchronized void method() {}
  1. 作用于静态方法,锁住的是类的 Class 对象,Class 对象全局只有一份,因此静态方法锁相当于类的一个全局锁,会锁所有调用该方法的线程。
public static synchronized void method() {}
  1. 作用于 Lock.class,锁住的是 Lock 的 Class 对象,也是全局只有一个。
synchronized (Lock.class) {}
  1. 作用于 this,锁住的是对象实例,每一个对象实例有一个锁。方案一同步代码块就是通过这种方式实现的。
synchronized (this) {}
synchronized (obj)  {}
  1. 作用于静态成员变量,锁住的是该静态成员变量对象,由于是静态变量,因此全局只有一个。
public static Object monitor = new Object(); 
synchronized (monitor) {}

大家有可能会搞混,但是其实很容易记,记住以下两点:

  1. 必须有“对象”来充当“锁”的角色。

  2. 对于同一个类来说,通常只有两种对象来充当锁:实例对象、Class 对象(一个类全局只有一份)。

Class 对象: 静态相关的都是属于 Class 对象,还有一种直接指定 Lock.class。

实例对象: 非静态相关的都是属于实例对象。

使用同步代码块,synchronized(obj)

关于obj需要注意以下几点内容:

  1. 必须是引用数据类型,不能是基本数据类型
  2. 在同步代码块中可以改变同步监视器对象的值,不能改变其引用
  3. 尽量不要String和包装类Integer做同步监视器,如果使用了,只要保证代码块中不对其进行任何操作也没有关系
  4. 一般使用共享资源做同步监视器即可
  5. 也可以创建一个专门的同步监视器,没有任何业务含义
  6. 建议使用final修饰同步监视器
使用同步方法,public synchronized void method() {}

需要注意以下几点内容:

  1. 不要将run()定义为同步方法
  2. 同步方法的同步监视器是this
  3. 同步代码块的效率要高于同步方法
  • 同步方法的锁是this,一旦锁住一个方法,就锁住了所有的同步方法;同步代码块只是锁住使用该同步监视器的代码块,而没有锁住使用其他监视器的代码块
  • 同步方法是将线程锁在了方法的外部,而同步代码块锁将线程锁在了代码块的外部,但是却是方法的内部

解释一下第3点

在Java中,同步方法和同步代码块都是用来实现线程同步的机制,它们都需要获取对象锁才能进入临界区。但是同步方法和同步代码块所锁住的对象不同,因此它们的作用范围也不同:

同步方法的锁是当前对象的实例,也就是this,一旦锁住一个同步方法,就锁住了当前对象的所有同步方法。这意味着,如果一个线程获取了当前对象的锁,它就可以访问该对象的所有同步方法,其他线程必须等待该线程释放锁之后才能访问这些方法。因此,同步方法的作用范围是当前对象的所有同步方法。

同步代码块使用的是一个同步监视器对象,也称为锁对象。同步代码块只锁住使用该锁对象的代码块,而没有锁住其他代码块。如果多个线程使用的是不同的锁对象,它们就可以并发地访问这些代码块,不会受到其他线程的影响。因此,同步代码块的作用范围是使用同一个锁对象的代码块。

需要注意的是,同步方法和同步代码块都是基于对象锁来实现的,而不是基于类锁。这意味着,每个对象都有自己的锁,不同的对象之间互不影响。因此,在多线程并发访问时,应该根据具体的业务需求选择使用同步方法或同步代码块,避免出现线程安全问题。同时,还应该注意锁的粒度,避免锁住过多的代码,影响程序的性能。

方案三:使用Lock锁实现线程同步

只需要更改 AccountRunnable 类,更改后的代码如下:

/**
 * 取款线程类
 */
public class AccountRunnable implements Runnable{
    private Account account = new Account();
    
    Lock lock = new ReentrantLock();//可重入锁

    @Override
    public void run() {

            try{
                lock.lock(); //上锁
                if(account.checkTheBalance() >= 400){
                    try {
                        Thread.sleep(1000);
                    } catch (InterruptedException e) {
                        throw new RuntimeException(e);
                    }
                    account.withdrawal();
                    System.out.println(Thread.currentThread().getName()+"取款成功,账户剩余金额为:"+account.checkTheBalance());
                }else {
                    System.out.println(Thread.currentThread().getName()+"取款失败,余额不足,账户剩余金额为:"+account.checkTheBalance());
                }

            }finally {
                lock.unlock(); //解锁
            }
    }
}
Lock锁初步介绍

这里只是初步介绍,在后续文章中我会详细介绍,后续文章完成后我会将连接更新到这里。

JDK1.5后新增功能,与采用synchronized相比,lock可提供多种锁方案,更灵活

Lock和syncronized的区别
  1. synchronized是Java语言的关键字,Lock是一个接口。
  2. synchronized不需要用户去手动释放锁,发生异常或者线程结束时自动释放锁;Lock则必须要用户去手动释放锁,如果没有主动释放锁,就有可能导致出现死锁现象。
  3. Lock可以配置公平策略,实现线程按照先后顺序获取锁。提供了trylock()方法 可以试图获取锁,获取到或获取不到时,返回不同的返回值让程序可以灵活处理。
  4. lock()和unlock()可以在不同的方法中执行,可以实现同一个线程在上一个方法中lock()在后续的其他方法中unlock(),比syncronized灵活的多。
  5. Lock只有代码块锁,synchronized有代码块锁和方法锁。
  6. Lock锁可以对读不加锁,对写加锁,synchronized不可以。
  7. Lock锁可以有多种获取锁的方式,可以从sleep的线程中抢到锁,synchronized不可以。
  8. 使用Lock锁,JVM将花费较少的时间来调度线程,性能更好;并且具有更好的扩展性(提供更多的子类)。
详细介绍 Lock和syncronized的区别

Java中的Lock和synchronized都是用于实现线程同步的机制,但是它们之间有以下几个区别:

  1. 锁的获取和释放方式不同
    synchronized关键字是隐式锁,在进入同步代码块或方法时自动获取锁,在退出同步代码块或方法时自动释放锁。而Lock是显式锁,需要手动获取锁和释放锁,分别通过lock()和unlock()方法实现。

  2. 锁的粒度不同
    synchronized关键字只能锁住整个方法或代码块,而不能对其中的一部分代码进行加锁。而Lock可以灵活地控制锁的粒度,可以只锁住代码中的一部分。例如,一个List中的某个元素需要更新,使用synchronized需要锁住整个List,而使用ReentrantLock可以只锁住该元素。

  3. 锁的可中断性不同
    使用synchronized时,如果一个线程正在执行同步代码块或方法,其他线程只能等待或阻塞,不能中断。而Lock提供了可中断的获取锁方式,即tryLock()和lockInterruptibly()方法,如果一个线程正在执行Lock的lockInterruptibly()方法,其他线程可以通过调用该线程的interrupt()方法来中断它。

  4. 锁的公平性不同
    synchronized关键字不保证线程的执行顺序,如果有多个线程在等待锁,当锁被释放后,哪个线程能够获取到锁是不确定的。而Lock提供了可重入锁(ReentrantLock)和公平锁(FairLock)两种方式,公平锁会按照线程的等待时间来获取锁,保证了线程的公平性,但是性能比可重入锁差。

  5. 锁的性能不同
    在竞争不激烈的情况下,synchronized的性能优于Lock,因为synchronized是由JVM内部实现的,而Lock需要通过Java代码实现。但是在竞争激烈的情况下,Lock的性能优于synchronized,因为它提供了更多的锁获取和释放方法,可以减少线程上下文切换的次数。
    总之,synchronized和Lock都可以用于实现线程同步,但是Lock提供了更多的控制和扩展性,适用于更复杂的多线程场景。但是,在并发度不高的情况下,synchronized可以提供更简单、更安全、更高效的同步机制。

代码案例(第2点)

下面是使用synchronized关键字实现的线程安全的List的例子,它使用synchronized关键字锁住整个方法:

import java.util.ArrayList;
import java.util.List;
public class SynchronizedList<E> {
    private List<E> list = new ArrayList<>();
    public synchronized boolean add(E element) {
        return list.add(element);
    }
    public synchronized E get(int index) {
        return list.get(index);
    }
    public synchronized int size() {
        return list.size();
    }
}

在上面的例子中,我们使用synchronized关键字锁住了add()、get()和size()方法,保证了它们的线程安全性。
下面是使用ReentrantLock实现的线程安全的List的例子,它使用ReentrantLock锁住List中的某个元素:

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class LockedList<E> {
    private List<E> list = new ArrayList<>();
    private Lock lock = new ReentrantLock();
    public boolean add(E element) {
        lock.lock();
        try {
            return list.add(element);
        } finally {
            lock.unlock();
        }
    }
    public E get(int index) {
        lock.lock();
        try {
            return list.get(index);
        } finally {
            lock.unlock();
        }
    }
    public int size() {
        lock.lock();
        try {
            return list.size();
        } finally {
            lock.unlock();
        }
    }
    public void update(int index, E element) {
        lock.lock();
        try {
            list.set(index, element);
        } finally {
            lock.unlock();
        }
    }
}

在上面的例子中,我们使用ReentrantLock锁住了add()、get()、size()和update()方法,其中update()方法只锁住了List中的某个元素,而不是整个List。使用ReentrantLock可以灵活地控制锁的粒度,提高代码的并发性能。

public void update(int index, E element) {
    synchronized(this) {
        try {
            list.set(index, element);
        } finally {
            // 这里可以添加一些清理操作,如果需要的话
        }
    }
}

当你在方法内部使用synchronized(this)来包围对list.set(index, element);的调用时,你是在同步对特定list的操作,而不是锁定整个对象或方法。但是,由于synchronized语句的特性,它只能锁定当前实例(this)或类的某个静态对象,不能直接锁定list中的某个元素。

如果你的目标是只锁定list中的特定元素,而允许其他元素并行更新,那么synchronized不适合这种细粒度的锁定。在这种情况下,ReentrantLock更为合适,因为你可以创建多个锁,每个对应list中的一个元素,从而只锁定和解锁特定的元素。

代码案例(第3点)

下面是一个使用ReentrantLock实现的可中断的线程安全队列的例子,使用lockInterruptibly()方法来获取锁。如果一个线程正在执行put()方
下面是一个简单的Java代码例子,演示了Lock的lockInterruptibly()方法如何在其他线程调用interrupt()方法时被中断:

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

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

    public void doSomething() throws InterruptedException {
        lock.lockInterruptibly();
        try {
            // 执行一些需要锁保护的操作
            // ...
        } finally {
            lock.unlock();
        }
    }

    public static void main(String[] args) throws InterruptedException {
        InterruptibleLockExample example = new InterruptibleLockExample();

        Thread thread1 = new Thread(() -> {
            try {
                example.doSomething();
            } catch (InterruptedException e) {
                System.out.println("Thread 1 is interrupted");
            }
        });

        Thread thread2 = new Thread(() -> {
            try {
                example.doSomething();
            } catch (InterruptedException e) {
                System.out.println("Thread 2 is interrupted");
            }
        });

        thread1.start();
        Thread.sleep(1000); // 确保thread1先获得锁
        thread2.start();
        Thread.sleep(1000); // 确保线程都进入等待状态
        thread2.interrupt(); // 中断thread2
    }
}

在上面的例子中,InterruptibleLockExample类有一个lock成员变量,该变量使用ReentrantLock实现。doSomething()方法获取锁,执行一些需要锁保护的操作,最后释放锁。这个方法可以响应中断,即其他线程可以通过调用Thread.interrupt()方法来中断它。

在main()方法中,创建了两个线程thread1和thread2,并分别启动它们。首先让thread1获取锁,然后等待1秒钟,再让thread2去获取锁。由于lock是不可重入锁,因此thread2会阻塞,一直等到thread1释放锁才能获取锁。

最后,等待1秒钟,确保线程都进入等待状态,然后调用thread2.interrupt()方法来中断thread2。由于thread2正在执行lockInterruptibly()方法,因此它可以被中断,并且会抛出InterruptedException异常。在catch块中,输出"Thread 2 is interrupted"的提示信息。

这里的 确保线程都进入等待状态 是什么意思?

在上面的Java代码例子中,通过调用Thread.sleep()方法来让程序等待一段时间。这个等待时间的目的是确保两个线程都进入了等待状态,即:

  1. 线程1已经获得了锁,并正在执行doSomething()方法。
  2. 线程2尝试获取锁,但由于锁被线程1持有,因此处于等待状态。

在不等待一段时间,直接调用thread2.interrupt()方法可能会导致线程2还没有进入等待状态就被中断,从而无法测试lockInterruptibly()方法响应中断的情况。

综上可以得出,使用 lockInterruptibly()方法 获取锁最大的好处就是可以调用对应线程的 interrupt() 方法,如 thread2.interrupt(),避免该线程一直等待。

  • 4
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

路上阡陌

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值