第 2 章 多线程的并发问题(随笔)

1. 初识并发问题

并发问题在我们现实生活中普遍存在,一个典型的例子就是抢票问题,例如多个人同时抢票,那么系统如何处理才能将票合理的分配给抢票用户?来看代码例子

public class TestDemo implements Runnable{
    private int tickets=10;
    boolean flag=true;  //线程停止标志位,外部停止线程方式
    @Override
    public void run() {
        while(flag) {
            try {
                buyTickets();
            } catch (Exception e) {
                throw new RuntimeException(e);
            }
        }
    }
    private void buyTickets() throws InterruptedException {
        if (tickets <= 0){
            flag=false;
            return;
        }
        Thread.sleep(200);
        System.out.println(Thread.currentThread().getName() + "抢到了第" + tickets-- + "张票");

    }
    public static void main(String[] args) {
        TestDemo ticket=new TestDemo();
        Thread t1=new Thread(ticket,"小何"); //3个线程同时抢票
        Thread t2=new Thread(ticket,"小胡");
        Thread t3=new Thread(ticket,"Cory");
        t1.start();
        t2.start();
        t3.start();
    }
}
结果:
小胡抢到了第9张票
Cory抢到了第10张票
小何抢到了第10张票
Cory抢到了第7张票
小胡抢到了第8张票
小何抢到了第6张票
小胡抢到了第5张票
Cory抢到了第4张票
小何抢到了第3张票
Cory抢到了第2张票
小何抢到了第2张票
小胡抢到了第2张票
小何抢到了第1张票
Cory抢到了第1张票
小胡抢到了第0张票

如果不做处理,很明显就会出现多个人抢到同一张票的情况,这就是并发问题。

在程序中什么是并发?

并发:多个线程同时操作同一个对象

2. 多线程并发问题解决方法

如何解决并发问题呢?通常的做法就是线程同步,线程同步其实是一种等待机制,多个需要访问此对象的线程进入这个对象的等待池形成队列,等待前面线程使用完毕,下一个线程再使用。

线程同步形成的条件:队列+锁,这种机制目的是为了保证线程的安全性。

2.1 使用synchronized关键字

在Java中由于同一个进程的多个线程共享同一块存储空间,在带来方便的同时,也带来了访问冲突问题,为了保证数据在方法中被访问的时的正确性,通常在访问时加入锁机制Synchronized,当一个线程获得对象的排他锁,会独占资源,其它线程必须等待,直到该线程释放锁,其他线程获得锁才能继续执行。

有小伙伴可能会问,synchronized锁的是什么?

1. 如果修饰一个代码块,被修饰的代码块称为同步语句块,其作用的范围是大括号{}括起来的代码,作用的对象是调用这个代码块的对象;
2. 如果修饰一个方法,被修饰的方法称为同步方法,其作用的范围是整个方法,作用的对象是调用这个方法的对象;
3. 如果修饰一个静态的方法,其作用的范围是整个静态方法,作用的对象是这个类的所有对象;
4. 如果修饰一个类,其作用的范围是synchronized后面括号括起来的部分,作用主的对象是这个类的所有对象。

2.1.1 synchronized同步方法

上文的线程不安全例子可用Synchronized修饰一个方法解决,代码例子如下:

public class TestDemo implements Runnable{
    private int tickets=10;
    boolean flag=true;
    @Override
    public void run() {
        while(flag) {
            try {
                buyTickets();
                //模拟延时
                Thread.sleep(200);
            } catch (Exception e) {
                throw new RuntimeException(e);
            }
        }
    }
    //synchronized同步buyTickets方法,所有线程执行buyTickets方法需先排队
    //拿TestDemo对象的锁(也可以用this表示本类对象)
    private synchronized void  buyTickets() {
        if (tickets <= 0){
            flag=false;
            return;
        }
        System.out.println(Thread.currentThread().getName() + "抢到了第" + tickets-- + "张票");

    }
    public static void main(String[] args) {
        TestDemo ticket=new TestDemo();
        Thread t1=new Thread(ticket,"小何"); //3个线程同时抢票,同一个对象
        Thread t2=new Thread(ticket,"小胡");
        Thread t3=new Thread(ticket,"cory");
        t1.start();
        t2.start();
        t3.start();
    }
}

