Java学习笔记之线程 (一)开启线程方法、线程状态、线程安全性问题

1 创建线程方法

创建线程有两种方法:

方法一是定义一个类继承Thread类,并重写run方法

class MyThread extends Thread {
    @Override
    public void run() {
        //线程的任务
    }
}

想要开启这个线程只需要new这个MyThread的实例并调用它的start方法:

new MyThread().start();

方法二是定义一个类实现Runnable接口

class MyThread implements Runnable {
    @Override
    public void run() {
        //线程的任务
    }
}

启动这个线程的方法是new一个Thread的实例,并将MyThread的一个对象作为它构造方法的参数,之后调用Thread的start方法:

new Thread(new MyThread()).start();

这两种方法的区别是:方法一是让MyThread变成Thread体系中的一员,MyThread这个类具备了Thread中的所有属性。但是继承Thread的目的仅仅是要重写run方法定义自己的任务,如果仅仅将任务被多线程技术所操作的话,是没有必要让它具有Thread类的所有属性的。而方法二是将线程的任务封装成了一个对象,那么运行这个任务的方法就是创建一个Thread对象,创建的同时告诉它这个任务。

Thread类的伪代码如下(仅仅为了分析开启线程的逻辑):

class Thread implements Runnable {
    private Runnable r;
    
	//无参构造方法	
    Thread(){
    }
    
	//有参构造方法	
    Thread(Runnable r){
        this.r = r;
    }

	@Override
    public void run(){
        if(r != null){
            r.run();
        }
    }

    public void start(){
        run();
    }
}

根据上述代码,如果使用方式一,调用start方法之后会执行run方法,但是此时的run方法已经被自定义的子类所覆盖,所以执行的是自定义的任务;方法二在创建Thread时调用的是有参的构造方法,将传入的实现Runnable接口的类赋给成员r,所以在run方法中调用的是r的run方法(也就是自定义的任务)。

由上述分析可知,使用方式二是有明显的好处的:

  1. 将线程的任务从线程Thread的子类中分离出来进行单独的封装,按照面向对象的思想将任务封装成了对象。
  2. 避免了Java单继承的局限性。

所以开启线程时推荐使用方法二,它还有一种更简单的写法(匿名类的方式):

new Thread(new Runnable() {
    @Override
    public void run() {
        //任务逻辑
    }
}).start();

2 线程的状态

当调用Thread的start方法时,线程被开启进入运行状态,直到run方法执行完毕线程被销毁。在线程运行过程中调用sleep或wait方法都会将线程冻结,直到睡眠时间到或者主动调用notify方法,回到阻塞状态或者运行状态(这时随机的,cpu决定)。因为cpu切换的随机性,线程也可以在阻塞状态和运行状态之间切换。

扫描二维码关注公众号,回复: 11457810 查看本文章

在这里插入图片描述
有两个概念需要说明:

  1. 执行资格:可以被cpu处理,在处理队列中排队
  2. 执行权:正在被cpu处理

3 线程的安全性

首先通过一个卖票的例子来引出多线程的安全性问题:

public class Test {
    public static void main(String[] args) {
        Ticket ticket = new Ticket();
        new Thread(ticket).start();
        new Thread(ticket).start();
        new Thread(ticket).start();
    }
}

class Ticket implements Runnable {
    private int num = 50;  //50张票

    @Override
    public void run() {
        while(true) {
            if(num > 0) {
                try {
                    Thread.sleep(10);  //调用睡眠方法增加错误发生的概率
                } catch (InterruptedException e){
                    e.printStackTrace();
                }
                System.out.println(Thread.currentThread().getName() + "sale "  + num--);
            }
        }
    }
}

在代码中定义了Ticket类用于卖票(卖的同时打印卖的是哪张票),然后在主方法中开启了3个线程去执行卖票任务,运行结果如下(截取了最后一段):

