java多线程编程基础

        java多线程编程,顾名思义就是用java语言在多线程环境下编程。没有学习多线程之前,我们的编程都是单线程编程。多线程编程,比单线程编程多了更多的细节。处理不好就很容易出bug。下面让我们来介绍相关的概念,以及如何减少在多线程的环境下编程出bug。

1.获取线程实例

上篇文章详细介绍过了~,这里简单复习下

一共有五种方式:

  1. 继承Thread类,重写run方法

  2. 实现Runnable接口,重写run方法

  3. 使用匿名内部类,继承Thread类,重写run方法

  4. 使用匿名内部类,实现Runnable接口,重写run方法

  5. 使用lambda表达式,重写run方法

其中run方法是回调函数,由jvm调用,调用start方法后才正式在系统内核中创建出线程

2.线程等待

        多个线程的执行顺序是不确定的,因为在操作系统底层线程是随机调度,采取抢占式执行的方式。

        但是可以通系统提供的api来影响线程执行的顺序。

2.1 join()

        我们可以通过join()方法来影响线程之间的结束顺序(即join只能确定线程的结束顺序),比如我们可以让t1线程等待t2线程。

        join的使用,是谁调用join就是谁是被等待的线程,join在哪个线程范围,哪个线程就是去等待的线程。

public class ThreadDemo2 {
    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 100; i++) {
                //cpu执行速度太快了,通过让线程sleep的方式让输出速度慢一点
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println("hhh~t1");
            }
            System.out.println("t1线程执行结束");
        });
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 100; i++) {
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println("hhh~t2");
            }
            System.out.println("t2线程执行结束");
        });
        t1.start();
        t2.start();
        System.out.println("main线程执行结束");
    }
}

如图,不对线程加以控制,那么线程就是随机调度的

public class ThreadDemo2 {
    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 100; i++) {
                //cpu执行速度太快了,通过让线程sleep的方式让输出速度慢一点
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println("hhh~t1");
            }
            System.out.println("t1线程执行结束");
        });
        Thread t2 = new Thread(() -> {
            try {
                t1.join();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            for (int i = 0; i < 100; i++) {
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println("hhh~t2");
            }
            System.out.println("t2线程执行结束");
        });
        t1.start();
        t2.start();
        System.out.println("main线程执行结束");
    }
}

        如图,使用join(),让t2等t1,那么t2一定是在t1执行完毕后,才从调用join的位置继续向下执行。

        注意,具体执行情况和join的位置有关,如果join放到t2线程代码块的最后,那么和没加join是没有区别的。join之前的代码还是随机调度的。

        t1正在执行并且t2运行到join时,t2进入阻塞状态(此时t2不能被调度到cpu中,t2此时就是主动放弃去系统调度器上调度),当t1执行完毕后,t2才能从阻塞状态中恢复,并继续执行。阻塞,就让这两个线程的结束时间产生了先后关系。

        你可以想象成,t2线程join之前是你家房子前的空地,cpu可以随便来,join就是大门,钥匙就是t1线程结束,只有t1线程结束了,cpu才有钥匙来开门,调度门内的代码。

        在被等待代码逻辑简单的情况下,我们可以预测或者通过方法计算t1执行的时间,来让t2调用sleep方法,t2在t1执行的时候就在休眠,也可以达到一样的效果。

        但是join也有弊端,如果t1线程一直在执行,那么t2线程有可能会阻塞(就是一直调度不到cpu上)。这时我们可以使用带参数的join方法。这个参数则代表了,join等待最长的时间(单位是毫秒),过了这个时间t2就不等了,t2就继续参与到随机调度中。在计算机中无参数的join属于在死等,非等到不可,不建议使用,建议使用join(long)。

                       

