多线程学习-线程安全

文章探讨了多线程编程中可能出现的安全问题,如重复售卖电影票,通过静态共享变量、同步代码块和同步方法来确保线程安全。重点讲解了如何避免死锁,强调了正确使用锁和同步机制的重要性。
摘要由CSDN通过智能技术生成

目录

1.多线程可能会造成的安全问题

2. static共享变量

3.同步代码块

4.同步方法 

5.死锁


1.多线程可能会造成的安全问题

        场景:三个窗口同时售卖100张电影票,使用线程模拟。

public class MyThread extends Thread{

    //ticketnum表示当前正在售卖第几张票
    public int ticketnum=0;

    @Override
    public void run() {

        //模拟卖票过程,总共100张票,售完为止
        while (ticketnum<100){
            ticketnum++;
            System.out.println(Thread.currentThread().getName()+"正在售卖第"+ticketnum+"张票");

            //通常卖票是有时间间隔的,用sleep来模拟
            //由于父类Thread的run方法没有抛异常,所以子类的run方法也不能抛异常,只能使用try-catch
            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        }
    }
}

//模拟三个窗口同时售票
public class Main {
    public static void main(String[] args) {

        //创建三个线程模拟三个窗口
        Thread t1=new MyThread();
        Thread t2=new MyThread();
        Thread t3=new MyThread();
        t1.setName("窗口1");
        t2.setName("窗口2");
        t3.setName("窗口3");

        //模拟三个窗口售票
        t1.start();
        t2.start();
        t3.start();
    }
}

        运行结果:

        从运行结果可以看到,如果这样设计多线程卖票,会出现三个窗口出售同一张票,在实际情况下肯定是不能够出现这种问题的。那么为什么会出现重复售票的情况呢?

        这是因为ticketnum是普通成员变量,也就意味着每个线程中ticketnum是独立的,即相当于每个线程各有100张票进行售卖,但实际上三个窗口应当共同售卖这100张票。

2. static共享变量

        我们可以通过将ticketnum变成静态成员变量来解决这个问题。静态成员变量是共享的,这样可以保证这三个线程共同售卖这100张票:

public class MyThread extends Thread{

    //ticketnum表示当前正在售卖第几张票
    //使用static将其变为共享的
    public static int ticketnum=0;