结果:
小何抢到了第10张票
cory抢到了第9张票
小胡抢到了第8张票
小何抢到了第7张票
小胡抢到了第6张票
cory抢到了第5张票
小何抢到了第4张票
小胡抢到了第3张票
cory抢到了第2张票
小何抢到了第1张票

上例中3个线程操作同一个对象ticket,我们给buyTickets方法加synchronized,其锁的对象是调用

这个方法的对象,即对象ticket,因此当多个线程操作对象ticket时会先进入synchronized队列排队拿锁,拿到锁的线程执行buyTickets方法,执行完成之后释放锁等待下一个线程拿锁执行,这样就保证了线程安全。

但需要注意的是,对于多个线程操作不同的对象,上例给方法加synchronized的方式无法保证不同对象之间的同步,例如:

public class TestDemo implements Runnable{
    private int tickets=10;
    boolean flag=true;

    @Override
    public void run() {
        while(flag) {
            try {
                buyTickets();
                //模拟延时
                Thread.sleep(200);
            } catch (Exception e) {
                throw new RuntimeException(e);
            }
        }
    }
    //synchronized同步buyTickets方法,但此时是不同的对象调用buyTickets方法,
    //所以这个地方3个线程分别有3个不同的对象,每个对象都有自己的锁,线程之间互不干扰
    private synchronized void buyTickets() {
        if (tickets <= 0){
            flag=false;
            return;
        }
        System.out.println(Thread.currentThread().getName() + "抢到了第" + tickets-- + "张票");

    }
    public static void main(String[] args) {
        TestDemo ticket=new TestDemo();
        TestDemo ticket2=new TestDemo();
        TestDemo ticket3=new TestDemo();
        Thread t1=new Thread(ticket,"小何"); //3个线程同时抢票,3个不同的对象
        Thread t2=new Thread(ticket2,"小胡");
        Thread t3=new Thread(ticket3,"cory");
        t1.start();
        t2.start();
        t3.start();
    }
}

结果:
小何抢到了第10张票
小胡抢到了第10张票
cory抢到了第10张票
...
小何抢到了第1张票
小胡抢到了第1张票
cory抢到了第1张票

这是因为,上例中调用ticket,ticket1,ticket2这3个对象分别对应3把不同的锁,而这3把锁是互不干扰的,不形成互斥,所以上面3个线程可以同时执行。

如果想多个线程操作不同的对象还能达到同步效果,一种思路是将共享资源部分的变量和方法定义为static类型,将上例中的buyTickets和ticket变量定义为static,再加synchronized来达到同步效果(思考下为什么这样可以达到同步?这种方式相当于类锁),来看代码例子:

package ThreadTest;

public class TestDemo implements Runnable{
    //将变量定义成static
    private  static int tickets=10;
    static boolean flag=true;

    @Override
    public void run() {
        while(flag) {
            try {
                buyTickets();
                //模拟延时
                Thread.sleep(200);
            } catch (Exception e) {
                throw new RuntimeException(e);
            }
        }
    }
    //将方法定义成静态方法再加synchronized同步
    private static synchronized void  buyTickets() {
        if (tickets <= 0){
            flag=false;
            return;
        }
        System.out.println(Thread.currentThread().getName() + "抢到了第" + tickets-- + "张票");

    }

