Java多线程编程——线程同步与线程安全问题及synchronized关键字

在多线程环境下,我们常常需要让多个线程同时去操作同一资源。在某些情况下,这种情形会导致程序的运行结果出现差错。专业上的,当多个线程在执行同一段代码的时候,每次的执行结果和单线程执行的结果都是一样的,不存在执行结果的二义性,就可以称作是线程安全的。具体的说,线程安全问题,其实是指多线程环境下对共享资源的访问可能会引起此共享资源的不一致性。线程安全问题多是由全局变量和静态变量引起的,当多个线程对共享数据只执行读操作,不执行写操作时,一般是线程安全的;当多个线程都执行写操作时,就需要考虑线程同步来解决线程安全问题。所谓线程同步,是指将操作共享数据的代码行作为一个整体,同一时间只允许一个线程执行,执行过程中其他线程不能参与执行。目的是为了防止多个线程访问一个数据对象时,对数据造成的破坏。线程同步其实是一种等待机制,多个需要同时访问此对象的线程进入这个对象的等待池形成队列,等待前面线程使用完毕,下一个线程才能去使用。这就好比我们现实生活中去食堂排队打饭,每个人一窝蜂涌在窗口肯定会引起混乱,排好队一个个来大家才能吃上饭。在Java中,实现线程同步的手段简单说,就是队列+锁。这一机制可以用synchronized关键字实现。下面我们举若干例子介绍synchronized的使用如何解决了线程安全的问题。

一、案例一:抢票——使用synchronized修饰方法形成同步方法

考虑这样一个场景,在售票站有10张票,现在来了3个人打算抢这10张票。我们使用Java编程模拟这一过程:

public class Main {
    public static void main(String[] args) {
        TicketStation ticketStation = new TicketStation(10);

        new Thread(ticketStation, "Alice").start();
        new Thread(ticketStation, "Bob").start();
        new Thread(ticketStation, "Cathy").start();
    }
}

class TicketStation implements Runnable {
    private int numberOfTicket;
    private boolean hasTicket;

    public TicketStation(int numberOfTicket) {
        this.numberOfTicket = numberOfTicket;
        hasTicket = true;
    }

    @Override
    public void run() {
        while (hasTicket) {
            buyTicket();
        }
    }

    private void buyTicket() {
        if (numberOfTicket <= 0) {
            hasTicket = false;
        } else {
            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println(Thread.currentThread().getName() + " buy ticket #" + numberOfTicket--);
        }
    }
}

程序的运行结果如下:

Bob buy ticket #10
Cathy buy ticket #9
Alice buy ticket #8
Alice buy ticket #7
Bob buy ticket #6
Cathy buy ticket #5
Alice buy ticket #4
Bob buy ticket #3
Cathy buy ticket #2
Alice buy ticket #1
Cathy buy ticket #-1
Bob buy ticket #0

我们惊讶的发现,结果的末尾,Cathy居然抢到了第0张票,Bob抢到了第-1张票,这显然出现了错误。具体的原因是,在3个线程同时运行的过程中,它们都先执行条件判断“numberOfTicket <= 0”是否成立,每个线程读取到的numberOfTicket都是1,所以条件成立。但是之后,Alice线程率先执行了“numberOfTicket--”,抢到了第1张票,并将剩余票数减为0。然后,Bob线程也去执行“numberOfTicket--”,即先读取到当前剩余票数为0,然后减1,变成了-1。类似的,Cathy线程最后执行“numberOfTicket--”,读取到当前剩余票数为-1,然后减1,变成了-2。综上所述,这程序是线程不安全的。 

为了解决这一问题,我们可以使用synchronized关键字对方法进行修饰,使之变为同步方法,例如

public synchronized void method(int arg) { ... }

被synchronized修饰的方法控制对“对象”的访问,每个对象对应一把锁,每个synchronized方法必须获得调用该方法的对象的锁才能执行,否则线程会阻塞。方法一旦执行,就独占该锁,直到该方法返回才释放锁,后面被阻塞的线程才能获得这个锁,继续执行。