    @Override
    public void run() {

        //模拟卖票过程,总共100张票,售完为止
        while (ticketnum<100){
            ticketnum++;
            System.out.println(Thread.currentThread().getName()+"正在售卖第"+ticketnum+"张票");

            //通常卖票是有时间间隔的,用sleep来模拟
            //由于父类Thread的run方法没有抛异常,所以子类的run方法也不能抛异常,只能使用try-catch
            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        }
    }
}

        此时再次运行代码,运行的结果为:

        此时运行结果还是有问题,不仅还是会重复,而且在卖完第100张票后又出现了还在卖第99张票的情况,并且第96、98张票并没有显示被卖出。这又是为什么呢?

        这是由于线程执行的随机性导致的,在java中,多线程的执行是抢占式调度的,哪个线程能竞争到CPU完全是随机的,并且线程在执行过程中CPU也是有可能被其他线程抢走的。

        下面在解释时线程1、2、3并不对应上面例子中的窗口1、2、3,只是为了解释原因

        先来解释为什么将ticketnum变成共享的还是会出现重复售卖的现象:

        虽然在运行结果中窗口3、1、2重复出售了第97张票,但实际上ticketnum并不会重复,因为已经设置为共享资源了,输出结果中出现重复是因为在输出结果时出现了问题。假设目前都是从第一张票进行售卖,即ticketnum还是初始值0。线程1先抢到了CPU,执行ticketnum++,此时ticketnum的值变为1,然后线程1还没有来得及执行输出的操作分配给线程1的CPU占用时间就耗尽了;此时线程2成功抢到CPU,也是执行ticketnum++,由于是共享资源,所以这里的自增是在1的基础上自增的,所以此时ticketnum的值就变为了2,同样线程2还没有来得及执行输出的操作CPU就被线程3抢走了;线程3也执行ticketnum++,此时ticketnum就变成了3,然后CPU占用时间到。

        此时线程1再次抢到CPU,执行输出操作,但由于ticketnum变成了3,所以线程1会输出“正在售卖第3张票”,然后执行sleep方法休眠100ms,CPU被强制释放;此时线程2抢到了CPU执行输出操作,ticketnum的值还是3,所以线程2也会输出“正在售卖第3张票”,随后也进入100ms的休眠;由于线程1和线程2都进入了休眠,只有线程3在竞争CPU,所以线程3会抢到CPU,也会输出“正在售卖第3张票”。

        由此可见,虽然输出结果显示三个线程收买了同一张票,但实际上售卖了三张不同的票,只是输出结果时出现了问题。

        再来解释为什么输出结果中有的票没有售出显示:

        其实从上面的过程我们可以看到,ticketnum是会一直自增1的,但由于打印结果时出现了CPU抢占问题,导致ticketnum在自增后不能及时输出结果,本来应该是自增之后就输出,变成了三个线程滞后输出了相同的结果。所以重复输出相同的结果已经覆盖了那些没有显示的输出。

        最后解释为什么已经售完了第100张票,还会出售第99张票:

        假设此时正在出售第97张票,线程1执行完ticketnum++后ticketnum变成了98。然后线程2抢占了CPU并执行ticketnum++,此时ticketnum变成了99,接着开始执行输出操作;在执行输出操作时,需要先获取要输出的内容,此时读到的ticketnum是99,但还没有来得及执行println时间就耗尽了,此时线程1抢到了CPU,准备执行输出操作,获取到的ticketnum自然也是99,还没有来得及执行println,CPU就被线程3抢走了,线程3不仅执行了ticketnum++使其变为了100,还完整执行了输出操作,随后线程3进入100ms的休眠状态。此时线程1抢到了CPU,继续执行println,但由于之前读到的值是99,所以线程1会输出“正在售卖第99张票”,随后也进入100ms的休眠状态。最后是线程2抢到CPU,继续执行println,也输出“正在售卖第99张票”。所以输出结果时会先出现线程3输出“正在售卖第100张票”,然后是线程1输出“正在售卖第99张票”,最后是线程2输出“正在售卖第99张票”。

        那怎样解决这种问题呢?

3.同步代码块

        上面这三种情况归根结底还是因为程序在运行过程中其他线程能够修改共享变量的值,导致前后内容不一致的情况,也就是不能保证代码执行的原子性。如果我们在执行的过程中进行加锁,当一个线程未使用完共享资源之前不允许其他线程访问这个共享资源,那不就可以解决这个问题了,这就需要同步代码块来帮忙。

        同步代码块的格式:

synchronized (锁对象) {

    //要执行的代码

}

        其中锁对象可以是任意一个对象,因为锁对象就相当于这把锁是什么样的,是什么品牌的锁,什么形状的锁不重要,关键在于要锁住所有可能用到共享资源的代码,保证在这些代码执行完之前不会有其他线程干扰代码的执行。同时锁对象又必须是唯一的或者共享的,虽然锁的样式可以不同,但是锁必须是唯一的或者共享的,就比如一个房间有一把黑锁和一把白锁,有两个人要进入这个房间,一个人只认黑锁,如果没有黑锁就认为房间没有上锁可以进入,而另一个人只认白锁;此时第一个人进入到房间并给房间加了黑锁,第二个人由于只认白锁,所以会认为房间没有上锁也进入到这个房间,此时锁就失去了作用。

        同步代码块在执行时会自动加锁,当其中的代码块都执行完之后才会自动解锁。

        接着之前卖电影票的案例,可以将代码修改为:

public class MyThread extends Thread{

    //ticketnum表示当前正在售卖第几张票
    //使用static将其变为共享的
    public static int ticketnum=0;

    //锁对象,用于加锁操作
    public static Object object=new Object();
    
    @Override
    public void run() {

        //模拟卖票过程,总共100张票,售完为止
        //对卖票过程进行加锁
        synchronized (object){
            while (ticketnum<100){
                ticketnum++;
                System.out.println(Thread.currentThread().getName() + "正在售卖第" + ticketnum + "张票");

                //通常卖票是有时间间隔的,用sleep来模拟
                //由于父类Thread的run方法没有抛异常,所以子类的run方法也不能抛异常,只能使用try-catch
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
        }

    }
}