public class ThreadDemo2 {
    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {

            while (true) {
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println("hh");
            }
        });
        Thread t2 = new Thread(() -> {
            try {
                t1.join(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            while (true) {
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println("hhh2");
            }
        });
        t1.start();
        t2.start();
        }
    }
 

        上篇文章介绍的线程中断(终止)中的interrupt方法可以取消线程的阻塞状态。哪个线程调用interrupt方法(直接调用就可以),哪个线程的阻塞就可以被取消。具体还是看代码怎么写。

        总结一下,join方法可以控制线程结束的顺序,具体和代码实现有关。join方法有两种类型的等待,一种是死等,一种是带有超时时间的等。还可以用interrupt方法让等待的线程结束等待状态。

  

  2.2 wait()和 notify()

          简单介绍一下前置概念:

          ​​​​​​​:可以简单理解为,骑共享单车去买东西,不加锁,共享单车就有可能被别人骑走了,但是加锁了之后别人就用不了,除非等你用完。锁就相当于是一个保障,保障你干完事之前,资源一定不会别的线程抢走。java中的加锁操作是靠synchronized关键字。

          ​​​​​​​阻塞:可以简单理解为,你拿来个碗准备装饭吃,但是饭还没做好,你就先放下碗,等饭做好,这个等待的过程就是阻塞。阻塞就是,你要完成一个操作,但是需要的资源还没准备好,那就先从CPU上下来,把CPU资源给别人先用,然后等饭做好的人过来叫你。

          wait和notify是从应用层面上去干涉不同线程的执行顺序(系统的线程调度还是随机调度),wait可以让线程主动放弃被调度到CPU上,notify可以让主动放弃调度的线程再次调度到CPU中。

          举个例子吧(建议先看看后面的锁相关内容再回来看这个),滑稽老铁去ATM里取钱。ATM一次只能一个人使用,具有互斥特性,可以看做是一把锁。滑稽老铁进ATM就相当于是加锁,出ATM就相当是释放锁。

          但是此时ATM没有钱,滑稽老铁就出了ATM(释放锁),和其他人再次竞争这个锁,很有可能有竞争到这个锁,但是ATM还是没有钱,然后再出ATM,再竞争到锁。如此来来回回,滑稽老铁多次拿到锁,但是滑稽老铁拿到锁,但是啥事也没干。这种情况就叫做“线程饿死”。这是概率性事件,但是也得处理。

          解决的方式就是让滑稽老铁发现执行逻辑的前提条件不具备(ATM没钱)时wait(主动放弃去CPU上调度,此时释放已经持有的锁,进入阻塞),等前提条件具备(ATM有钱)的时候,在其他线程上用notify唤醒滑稽老铁(wait解除阻塞,并尝试获取到锁,让线程重新进入CPU上调度)。

            那么如何使用wait和notify呢?有几个注意点(需要先看下文的锁相关内容):

  1. wait和notify高度依赖synchronized,wait需要在synchronized的代码块里使用,因为wait的操作是释放锁,释放锁的前提是有锁。不然就会抛出异常。notify也需要在synchronized的代码块里使用,虽然notify不需要先加锁,但是java约定要放里面。

  2. wait是被任意对象调用的,具体使用时,调用wait的对象必须和synchronized中的锁对象一致,唤醒锁时,调用notify的对象也必须和synchronized的对象一致。这样就能保证释放和唤醒的是同一个对象的锁。

  3. 线程没有执行到wait的时候,直接调用notify不会抛出任何异常,但是这样wait之后就不会被唤醒了,需要注意,一定要保证wait比notify先执行到。

  来个具体的例子:

package thread;