在这里插入图片描述
理论上讲应该卖到第1张票就结束了(是从第50张开始卖的),可是最后却卖了第0张和第-1张。产生这种现象的原因是cpu在线程中的切换是随机的,比如说线程1正在卖最后一张票,此时它判断num确实大于0,正准备执行num- -的时候cpu跑去执行线程2了,线程2判断num也是大于0的(因为线程1还没执行num- -),所以线程2也准备卖最后一张票,可是还没卖呢cpu又切换到线程3了,线程3判断票数还是大于0所以它把票卖掉了,这时候cpu再切换到线程2,因为线程2已经判断大于0完毕,不会再次判断了,所以它就会直接执行num- -,线程1也是同理,所以会出现票减到负数的情况。

由于cpu切换的随机性,所以上述的解释只是可能发生的一种情况。最理想的情况就是cpu切换的时机很完美,每次都是在num- -执行完毕之后再切换,这样才不会出现上述错误。但是这种错误一旦出现就是致命的,所以要从原理上杜绝这种可能性。

总结一下线程安全性隐患产生的原因:

  1. 多个线程在操作共享的数据(例子中为票数)
  2. 操作共享数据的线程代码有多条

根据上述分析,解决安全性的思路就是将多条操作共享数据的代码封装起来,当有线程执行这些代码时,其余线程不可执行这些代码,必须等待当前线程执行完毕之后,其余线程才有机会执行这些代码。

3.1 同步代码块

在Java中可以使用同步代码块来解决这个问题,其格式如下(括号中的对象称为对象锁):

synchronized (对象){
	//需要被同步的代码
}

所以将买票的代码放在同步代码块中就可以了:

    @Override
    public void run() {
        while(true) {
            synchronized (this){
                if(num > 0) {
                    try {
                        Thread.sleep(10);
                    } catch (InterruptedException e){
                        e.printStackTrace();
                    }
                    System.out.println(Thread.currentThread().getName() + "sale "  + num--);
                }
            }
        }
    }

举个例子描述上述代码:火车上一节车厢只有一个厕所,先进去的人把门锁上了,虽然他被熏晕了(线程的sleep方法),这时别人也想上厕所(cpu切换到别的线程),但是门已经被锁上了别人进不去,只能等先来的人醒过来上好厕所出来之后锁才打开,别人才有上厕所的机会。

例子中的锁门在Java里面称为获得锁,当一个线程获得锁才能运行同步代码块,运行完毕锁释放,其余线程才有获得锁的机会。

虽然同步可以解决线程安全性问题,但是它也有一定的弊端的。获得锁的线程不会一直享有cpu执行权,cpu在运行它的过程中可能会却换到别的线程中去,但是不管切换到哪个线程,它们都会判断能否获得锁,而锁已经被获取了所以这个判断是无效的,因而会降低程序的运行效率,只不过这个降低是我们可以承受的。

使用同步时要注意一个前提使用同步时必须要有多线程而且使用相同的锁。有多线程这一点很好理解,比如说一节车厢上只有一个人那上厕所就没必要锁门了(虽然是句玩笑话但是仔细想好像也有道理);至于说为什么使用相同的锁,比如说你把第一节车厢的厕所门锁上了,但是别人可以去别的车厢上厕所呀,那么两个人就可以同时上厕所了(失去了同步的意义)。

3.2 同步方法

同步代码块只是同步的一种方法,我们还可以通过同步方法来实现线程同步。举一个具体的例子:有两个用户要到银行存钱,每个人每次存100块,每个人各存3次。代码实现如下:

public class Test {
    public static void main(String[] args) {
        Bank bank = new Bank();
        new Thread(new Customer(bank)).start();
        new Thread(new Customer(bank)).start();
    }
}

class Bank {
    int amount; //银行钱的总数
    public void deposit(int num){
        //存钱的代码要放在同步代码块中
        synchronized (this) {
            amount = amount + num;
            System.out.println("银行存款总数为: " + amount);
        }
    }
}