        这里要注意要把整个while循环放到同步代码块中,因为while的判定条件也用到了共享变量ticketnum,凡是可能用到共享资源的代码都要放到同步代码块中。

        运行结果为:

        如果仅把ticketnum++和输出操作放入同步代码块也会出现线程安全问题,就是下面这样:

            //模拟卖票过程,总共100张票,售完为止
            while (ticketnum<100){
                
                //对卖票过程进行加锁
                synchronized (object){
                ticketnum++;
                System.out.println(Thread.currentThread().getName() + "正在售卖第" + ticketnum + "张票");
                }
                //通常卖票是有时间间隔的,用sleep来模拟
                //由于父类Thread的run方法没有抛异常,所以子类的run方法也不能抛异常,只能使用try-catch
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
        }

        运行结果为: 

        这是因为while中的判定条件并没有在同步代码块中,所以在执行判定条件时就无法保证线程安全。假设已经执行到卖出第99张票,随后线程1执行while的判定条件,99<100,所以线程1再次进入循环,但此时还没有来得及执行同步代码块线程2就抢走了CPU。此时线程2也执行while的判定条件,由于同步代码块还没有执行,就意味着还没有加锁,所以线程2也可以拿到ticketnum的值为99,所以线程2也可以再次进入循环,然后又和线程1一样,还没有来得及执行同步代码块线程3就抢走了CPU,同理线程3也拿到了ticketnum的值为99,也再次进入了循环。

        然后假设CPU又被线程1抢走了,此时线程1执行同步代码块,虽然加锁了,其他线程在同步代码块执行期间也都不能再次访问共享变量ticketnum,但是在这之前线程2和线程3都因为while没有保证线程安全都再次进入了循环,所以线程1、2、3都会执行ticketnum++和输出操作。

        只不过不同的是,线程1执行同步代码块之后ticketnum变为了100,然后输出“正在售卖第100张票”,执行结束后进入100ms的休眠状态,然后线程2抢到CPU并执行同步代码块,此时ticketnum变成了101,然后输出“正在售卖第101张票”,随后线程2也进入休眠状态,线程3抢占CPU并执行同步代码块,ticketnum变成了102,然后输出“正在售卖第102张票”。

        但是如果把整个while都放到同步代码块中还是会有问题,因为sleep也在循环体中,这么做虽然线程也会进入阻塞状态,但这期间由于还没有解锁,其他线程并不能对这个线程所持有的共享资源进行操作,所以其他线程抢到CPU也没有用,最后的运行结果会显示为这个线程售完了所有的票,相当于这个窗口垄断了整个售票部。

        那怎样才能既保证所有可能用到共享资源的代码都在代码块中,也能保证sleep不在同步代码块中呢?

        sleep在while循环体中,要想不让sleep在同步代码块中就不能让整个while在同步代码块中,这样做就不能让ticketnum这个共享变量出现在while的判定条件里,所以我们可以在while中使用if-else判断语句,将ticketnum<100这个判定条件转移到if中,这样就不需要对整个while进行加锁了:

@Override
    public void run() {

            //模拟卖票过程,总共100张票,售完为止
            while (true){

                //对卖票过程进行加锁
                synchronized (object){

                    //加入if-else
                    if(ticketnum<100) {
                        ticketnum++;
                        System.out.println(Thread.currentThread().getName() + "正在售卖第" + ticketnum + "张票");
                    }else{
                        break;
                    }
                }

                //通常卖票是有时间间隔的,用sleep来模拟
                //由于父类Thread的run方法没有抛异常,所以子类的run方法也不能抛异常,只能使用try-catch
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
        }

    }

        运行结果为:

4.同步方法 

        也可以使用同步方法,将同步代码块中的代码放到同步方法中,同步方法可以不用指定锁对象,在运行时会自动指定锁对象:如果使用的是非静态的同步方法,那么锁对象就是this,也就是调用这个方法的对象;如果使用的是静态的同步方法,那么锁对象就是这个类的Class对象,也就是类.class。下面是同步方法的格式:

public synchronized void 方法名(){
        
    //要执行的代码
        
    }