public class ThreadDemo2 {
    public static void main(String[] args) {
        Object locker1 = new Object();
        Thread t1 = new Thread(() -> {
            synchronized (locker1) {
                System.out.println("t1进入等待");
                try {
                    locker1.wait();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println("t1执行完毕");
            }
        });
        Thread t2 = new Thread(() -> {
            try {
                Thread.sleep(100);//为了确保t1的wait先于notify执行
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            synchronized (locker1) {
                System.out.println("线程唤醒");
                locker1.notify();
                System.out.println("t2执行完毕");
            }
        });
        t1.start();
        t2.start();
    }
}

                         

        t1和t2谁先被调度到CPU这是不确定的,所以t2sleep一下确定wait能先执行到,执行到wait之后线程进入阻塞状态,等待notify唤醒。sleep的时间到了,t2继续向下执行,执行到notify时,t1被唤醒继续向下执行,同时t2继续向下执行。

        还有wait和join一样也有一个带最长阻塞时间的方法,同样比较推荐用带有最长阻塞时间的wait方法,因为这样不会因为一个操作等太久而导致别的操作也执行不了。

        其次还有一个notifyAll()方法,可以唤醒调用该方法的对象上的所有等待的线程。其实并不能都往下执行,因为是同一个对象,都需要去竞争锁(具体看线程安全部分),竞争到锁的线程才能继续向下执行,没竞争到的又会进入阻塞

3.线程休眠/睡眠(sleep)

        线程休眠主要是调用sleep方法,让线程挂起到外存,从CPU上调度出来,等待一定的时间再进入CPU上调度(不是时间一到就一定能被调度上,就是让线程处于就绪状态,可以被调度)

        sleep是Thread类里的静态方法,在哪个线程里调用,哪个线程就进入休眠。sleep需要传入休眠的时间,时间的单位是毫秒。

        sleep时间没到但是被提前唤醒(比如线程调用了interrupt()方法,),sleep会抛出异常,并且清空对线程的标志位的修改。(标志位具体看上篇文章)

4.线程唤醒

        线程唤醒主要是两个方法interrupt() (在上篇详细介绍过)和notify(在上文介绍过)

        它们之间的区别主要就是,notify只能和wait搭配使用,唤醒因为wait而进入阻塞状态的线程。

        interrupt()的功能就比较强大,可以提前唤醒因为wait,sleep,join而进入的阻塞状态

5.线程状态

        在操作系统中,线程(PCB)有三种状态,执行态(正在CPU上执行),就绪态(随时可以调度到CPU上执行),阻塞态(因为一些原因,线程目前不可以被调度到CPU上执行)。

        在java中针对阻塞态又进行进一步区分,因为状态类是Thread类中的枚举类,我们可以通过以下代码来输出线程的状态有几种:

public class ThreadDemo3 {
    public static void main(String[] args) {
        for (Thread.State state: Thread.State.values()
             ) {
            System.out.println(state);
        }
    }
}

java中线程有以下几种状态:

  1. NEW : Thread对象创建好了,但是此时还没有调用start方法,还没有在系统中创建出线程。只有状态是NEW的线程才能成功调用start方法。

  2. RUNNABLE : 执行态+就绪态,此时start方法已经调用过了,系统中已经创建出线程。此时正在CPU上执行,或者随时可以准备去CPU上执行。

  3. BLOCKED : 因为出现了锁竞争(具体可以看线程安全部分),线程进入了阻塞状态。

  4. WAITING : 主动进入阻塞态,不带超时时间等待(死等),一定要某个条件满足后,才会解除阻塞状态。不带超时时间的,join和wait会进入此状态。

  5. TIMED_WAITING : 主动进入阻塞态,但是到达一定阻塞(等待)时间,自动解除阻塞。没有到达等待时间前,满足一定得条件也可以解除阻塞状态。 sleep和带有超时时间的join会进入这个状态。

  6. TERMINATED : Thread对象存在,但是系统中的线程已经执行结束。

每个线程一定有NEW,RUNNALE,TERMINATED这三个状态。

6.线程安全

        引入多线程是为了实现“并发编程”,但是实现并发编程的方式,有很多的解决方案,多线程是最朴素的方案。其他的一些编程语言引入封装层次更高的并发编程模型,会比java多线程更简单,更方便,更不容易出错。

线程安全程序设计中的术语,指某个函数函数库多线程环境中被调用时,能够正确地处理多个线程之间的公用变量,使程序功能正确完成。

        通俗来讲,线程安全就是某个代码无论在单线程还是在多线程下执行,都不会产生bug。

如果某个代码,在单线程下运行没有问题,但是在多线程下执行就有很大概率出bug,那么这个情况,就叫做“线程不安全”或者“存在线程安全问题”。

6.1 线程不安全的典型案例

来举个典型的线程不安全的例子:

public class ThreadDemo4 {
    //典型的线程不安全问题
    private static int count = 0;

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 5000; i++) {
                count++;
            }
        });
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 5000; i++) {
                count++;
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println("count = " + count);
    }
}

运行的结果理论上应该是10000,但实际上是

并且每次运行的结果都不相同

        这是为什么呢?这主要是因为代码的一个操作所对应的CPU指令不止一条并且并发调度是随机调度(抢占式执行)。CPU每次只执行一个CPU指令,CPU指令是原子的(就是不可再划分的意思),但是代码的一个操作,不是原子的,还能划分成多条CPU指令。