    public static void main(String[] args) {
        TestDemo ticket=new TestDemo();
        TestDemo ticket2=new TestDemo();
        TestDemo ticket3=new TestDemo();
        Thread t1=new Thread(ticket,"小何"); //3个线程同时抢票,3个不同的对象
        Thread t2=new Thread(ticket2,"小胡");
        Thread t3=new Thread(ticket3,"cory");

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

结果:
小何抢到了第10张票
小胡抢到了第9张票
cory抢到了第8张票
cory抢到了第7张票
小胡抢到了第6张票
小何抢到了第5张票
小何抢到了第4张票
cory抢到了第3张票
小胡抢到了第2张票
小胡抢到了第1张票
2.1.2 synchronized同步代码块

上文中的例子都是给方法加Synchronized来解决并发问题,这样虽然能解决多线程操作同一资源时线程的同步问题,但给方法加锁粒度较大,在某些场景下实际上会造成资源浪费。例如,A线程同步的方法需要执行一个长时间的任务,B线程只需要访问该方法,由于A线程先拿到锁,那么B线程就必须等待比较长的时间直到A线程释放锁才能执行,如下图所示:

很明显这种方式在程序执行中效率很低,如何解决这个问题呢?这种情况可以使用synchronized只同步相关的代码块去优化代码执行时间,也就是通常所说的减少锁的粒度

具体做法是我们只需要对修改的代码块加锁即可,这样就需要用到synchronized同步块。

同步块写法:synchronized(Obj){...}

再看一个常见的取款的例子:多个线程操作同一个账户。

public class TestBank {
        public static void main(String[] args){
            Account account=new Account(100,"新西兰旅游基金");
            Withdraw hhfounder=new Withdraw(account,50,"hhfounder");
            Withdraw cory=new Withdraw(account,100,"cory");
            hhfounder.start();
            cory.start();
        }
}
//账户
class Account {
    int money;
    String accountName;
    public Account(int money, String accountName){
        this.money=money;
        this.accountName=accountName;
    }
}
//取钱
class Withdraw extends Thread{
        //账户
        Account account;
        int drawMoney;
        public Withdraw(Account account,int drawMoney,String name){
            super(name);
            this.account=account;
            this.drawMoney=drawMoney;
        }
        @Override
        public void run() {
            System.out.println(account.accountName+"初始余额为:"+account.money+"万");
            System.out.println(Thread.currentThread().getName()+"准备取"+drawMoney+"万");
                try {
                    Thread.sleep(200);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                if(account.money-drawMoney<0){
                    System.out.println(Thread.currentThread().getName()+"来取余额不足");
                    return;
                }
                account.money =account.money-drawMoney;
                System.out.println(Thread.currentThread().getName()+"取完"+account.accountName+"账户余额为:"+account.money+"万");

            }
}
结果:
新西兰旅游基金初始余额为:100万
新西兰旅游基金初始余额为:100万
hhfounder准备取50万
cory准备取100万
hhfounder取完新西兰旅游基金账户余额为:-50万
cory取完新西兰旅游基金账户余额为:-50万

很明显如果不加任何保护则程序会出现线程冲突的问题,那么应该如何解决呢,如果给run()方法加synchronized很明显不能起到同步的作用(不同的对象),那么这个时候就需要用到同步块了,给指定的代码块加锁。代码例子如下:

public class TestBank {
        public static void main(String[] args){
            Account account=new Account(100,"新西兰旅游基金");
            Withdraw hhfounder=new Withdraw(account,50,"hhfounder");
            Withdraw cory=new Withdraw(account,100,"cory");
            hhfounder.start();
            cory.start();
        }
}
//账户
class Account {
    int money;
    String accountName;
    public Account(int money, String accountName){
        this.money=money;
        this.accountName=accountName;
    }
}
//取钱
class Withdraw extends Thread{
        Account account;
        int drawMoney;
        public Withdraw(Account account,int drawMoney,String name){
            super(name);
            this.account=account;
            this.drawMoney=drawMoney;
        }
        @Override
        public void run() {
            System.out.println(account.accountName+"初始余额为:"+account.money+"万");
            System.out.println(Thread.currentThread().getName()+"准备取"+drawMoney+"万");
            //同步代码块,锁定变化的量account
            synchronized (account){
                try {
                    Thread.sleep(2000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                if(account.money-drawMoney<0){
                    System.out.println(Thread.currentThread().getName()+"来取余额不足");
                    return;
                }
                account.money =account.money-drawMoney;
                System.out.println(Thread.currentThread().getName()+"取完"+account.accountName+"账户余额为:"+account.money+"万");

            }
    }
}
结果:
新西兰旅游基金初始余额为:100万
新西兰旅游基金初始余额为:100万
cory准备取100万
hhfounder准备取50万
cory取完新西兰旅游基金账户余额为:0万
hhfounder来取余额不足

上例中 在run()方法中给指定的account对象加synchronized,那么即使是不同的对象在操作账户account时,都需要排队竞争account的锁,这样不仅达到了同步效果,而且锁的粒度很小。

上例中也可以改成类锁(什么是类锁请参考这篇博文Java中synchronized实现类锁的两种方式及原理解析_synchronized(.class)-CSDN博客,个人觉得写的挺好),此时类的对象的所有实例只能争抢同一把锁,但这种方法锁的粒度比较大。代码如下:

            //锁定TestBank.class
            synchronized (TestBank.class){
                try {
                    Thread.sleep(2000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                if(account.money-drawMoney<0){
                    System.out.println(Thread.currentThread().getName()+"来取余额不足");
                    return;
                }
                account.money =account.money-drawMoney;
                System.out.println(Thread.currentThread().getName()+"取完"+account.accountName+"账户余额为:"+account.money+"万");

            }

2.2 使用Lock锁

Java中还有一种方法来解决同步问题,那就是使用Lock锁,与synchronized 隐式加锁的方式不同,Lock需要显式的加锁来实现同步,使用时需手动获取锁和释放锁,可中断获取锁,超时获取锁,这种方式比synchronized关键字更加灵活。

对比synchronized关键字Lock锁
获取锁无超时时间,未获取到锁的线程则阻塞等待(占用cpu资源),且无法被中断非阻塞(需调用非阻塞式获取锁的方法),可以被中断,未获取到锁的线程则排队,可以自定义超时时间
共享锁不支持其中的读写锁ReadWriteLock支持
释放锁必须在当前代码块中,因为synchronized是以{}来锁住共享资源的以在任意位置释放锁(其他方法或者其他类)

Lock实际上是java.util.concurrent.locks中的一个接口,该接口定义了加锁,解锁等具体方法,大家可以看看源码。

public interface Lock {

    void lock();

    void lockInterruptibly() throws InterruptedException;

    boolean tryLock();

    boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
 
    void unlock();

    Condition newCondition();
}

实际使用中,大家通常都是使用Lock接口的实现类ReentrantLock(可重入锁,下一篇再解释什么是可重入锁)来对具体代码进行加锁,将上面取钱的例子改写成Lock锁,代码例子如下:

import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class TestBank {
        public static void main(String[] args){
            Account account=new Account(100,"新西兰旅游基金");
            Withdraw hhfounder=new Withdraw(account,50,"hhfounder");
            Withdraw cory=new Withdraw(account,100,"cory");
            hhfounder.start();
            cory.start();
        }
}
//账户
class Account {
    int money;
    String accountName;
    public Account(int money, String accountName){
        this.money=money;
        this.accountName=accountName;
    }
}
//取钱
class Withdraw extends Thread{
        //账户
        Account account;
        int drawMoney;
        //实例化一个ReentrantLock对象
        private Lock lock = new ReentrantLock();//ReentrantLock继承了Lock接口
        public Withdraw(Account account,int drawMoney,String name){
            super(name);
            this.account=account;
            this.drawMoney=drawMoney;
        }
        @Override
        public void run() {
            System.out.println(account.accountName+"初始余额为:"+account.money+"万");
            System.out.println(Thread.currentThread().getName()+"准备取"+drawMoney+"万");
            //调用lock方法给下面代码加锁,线程运行到此处拿到锁之后执行下面的代码
            //另一个线程则会阻塞等待前面的线程释放锁,lock加在此处比synchronized更加细粒度
            //lock.lock()放在try外面可以让没有拿到锁的线程无法访问try中的代码
            lock.lock();
                try {
                    try {
                        Thread.sleep(1000);
                    } catch (InterruptedException e) {
                        throw new RuntimeException(e);
                    }
                    if (account.money - drawMoney < 0) {
                        System.out.println(Thread.currentThread().getName() + "来取余额不足");
                        return;
                    }
                    account.money = account.money - drawMoney;
                    System.out.println(Thread.currentThread().getName() + "取完" + account.accountName + "账户余额为:" + account.money + "万");
                } catch (Exception e) {
                    e.printStackTrace();
                } finally {
                    //需要手动释放锁
                    lock.unlock();
                    //System.out.println(Thread.currentThread().getName() + "释放锁");
                }
    }
}
结果:
新西兰旅游基金初始余额为:100万
新西兰旅游基金初始余额为:100万
cory准备取100万
hhfounder准备取50万
cory取完新西兰旅游基金账户余额为:0万
hhfounder来取余额不足

由此可见,调用lock方法给代码加锁可以实现比synchronized更加细粒度的加锁操作,减少了线程开销,其中的lock()方法属于阻塞等待获取锁,当一个线程调用ReentrantLock对象中的lock()方法获取锁成功之后,该线程不可被手动中断,其他线程需要阻塞等待,如果想要创建可中断的锁,则需调用lockInterruptibly()方法。

lock.lockInterruptibly()

这里只讲了ReentrantLock锁的使用,关于ReentrantLock可重入锁原理和很多细节还没讲,感兴趣的童鞋可以看下一篇博文~

  • 5
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 2
    评论
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值