        不过要注意的是,如果使用了循环并且判定条件中使用了共享变量,或者在循环中使用了break或continue,那么要把整个循环都要放在同步方法中。前者上面讲过了,整个循环体都要放在同步代码块中,同步方法中肯定也是要把整个循环体都放进去;后者虽然不用将整个循环体放到同步代码块中,但必须都要放在同步方法中,因为break和continue只能在循环体中使用,如果在循环外使用会报错。

        那如果使用实现Runnable接口的方式实现电影院售票呢?下面是代码:

public class MyRun implements Runnable{

    int ticketnum=0;

    //对卖票过程进行加锁
    public synchronized void lock(){
        //模拟卖票过程,总共100张票,售完为止
        while (true){

                if(ticketnum<100) {
                    ticketnum++;
                    System.out.println(Thread.currentThread().getName() + "正在售卖第" + ticketnum + "张票");
                }else{
                    break;
                }
            }

            //通常卖票是有时间间隔的,用sleep来模拟
            //由于父类Thread的run方法没有抛异常,所以子类的run方法也不能抛异常,只能使用try-catch
            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
    }

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

//实现模拟卖票
public class Main {
    public static void main(String[] args) {

        //创建MyRun任务类的对象
        Runnable r=new MyRun();

        //创建三个线程模拟三个窗口
        Thread t1=new Thread(r);
        Thread t2=new Thread(r);
        Thread t3=new Thread(r);
        t1.setName("窗口1");
        t2.setName("窗口2");
        t3.setName("窗口3");

        //模拟三个窗口售票
        t1.start();
        t2.start();
        t3.start();
    }
}

        运行结果为:

        如果使用Runnable接口,那么ticketnum就没必要使用静态的了,普通成员变量就可以了,因为Runnable的实现类MyRun仅创建了一个实例,这就谈不到独立和共享变量的问题了,前面的MyThread创建了三个实例,所以必须让ticketnum变成静态的使其成为共享变量。所以对于MyRun的同步方法而言锁住的是非静态变量,那么锁对象就是this,也就是MyRun的实例;如果在MyThread中使用同步方法,那么锁住的就是静态变量,锁对象就是MyThread.class。

        由上面的运行结果会发现,由于把整个循环体都放进了同步方法中,sleep也放被迫进去了,会导致线程1卖完了所有的票,所以遇到循环需要考虑使用同步方法会不会产生其他影响。

        如果是使用lambda表达式,那么直接将资源类的方法变成同步方法即可:

//资源类Ticket
public class Ticket {
    int ticketnum=0;

    //直接加上synchronized关键字使其变为同步方法
    public synchronized void sale(){
        //模拟卖票过程,总共100张票,售完为止
        while (true){

            if(ticketnum<100) {
                ticketnum++;
                System.out.println(Thread.currentThread().getName() + "正在售卖第" + ticketnum + "张票");
            }else{
                break;
            }
        }

        try {
            Thread.sleep(100);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
    }

}

//实现买票过程
public class Main {
    public static void main(String[] args) {

        //创建资源类对象
        Ticket ticket=new Ticket();

        //在创建线程时使用lambda表达式
        Thread t1=new Thread(()->{
           ticket.sale();
        },"线程1");

        //使用方法引用
        Thread t2=new Thread(ticket::sale,"线程2");

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

        多线程的实现方式详解见另一篇博客多线程学习-CSDN博客的第三部分。

        介绍完了同步代码块和同步方法,可以看到他们都是使用了syncronized关键字,那么它是怎样加锁的呢?

        synchronized加锁原理是:当一个线程获取锁时,会将对象的对象头中mark word部分的一些标志位设置为当前线程的ID,并将对象的等待队列中的其他线程阻塞;当线程释放锁时,会将标志位清空,并唤醒等待队列中的一个线程继续执行。需要注意的是,在Java 6之前,synchronized锁的实现是重量级锁,使用操作系统的互斥量来实现。

5.死锁

        死锁就是指,线程1加了A锁又准备加B锁,线程2加了B锁又准备加A锁,此时由于B锁已经被添加,线程1只能等待B锁释放,而线程2由于A锁也已经被添加,也在等待A锁释放,这样线程1、2因为要等待对方先释放锁导致一直无法解除自身已经添加的锁,造成死锁。所以在使用多线程时尽量不要嵌套加锁。

  • 21
    点赞
  • 20
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值