举个例子吧,比如count++,是由三条CPU指令构成的:

  1. load 从内存中读数据到CPU的寄存器中。

  2. add 把寄存器中的值+1。

  3. save 把寄存器中的值写回到内存中。

        线程在CPU中的调度是无序的,是以CPU指令是最小的划分单元,那么多个线程的指令并发(分时复用)执行时就有多种划分方式。如下图,两个线程同时count++,假设count的初始值是3:

        如图,第一种情况,t1线程在寄存器中给count加1,但是还没来得及保存到内存中,sava指令就被调度出CPU了,然后t2线程从内存中获取的数据,就是未被修改前的数据。最后保存的结果从逻辑上就出错了,因为t2得读取t1自增后的数据才对。

        如图,第五种情况,这种情况结果就是正确的,因为每个线程都是让这三个指令执行完才调度出CPU。

        实际上如图上的这些指令分布还有无数种情况,因为往往是并行+并发同时进行,并发是随机调度,是抢占式执行,并行之间,两个线程有各自的上下文(各自有一套寄存器的值,不会相互影响)。

        多线程出现bug的其实是一个概率事件,把上面的代码,每个线程只循环500次count++,实际上结果是正确的。

6.2 线程不安全的原因

  1. 直接原因:代码中的一个操作由多个CPU指令构成。操作本身不是原子的。

  2. 根本原因:操作系统上,并发调度是随机调度,线程是抢占式执行。这给线程之间执行的顺序带来了很多变数。

  3. 代码结构:多个线程同时(近似同时,分时复用)执行,都对同一个变量进行修改。这种情况就有很大概率发生线程安全问题。但是多个线程同时读同一个变量就不会出现线程安全问题。

  4. 内存可见性问题:和jvm的优化有关。

  5. 指令重排序问题:和jvm的优化有关。

6.3 解决线程不安全方法

        知道线程是因为什么而不安全,我们就可以针对具体原因来“对症下药”

1. 针对操作本身不是原子的。

        我们虽然改变不了构成操作的CPU指令数,但是操作系统提供了一个api,我们可以将操作对应多条CPU指令通过“加锁”的方式,让这些指令成为一个“整体”,要么就都不执行,要么就都执行完,不存在执行一半就调度走的情况。jvm对系统api又进行了一层封装。

2. 针对操作系统随机调度,线程抢占式执行。

        我们干预不了使用的操作系统的调度方式,除非你能成功说服操作系统开发商,把调度方式改一改。

3. 针对代码结构。

        有的情况可以调整代码结构,有的情况调整不了代码结构。

4. 针对内存可见性问题 和 指令重排序问题。

用volatile关键字。

        总结一共有三种方式,加锁,调整代码结构,volatile。(不同情况采用不同方式来解决线程安全问题)。

6.3.1 加锁(synchronized)

        举个例子,滑稽老铁要开共享单车去上班,中途滑稽老铁想吃个早饭,就把车放在了早餐店外,粗心的滑稽老铁没有给共享单车上锁,然后回头找车的时候,发现车被别人开走了。但是滑稽老铁在吃早饭的时候,把共享单车上锁了,那么别人就骑不走了,只能等滑稽老铁使用完共享单车,然后“释放锁”,才能使用这个共享单车。

        java中的加锁操作主要是通过synchronized关键字来进行加锁的,(也有别的加锁方式,不止一种),进行加锁操作的时候需要准备一个锁对象(java中任意一个对象都可以作为锁对象,因为加锁是Object提供的功能,而java中的对象都继承了Object)。加锁操作依赖于锁对象来进行。

        如果一个线程针对一个对象进行加锁,那么其他线程也尝试对这个对象进行加锁的,就会产生阻塞(BLOCKED,就是前文说到的因为锁竞争出现的阻塞状态),一直阻塞到,拥有锁的线程释放锁为止。这里的阻塞也可以称为“锁冲突”,“锁竞争”。加锁的效果,称为“互斥性”。这种锁也称为“互斥锁”。

        滑稽老铁对公共自行车加锁,那么别的人(线程)尝试对自行车进行加锁,就发现,拿不到自行车的锁,就要等(阻塞)到滑稽老铁使用完自行车,把自行车的锁让出来。当滑稽老铁释放锁之后,别的人就得去争抢(锁竞争)这个锁去对自行车加锁,因为自行车的锁只有一个,但是要对自行车加锁的人不止一个(僧多肉少)。坤坤老铁就觉得,非要等这一个公共自行车吗,于是就去骑(加锁)另一个自行车b(另一个对象)了。