class Customer implements Runnable {
    Bank bank;
    Customer(Bank b){
        this.bank = b;
    }
    @Override
    public void run() {
        for (int i = 0; i<3; i++) {
            bank.deposit(100);
        }
    }
}

由于两个用户要在一个银行里面存钱,所以银行金钱总额是两个线程都要操作的一个数据,为了保证线程安全性,所以要对存钱的代码放到同步代码块中。其实,同步代码块就是对要同步的代码进行封装,而方法也是对代码的一种封装。Java中也提供了同步方法,同步方法中的代码具有同步性,那么只需要把Bank类中的存钱方法改成如下即可:

public synchronized void deposit(int num) {
    amount = amount + num;
    System.out.println("银行存款总数为: " + amount);
}

用synchronized修饰的方法就是同步方法,同步方法的锁是this。同步方法和同步代码块的区别就是同步代码块的锁可以是任意对象,而同步方法锁是固定的this,在实际的开发中建议使用同步代码块,而同步方法就是对同步代码块的化简。

如果同步方法是静态的那么它的锁就是该函数所属的字节码文件对象,可以用getClass获取,或者用当前类名.class表示。

3.3 单例设计模式中的多线程安全问题

单例设计模式有两种,懒汉模式和饿汉模式:

//饿汉模式
class Single {
    private static final Single s= new Single();
    private Single(){}

    public Single getInsatance() {
        return s;
    }
}

饿汉模式不存在安全问题,因为操作共享数据的语句只有一句return s,而且共享数据s是final类型固定不变的。

//懒汉模式
class Single {
    private static Single s;
    private Single(){}

    public static Single getInsatance() {
        if(s == null) {
            s = new Single();
        }
        return s;
    }
}

而懒汉模式有多条语句在操作共享数据s,所以存在安全问题,要使用同步来解决:

class Single {
    private static Single s;
    private Single(){}

    public static synchronized Single getInsatance() {
        if(s == null) {
            s = new Single();
        }
        return s;
    }
}

使用同步方法虽然解决了安全问题,但是每个线程调用getInstance方法都要判断锁,效率较低,所以可以进行如下改进:

class Single {
    private static Single s;
    private Single(){}

    public static  Single getInsatance() {
        if (s == null) {
            synchronized (Single.class) {
                if(s == null) {
                    s = new Single();
                }
            }
        }
        return s;
    }
}

上述代码判断了两次是否为空,这样做的好处是如果s不为null就不用再判断锁。总结起来就是,加锁是为了解决安全问题,加判断是为了解决效率问题。

3.4 死锁

死锁就是指两个或两个以上的线程/进程在执行的过程中,因争夺锁而造成的一种相互等到的现象,演示代码如下:

public class Test {
    public static void main(String[] args) {
        new Thread(new Demo(true)).start();
        new Thread(new Demo(false)).start();
    }
}

class Demo implements Runnable {
    private boolean flag;
    public Demo (boolean f) {
        flag = f;
    }
    @Override
    public void run() {
        if (flag) {
            while (true) {
                synchronized (Lock.locka) {
                    System.out.println("线程" + Thread.currentThread().getName() + "拿到了a锁");
                    synchronized (Lock.lockb) {
                        System.out.println("线程" + Thread.currentThread().getName() + "拿到了b锁");
                    }
                }
            }

        } else {
            while (true) {
                synchronized (Lock.lockb) {
                    System.out.println("线程" + Thread.currentThread().getName() + "拿到了b锁");
                    synchronized (Lock.locka) {
                        System.out.println("线程" + Thread.currentThread().getName() + "拿到了a锁");
                    }
                }
            }
        }

    }
}

class Lock {
    public static final Object locka = new Object();
    public static final Object lockb = new Object();
}

运行结果如下:

在这里插入图片描述
可以看到,线程0拿到了a锁在等待b锁,线程1拿到了b锁在等待a锁,如果不做特殊处理,程序会一直卡死。

猜你喜欢

转载自blog.csdn.net/weixin_44965650/article/details/107037136