针对上面抢票的例子,我们在buyTicket方法前加上了synchronized关键字,修改后的程序为:

public class Main {
    public static void main(String[] args) {
        TicketStation ticketStation = new TicketStation(10);

        new Thread(ticketStation, "Alice").start();
        new Thread(ticketStation, "Bob").start();
        new Thread(ticketStation, "Cathy").start();
    }
}

class TicketStation implements Runnable {
    private int numberOfTicket;
    private boolean hasTicket;

    public TicketStation(int numberOfTicket) {
        this.numberOfTicket = numberOfTicket;
        hasTicket = true;
    }

    @Override
    public void run() {
        while (hasTicket) {
            buyTicket();
            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }

    private synchronized void buyTicket() {
        if (numberOfTicket <= 0) {
            hasTicket = false;
        } else {
            System.out.println(Thread.currentThread().getName() + " buy ticket #" + numberOfTicket--);
        }
    }
}

程序的执行结果为:

Alice buy ticket #10
Cathy buy ticket #9
Bob buy ticket #8
Cathy buy ticket #7
Alice buy ticket #6
Bob buy ticket #5
Alice buy ticket #4
Bob buy ticket #3
Cathy buy ticket #2
Cathy buy ticket #1

此时,3个人排好队依次购票,就没有出现之前混乱的场面了。

细心的读者可能发现,修改后的程序中线程休眠的语句从buyTicket方法体中挪到了run方法中。这是出于什么考虑呢?假设我们依然将线程休眠的语句放在buyTicket方法体内,即

private synchronized void buyTicket() {
        if (numberOfTicket <= 0) {
            hasTicket = false;
        } else {
            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println(Thread.currentThread().getName() + " buy ticket #" + numberOfTicket--);
        }
    }

那么程序的运行结果很大可能是这样的:

Alice buy ticket #10
Alice buy ticket #9
Alice buy ticket #8
Alice buy ticket #7
Alice buy ticket #6
Alice buy ticket #5
Alice buy ticket #4
Alice buy ticket #3
Alice buy ticket #2
Alice buy ticket #1

即这10张票都被同一个人买走了!这是为什么呢?由于线程休眠时,调用对象不会释放锁,所以在休眠100ms时Alice线程依旧把持着锁(或者说占据着售票窗口),其它线程是没有机会进入buyTicket方法买票的。当Alice线程买好一张票后,buyTicket方法执行结束,Alice线程释放了锁。但是CPU依然让Alice线程执行run方法中的while判断,发现依然有票,于是获得锁又去执行buyTicket方法。在这极短暂的时间内,其它线程根本没有机会抢占到时间执行buyTicket方法。因此,为了让3个线程都有机会买到票,我们将线程休眠语句从同步方法buyTicket中拿了出来。于是,当一个线程在执行buyTicket方法买票时,其它线程没有机会也进入buyTicket方法购票;而当一个线程执行完毕buyTicket方法并释放锁后,该线程会休眠一段时间,这就给了其它线程机会来拿到锁,执行buyTicket方法进行购票。

二、案例二:取款——使用synchronized修饰代码块形成同步块。

使用synchronized关键字修饰方法存在着一个弊端:若将一个很大/复杂的方法声明为同步方法,那么它将大量霸占计算资源,严重影响程序运行效率。举个不是很文雅的例子,假如现在有5个人要排队上厕所,其中4个人只是尿尿(时间很短,不会长期占用厕所),1个人则是拉肚子(时间很长,会长期霸占厕所)。假如那一名拉肚子的朋友进入了厕所,关起了门(拿到锁),其它4个人本来一会就能完事,如今却要等上半天,憋的痛不欲生。这样子的安排显然不甚合理。所以,在实践中,我们会使用synchronized关键字仅修饰方法中的一小段程序,即修饰代码块。语法格式为:

synchronized (obj) { ... }

其中花括号内的为代码块,括号内的obj称为“同步监视器”,它可以是任何对象,但一般推荐是共享资源(即要在代码块中被修改的共享资源,若仅是读取则无需设立同步代码块)。

下面我们以模拟银行账户取款为例进行讲解。假设我们在银行内有一个账户,现在同时在手机银行和线下ATM机上执行取款操作,利用程序进行模拟如下:

public class Main {
    public static void main(String[] args) {
        Account account = new Account("Research Funding", 100);

        new WithdrawMoney(account, "APP", 50).start();
        new WithdrawMoney(account, "ATM", 100).start();
    }
}

class Account {
    private String name;
    private int money;