接下来通过具体代码来用加锁解决3.1案例的问题:

synchronized的使用方法:

  synchronized(锁对象){

    需要加锁的内容

  }

public class ThreadDemo5 {
    private static int count = 0;
    public static void main(String[] args) throws InterruptedException {
        Object locker1 = new Object();//定义一个锁对象
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 5000; i++) {
                synchronized (locker1) {
                count++;
                }
            }
        });
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 5000; i++) {
                synchronized (locker1) {
                    count++;
                }
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println("count = " + count);
    }
}

        之前说过count++在CPU中不是原子的操作,现在随便列举一个情况:

        没加锁之前,这种情况,t1在执行完add操作之后,没保存到内存中,t1就会被调度走,然后执行t2的load等操作,加锁之后呢,add执行完后,t1虽然仍然被调度出CPU了,但是t2也拿不到锁,虽然t2调度到cpu上了但是一条指令也没执行,只能等t1释放锁后拿到锁,下次调度到CPU的时候才能执行。

        但是如果t1,t2线程加锁的对象不一致,t1,t2构成不了锁竞争,t1和t2互不影响,这时加锁就不能解决线程安全问题,如下:

public class ThreadDemo5 {
    private static int count = 0;
    public static void main(String[] args) throws InterruptedException {
        Object locker1 = new Object();
        Object locker2 = new Object();
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 5000; i++) {
                synchronized (locker1) {
                count++;
                }
            }
        });
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 5000; i++) {
                synchronized (locker2) {
                    count++;
                }
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println("count = " + count);
    }
}

        此外synchronized的位置也很重要哦~,位置不对的话,就不能解决线程安全问题,其实还是得看具体的代码逻辑,不是加了synchronized就万事大吉的。

        其实synchronized本质上是调用了系统的api,系统api的加锁和解锁是分开的,加锁是lock,解锁是unlock。synchronized后的“{”就相当于系统中的lock,"}"就相当于系统中的unlock。当进入synchronized的{}时加速,出{}时就释放锁。

        根据synchronized加锁的位置不同,分为类锁和对象锁。对象锁是用于对象实例方法,或者一个对象实例上的,类锁是用于类的静态方法或者一个类的class对象上的。

          synchronized(this),等价于把synchronized加到普通方法上。synchronized(Test.class),等价于把synchronized加到静态方法上。最常用的还是对象锁。

          synchronized修饰普通方法,相当于给this加锁;synchronized修饰静态方法,相当于给类对象加锁。

        synchronized加锁的范围越小,并发性能就越好,当出现锁竞争的时候只有synchronized内部会产生阻塞,未加锁地方是正常并发执行的。

6.3.2 调整代码结构

        根据具体情况调节代码结构也可以解决线程安全问题,但是有的情况代码结构调节不了,得采用其他的办法解决线程安全问题。

        3.1的典型案例,这个就可以通过调节代码结构来解决线程安全问题,我们可以这样改代码:

public class ThreadDemo4 {
    private static int count = 0;
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            int count1 = 0;
            for (int i = 0; i < 5000; i++) {
                count1++;
            }
            count = count + count1;
        });
        Thread t2 = new Thread(() -> {
            int count2 = 0;
            for (int i = 0; i < 5000; i++) {
                count2++;
            }
            count = count + count2;
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println("count = " + count);
    }
}

6.3.3 volatile

        一般一个线程读一个变量,一个线程写同一个变量时,有可能出现内存可见性问题,比如:

import java.util.Scanner;

