【Java笔记分享】线程安全

笔记源代码地址:B站 库米豪斯巴达锅铲祖师 空间

还在用Vector的同学们,撒手吧,ArrayList才是你的真爱,因为你马上要学会线程安全了

线程安全的理解

之前我们已经学过了线程的创建,线程的规划等知识,但这些其实没那么重要。据说实际开发的时候会有很好用的框架来帮助我们做这些事

所以实际开发上,我们最需要注意的是线程安全的问题

某同学:up主up主,为什么要线程安全啊,不安全它又能把我怎么样啊

我认为线程安全这个概念是无法用定义完全描述明白的,最好是在开发中发现问题。这里为了帮助大家快速理解,我们来举一个例子:

小栗子

你负责写一个银行自动管理的项目,现在写到取款部分了。你想到了一个账号可能有多个人同时操作,于是你实现了这个功能:每人一个线程,可以同时操作这个账号。用户经过取款操作后,系统将剩余的金额提交到服务器(也就是分两步,取款和保存)

做完了,啊没有bug,然后你就提交了。然后第二天,出现了两个大聪明 ->

多聪明呢?这两个人用同一个账号,这个账号里面有5000块钱,这两个人一起取钱,两个线程一起开始工作。一个线程拿到5000块钱的时候,还没来得及保存,哎很快嗷,CPU给它轰出去了,第二个线程屁颠屁颠又进来拿了5000块钱。然后,两个人一共从ATM里提了10000元,余额显示0元

啊然后你们都被烟绯抓走了


要解决这个问题,就要用到线程安全的知识,即如何让这两个线程不危害社会,正常拿出5000块钱

其实,经过概括,同时满足这三个特征的多线程是不安全的:

  • 多线程并行(大前提)
  • 线程有共享的数据
  • 共享的数据被修改了

就像上面的两个人(多线程并行)用一个账号(有共享数据)一起拿钱(数据被修改),导致这个世界上秃然出现5000块钱。要解决线程安全的问题,理论上有多种方式,将在下一模块提到;这个例子中应该采用线程同步来解决异常

如何实现线程安全

这节课中,我们主要要学习线程同步解决问题的方式。实际上,我们有这么几种比较好的方式

从上面提到的3个共性来分析,最简单的方法是:不要更改数据,只读就好了。然而提款过程中不改数据也会被抓走

不能改数据,次等的解决方式是:不让它们有共享的数据。这种方式在实际开发中多表现为:使用局部变量代替类变量和实例变量使用多个实例变量。但是在这个例子中,不可能,咱又不是没见过几个人用一个账号的情况

那就要用到最逊的办法:线程同步

为什么说这是最逊的?线程同步,翻译成大白话就是线程排队执行,类似于我们上节课见过的线程合并。这么一来,多线程的优点没有了,浪费时间,容易被客户投诉。

与它相对的是线程异步,也就是我们常说的多线程并行

线程同步是下下策,咱们能不耽误效率就不耽误效率;但是为了数据安全,有的时候我们必须使用线程同步

实现线程同步

对于线程同步的实现,Java专门提供了线程同步关键字:synchronized,跟我念,锌科肉奈斯的(别,自己去找音标跟读,不要听我的)

synchronized的使用需要“锁”,锁是实例对象的叫做对象锁,静态方法或使用Class对象的可以视为类锁(先不用管Class是什么东西)

单是这一个关键字,它有3种语法。我努力想了一个应该比较好理解的例子,来解释这些语法

synchronized语句块

synchronized语句块用于方法或代码块内的局部线程同步。也就是,在这个代码块中,前面和后面的代码都可以多线程并行,只有运行到语句块的位置,才会有一堆线程蹲在那里排队执行

就比如,这里有一条大道,大道上面有公厕

public class Toilet {
    
}

也会有很多想方便的人经过这里。现在这条路上只有一个厕所,每个人都有使用它的权力

public class Person {
    Toilet toilet;

    public Person(Toilet toilet) {
        this.toilet = toilet;
    }

    public void useToilet() {

    }
}

我们为这个事件创建一个类Run,里面包含了事件运行的主方法

public class Run {
    public static void main(String[] args) {
        // 这是剧情里的厕所
        Toilet toilet = new Toilet();
    }
}