    public Account(String name, int money) {
        this.name = name;
        this.money = money;
    }

    public String getName() { return this.name; }
    public int getMoney() { return this.money; }
    public void setName(String name) { this.name = name; }
    public void setMoney(int money) { this.money = money; }
}

class WithdrawMoney extends Thread {
    private Account account;
    private int moneyToDraw;

    public WithdrawMoney(Account account, String threadName, int moneyToDraw) {
        super(threadName);
        this.account = account;
        this.moneyToDraw = moneyToDraw;
    }

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

    private void withdraw() {
        if (account.getMoney() < moneyToDraw) {
            System.out.println(account.getName() + ": no enough money");
        } else {
            // It takes a while to withdraw money
            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }

            account.setMoney(account.getMoney() - moneyToDraw);
            System.out.println(Thread.currentThread().getName() + ": draw money " + moneyToDraw);
            System.out.println(account.getName() + ": balance is " + account.getMoney());
        }
    }
}

程序的运行结果可能是:

ATM: draw money 100
Research Funding: balance is 0
APP: draw money 50
Research Funding: balance is -50

显然,银行账户内的余额不应该是-50。出行这种现象的原因是,两个线程都先后执行withdraw中的if判断,此时都读取到当前账户余额为100,并不低于待取金额,因此打算执行else中的语句。然而,ATM线程先执行该语句块,将钱全部取出,于是账户余额清零。接着APP线程也过来执行这一段话,于是取走50后账户余额就变成了-50。显然,这是线程不安全的实现。

假如,我们跟之前一样在withdraw方法前添加synchronized关键字,并不会解决线程不安全的问题。因为synchronized方法默认的同步监视器(或者说共享资源)是this(或者说是这个类的对象自己)。在本例中,我们的共享资源是Account类对象,而非WithdrawMoney类对象。在上面的程序中,我们创建了两个WithdrawMoney线程对象,两者是相互独立的。就算用synchronized修饰了withdraw方法,那么APP线程锁住跟ATM线程锁住没有一点关系,APP线程获得锁并不妨碍ATM线程执行它自己的withdraw方法。于是,两个线程还是能同时操作Account对象。因此,我们就需要在withdraw方法中使用同步语句块。当一个线程中操作account时,其它线程就只能排队等待,这个逻辑才是正确的。

public class Main {
    public static void main(String[] args) {
        Account account = new Account("Research Funding", 100);

        new WithdrawMoney(account, "APP", 50).start();
        new WithdrawMoney(account, "ATM", 100).start();
    }
}

class Account {
    private String name;
    private int money;

    public Account(String name, int money) {
        this.name = name;
        this.money = money;
    }

    public String getName() { return this.name; }
    public int getMoney() { return this.money; }
    public void setName(String name) { this.name = name; }
    public void setMoney(int money) { this.money = money; }
}

class WithdrawMoney extends Thread {
    private Account account;
    private int moneyToDraw;

    public WithdrawMoney(Account account, String threadName, int moneyToDraw) {
        super(threadName);
        this.account = account;
        this.moneyToDraw = moneyToDraw;
    }

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

    private void withdraw() {
        synchronized (account) {
            if (account.getMoney() < moneyToDraw) {
                System.out.println(account.getName() + ": no enough money");
            } else {
                // It takes a while to withdraw money
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }

                account.setMoney(account.getMoney() - moneyToDraw);
                System.out.println(Thread.currentThread().getName() + ": draw money " + moneyToDraw);
                System.out.println(account.getName() + ": balance is " + account.getMoney());
            }
        }
    }
}

这时,程序的运行结果为

APP: draw money 50
Research Funding: balance is 50
Research Funding: no enough money
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值