public class ThreadDemo7 {
    private static int a = 10;
    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            while (a == 10) {
                
            }
            System.out.println("t1线程结束");
        });
        Thread t2 = new Thread(() -> {
            Scanner sc = new Scanner(System.in);
            System.out.println("请输入");
            a = sc.nextInt();
        });
        t1.start();
        t2.start();
    }
}

        无论是t1还是t2先执行,在t2等待用户输入的过程中,t1会循环很多次,while这个执行速度是很快的。从理论上讲,当t2中从控制台中输入非10数的时候,进程就会停止,但实际情况是,不会停止,会进入死循环。

        这和指令是不是多条无关(不信,你可以通过加锁,自己来试试能不能解决问题)。这是因为jvm对代码进行了优化。

        while(a == 10),在CPU中主要是两个操作,load读取内存中的a的值到CPU寄存器(或者缓存)里,拿CPU寄存器(或者缓存)里的值和10进行比较。因为while循环了多次,并且每次取到的数都是a ,jvm就认为a不会再变了,因为从内存中读数的速度远远低于从寄存器(或者缓存)中读数,jvm为了提高运行效率,就不从内存中读数了,直接从寄存器(或者缓存)中读数。t2线程修改内存里的数也就不会被读到了。

        这里t2修改了内存数据,但是t1看不到这个内存数据的变化,这就称为“内存可见性”问题。内存可见性,是个高度依赖编译器优化的具体实现的问题。解决此类问题只需要在后续要修改的变量前加上volatile即可。private static volatile int a = 10; volatile可以认为是一个开关,加上volatile就相当于告诉编译器,在这个地方把编译器优化关了,老老实实的先读取内存到寄存器(或者缓存)中,再读取寄存器(或者缓存)中的数值。

7.相关概念

7.1 死锁

        虽然加锁可以解决安全问题,但是如果加锁的方式不当,就有可能产生死锁问题。

死锁(英语:deadlock),又译为死结,计算机科学名词。当两个以上的运算单元,双方都在等待对方停止执行,以获取系统资源,但是没有一方提前退出时,就称为死锁。

        举个例子,当两个人相向而行过独木桥,互相都在等对方退让,但是没有一个退让,此时就僵持住了,谁也过不了独木桥。

死锁有三种典型场景:

1. 一个线程,一把锁

public class ThreadDemo6 {
    public static void main(String[] args) {
        Object locker = new Object();
        Thread t = new Thread(() -> {
            synchronized (locker) {
                synchronized (locker) {
                    System.out.println("hhh");
                }
            }
        }); 
    }
}

        在一个线程中,对同一个锁对象“套娃加锁”。从理论分析,外层synchronized持有锁,内层synchronized需要等外层执行完毕后释放锁,但是外层只有执行完内层synchronized这块才能释放锁,但是内层synchronized又要等外层synchronized。

        理论上会造成阻塞,谁也运行不了。但实际上是可以执行的。因为java中的synchronized是“可重入锁”。

        如果对已经上锁的普通互斥锁进行“加锁”操作,其结果要么失败,要么会阻塞至解锁。而如果换作可重入互斥锁,当且仅当尝试加锁的线程就是持有该锁的线程时,类似的加锁操作就会成功。可重入互斥锁一般都会记录被加锁的次数,只有执行相同次数的解锁操作才会真正解锁。

        如果是不可重入锁,并且在同一个线程中“套娃加锁”,就会出现死锁。相当于把钥匙锁在屋子里了。

2. 两个线程,两把锁

        线程1获取到锁A,线程2获取到锁B, 同时线程1尝试获取锁B,线程2尝试获取锁A。这样就会出现死锁,因为两个线程都需要获取对方持有的锁才能继续执行。

public class ThreadDemo6 {
    public static void main(String[] args) {
        Object A = new Object();
        Object B = new Object();
        Thread t1 = new Thread(() -> {
            synchronized (A) {
                //sleep 确保t2能拿到B锁
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                //持有A锁,尝试获取B锁
                synchronized (B) {
                    System.out.println("1111");
                }
            }
        });
        Thread t2 = new Thread(() -> {
            synchronized (B) {
                //sleep 确保t1能拿到A锁
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                //持有B锁,尝试获取A锁
                synchronized (A) {
                    System.out.println("1111");
                }
            }
        });
        t1.start();
        t2.start();
    }
}

        对于这种情况只需要约定好加锁的顺序就可以解决死锁问题了,比如可以约定先加A锁再加B锁。

  1. N个线程M把锁