现在,来了两个人,他们同时发现了这个厕所并且都想使用它

public void useToilet() {
    // 发现了厕所
    System.out.println("我要用厕所!");

    // 使用厕所
    System.out.println("框!(关门)【使用厕所】");
    try {
        Thread.sleep(3000);
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
    System.out.println("吱呀~(开门)【满意地离开】");
}

我们在主方法中重现这个事件

public static void main(String[] args) {
    // 这是剧情里的厕所
    Toilet toilet = new Toilet();

    // 两个人,互不干扰的动作
    new Thread(() -> new Person(toilet).useToilet()).start();
    new Thread(() -> new Person(toilet).useToilet()).start();
}

但是请观察控制台,这样的输出结果是不符合常理的。你们将会看到这样的结果:

我要用厕所!
框!(关门)【使用厕所】
我要用厕所!
框!(关门)【使用厕所】
(这里停了3秒)
吱呀~(开门)【满意地离开】
吱呀~(开门)【满意地离开】

也就是这两个人一起用了3秒的厕所,一起出来了。。。

我们要做到的是:两个人可以同时发现厕所,也可以一个人在另一个人使用厕所的时候发现厕所,但是一个人冲进去用完之后才轮到另一个人用。也就是,实际使用厕所的地方需要线程同步。我们只需要找到两人共用的那个对象(即厕所),将它作为语句块的“钥匙”。这样当一个对象占用这个厕所的时候(就绪状态),另一个对象会无法使用这个厕所(阻塞状态),直到语句块运行完毕,前一个对象归还钥匙。即useToilet()方法可以这样改:

public void useToilet() {
    // 发现了厕所
    System.out.println("我要用厕所!");

    synchronized (toilet) {
        // 使用厕所
        System.out.println("框!(关门)【使用厕所】");
        try {
            Thread.sleep(3000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("吱呀~(开门)【满意地离开】");
    }

}

即synchronized语句块的用法是:将一个共用对象充当钥匙,钥匙每次只能供一个对象使用。语句体即为希望同步的内容

这个语句块的“钥匙”也可以指定一个String,就直接往上面写一样的字符串就可以了。因为JavaSE中,字符串在方法区内存中专门有一个字符串常量池;如果你往里填入某个类.class,那可以作为类锁使用,因为这样的语法返回Class对象

用作实例方法的修饰符列表

现在,为了维护秩序,公厕所在道路的两边围上了栅栏门,一次只能放一个人进去,这个人在里面发现厕所,使用厕所并离开后,才轮到下一个人发现厕所

这里我们必须先讲解这种用法的特征了:用作修饰符列表的元素,在使用这个方法时,以当前对象(this)为对象锁进行线程同步

这样看用法能看懂的,你有自学变大佬的潜力;其他人看我操作qwq

对于这个需求,我们当然可以选择用刚学过的语句块把整个方法体都套上,但是这种情况下我们一般把关键字放在修饰符列表。根据这个用法的特征,我们来推断:这个公厕对象是两人的共用对象,所以这个方法应该放在公厕类里面。至于里面的算法,可以沿用之前使用者类里的算法

对之前这个帖子的评论中@>ping_的发言我也要反驳,算法的一般定义是解决问题的步骤。不管多小的问题,比如之前问的paint()方法,它确实解决了“画背景”这个问题,那就可以称之为算法。

而且他只是在干一些事情并不能称之为算法

回来了ovo所以我们只需要把原来使用者类的同步语句块去掉,也不需要原本带有的Toilet实例对象和那个有参构造器了。这样改,Person类里就留下那个方法,方法里面的同步语句不要,然后在Toilet类里写一个新的方法,来实现全部过程的线程同步

public synchronized void useThis(Person person) {
    person.useToilet();
}

这样更改不是最好的实现方式,只能用来探究synchronized关键字的用法

同时main方法里面的用法也要改一下

// 两个人,互不干扰的动作
new Thread(() -> toilet.useThis(new Person())).start();
new Thread(() -> toilet.useThis(new Person())).start();

然后就是你们观察控制台的事情啦ovo正确的结果应该是:

我要用厕所!
框!(关门)【使用厕所】
吱呀~(开门)【满意地离开】
(这里停了3秒)
我要用厕所!
框!(关门)【使用厕所】
吱呀~(开门)【满意地离开】

用作静态方法的修饰符列表

现在这条路上多了一个公厕了,但是栅栏没拆,也就是一个人进去之后能选厕所,但是其他人不能用厕所。现在,所有的厕所可以抽象成一个锁,即我们提到过的类锁。我们来测试类锁的功能。只需要在Toilet类的那个方法上面加一个static,在main里面再创建一个Toilet对象,两个对象都按之前的使用一遍。main里面应该改成这样:

public static void main(String[] args) {
    // 这是剧情里的厕所
    Toilet toilet1 = new Toilet();
    Toilet toilet2 = new Toilet();

    // 两个人,互不干扰的动作
    new Thread(() -> toilet1.useThis(new Person())).start();
    new Thread(() -> toilet2.useThis(new Person())).start();
}

更改无误,运行后会得到与上一次一样的结果

我知道不建议这样使用静态方法,这不测试功能嘛

线程死锁

同步代码块其实是个危险的东西。当你使用不当的时候,会出现线程死锁的情况。实际开发中,我们只要通过尽量使用局部变量等方式就可以最大限度避免线程死锁,但是我们必须要学会解死锁

然后实际上解死锁的方式是多种多样的,需要程序员根据开发经验来临场思考解死锁的办法。我们要理解线程死锁,以及实际开发的时候要会解死锁,就必须要知道线程死锁为什么会发生

理解它为什么会发生的最好办法,莫过于自己造一个线程死锁现象。这里我尽量让大家测试起来方便一点

弄一个类,名字是什么随便你(我以现象英文名DeadLock为例),它实现了Runnable接口(随便啦,只要是个线程就可以)

弄好了吗?然后重写run方法,在里面套两层synchronized语句块(不给大家增加代码量了,我们用字符串来当锁),外面一层的括号里写上"a",里面一层的括号里写上"b",在最里面一层的语句体里面放一个输出语句。整个类弄好了应该是这样的:

public class DeadLock implements Runnable {
    @Override
    public void run() {
        synchronized ("a") {
            synchronized ("b") {
                System.out.println("It's here!");
            }
        }
    }
}

接下来再定义一个类(我以DeadLock2为例),一样写,a改成b,b改成a

public class DeadLock2 implements Runnable {
    @Override
    public void run() {
        synchronized ("b") {
            synchronized ("a") {
                System.out.println("It's here!");
            }
        }
    }
}

最后在主方法里先输出一句随便什么(确保你不是测试成功而是IDE发生了bug),然后对这两个类分别开启线程就可以了。比如这样:

public static void main(String[] args) {
    System.out.println("start!");

    new Thread(new DeadLock()).start();
    new Thread(new DeadLock2()).start();
}

到这里,我们开始分析:这两个类对象中,若其中一个抢占了字符串"a",那么只要CPU这时候给它轰出去,另一个线程就会进来抢走字符串"b",接下来不管哪个线程开始,都会发现找不到锁而进入阻塞状态,另一个也如此,那么方法中的字符串"It's here!"就永远不会输出

只要你电脑开着,它们就一直在这里对峙,手里拿着一个锁,眼睛盯着别人拿着的锁,都没法运行,死锁现象出现

但是!如果有一个线程啊,它非常猛,一路冲过去两个锁都抢了,都还回来了,那死锁现象就没有了

要实现百分百的线程死锁,我们需要在两个synchronized语句中间插入一个Thread.sleep(1000),休眠1秒。整整一秒啦,另一个线程进入方法,一个锁就在它脸上,爬都爬到了

更改后的run方法(另一个照样改就可以了)

public void run() {     
    synchronized ("a") {
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        synchronized ("b") {
            System.out.println("It's here!");
        }
    }
}

这样不管你怎么测试,它都是不会运行里面的输出语句的。

别有人测试上头了误以为线程死锁是个好东西嗷。知道了原理,实际开发中,我们就能够快速找到应对死锁的办法

finally——

贪吃蛇做好了qwq考虑到我家的断网几率非常高,贪吃蛇估计还会往下做qwq

java
65 views
Comments
登录后评论
Sign In