最典型的就是哲学家就餐问题(N个线程N把锁)

        哲学家就餐问题:有五位哲学家围坐在一张圆形餐桌旁,做以下两件事情之一:吃饭,或者思考。吃东西的时候,他们就停止思考,思考的时候也停止吃东西。餐桌上有五份面,两两哲学家之间只有一只筷子,哲学家必须要获取到两只筷子才能吃面,哲学家只能获取离自己最近的两只筷子。

        一般情况下,会有至少一个哲学家(线程)(具体是哪个哲学家不确定,因为是随机调度)能获得两只筷子(锁)去吃面,获得不到两只筷子的哲学家就阻塞等待,哲学家不定时的思考,思考的时候会放下筷子(释放锁),再次吃饭的时候会去尝试获取获取筷子。当有哲学家放下筷子思考时,别的哲学家,就会获得筷子吃面。这样每个哲学家都能吃面和思考。

        但是在一些极端的情况下,不是每个哲学家都能吃面和思考。

        比如,每个哲学家都拿起了左边的筷子,每个哲学家都在尝试获取右边的筷子。但是右边的筷子都被别的哲学家获取了,每个哲学家就只能等待获取右边的筷子。可是没有哲学家能获取到两只筷子,也就不会有哲学家释放筷子,每个哲学家都不能吃面和思考。此时就出现了死锁这种情况。

        产生死锁有四个必要条件,只有同时满足这四个条件,才会产生死锁

  • 禁止抢占(no preemption):一个线程获取到锁之后,只能由这个线程主动释放锁。不可以被其他线程直接抢走锁。

  • 持有和等待(hold and wait):一个线程可以在持有一个锁的情况下,尝试获取另一个锁。

  • 互斥(mutual exclusion):一个锁只能被一个线程锁获取,别的线程想要获取这把锁只能阻塞等待。

  • 循环等待(circular waiting):一系列线程互相持有其他线程所需要的锁。

        只需要破坏四个必要条件之一就可以解决死锁问题,禁止抢占和互斥是锁最基本的特性,不可以更改,其他两个条件主要是看代码的结构怎么写。

        解决死锁问题,主要就是把代码结构搞搞好,避免出现循环等待或者持有和等待这种情况。

        解决死锁,有很多种方案,比如解决哲学家问题,我们可以:

(1)引入额外的筷子(锁)

(2) 减少一个线程

(3) 引入计数器,限制同时吃面的人数

(4) 引入加锁顺序的规则

(5) 银行家算法

其中最具普适性,并且方案容易落地的就是“引入加锁顺序规则”。

7.2 线程饿死

线程饿死就是一把个线程来来回回竞争到锁,但是每次都没有完成什么实际上的操作。具体的看上文wait那块。

  • 18
    点赞
  • 18
    收藏
    觉得还不错? 一键收藏
  • 2
    评论
Java多线程编程是指在Java语言中使用多个线程来同时执行多个任务,以提高程序的并发性能和响应速度。Java多线程编程PDF是一本介绍Java多线程编程的PDF文档,其中包含了Java多线程编程的基本概念、原理、技术和实践经验。该PDF文档可以帮助读者快速了解Java多线程编程的相关知识,并提供实用的编程示例和案例分析,有助于读者掌握Java多线程编程的核心技术和方法。 在Java多线程编程PDF中,读者可以学习到如何创建和启动线程、线程的状态和生命周期、线程间的通信与同步、线程池的使用、并发容器等相关内容。同时,该PDF文档还介绍了Java中的并发包(concurrent package)的使用和实现原理,以及多线程编程中的常见问题和解决方案。 通过学习Java多线程编程PDF,读者可以深入了解Java多线程编程的理论和实践,掌握多线程编程的核心知识和技能,提高自己的并发编程能力,为开发高性能、高并发的Java应用程序打下坚实的基础。同时,对于已经掌握多线程编程知识的读者来说,该PDF文档也能够帮助他们进一步巩固和扩展自己的多线程编程技能,提升自己的编程水平和竞争力。 总之,Java多线程编程PDF是一本全面介绍Java多线程编程的优秀文档,对于Java程序员来说具有很高的参考价值,可以帮助他们在多线程编程领域取得更好的成就。

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值