• 首页 首页 icon
  • 工具库 工具库 icon
    • IP查询 IP查询 icon
  • 内容库 内容库 icon
    • 快讯库 快讯库 icon
    • 精品库 精品库 icon
    • 问答库 问答库 icon
  • 更多 更多 icon
    • 服务条款 服务条款 icon

JavaEE多线程相关知识——基础篇

武飞扬头像
同学潘
帮助1


引言:

线程的状态

  1. NEW: 安排了工作, 还未开始行动

新建了一个Thread对象,但是还没有调用 start() 方法

示例如下:

package thread;

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

        });
        System.out.println(t.getState());//通过getState()这个方法获取到指定线程(也就是t)的状态
        t.start();
    }
}

结果图如下:
学新通

  1. TERMINATED: 工作完成了

操作系统中的线程已经执行完毕,销毁了;但是Thread对象还在时获取到的状态

示例如下:

package thread;

public class Demo {
    public static void main(String[] args) throws InterruptedException {
        Thread t=new Thread(()->{

        });
        t.start();
        Thread.sleep(1000);//由于不知道是main先执行还是t先执行,所以为了拿到该状态,让它一秒钟之后再来拿
        System.out.println(t.getState());
    }
}

结果图如下:
学新通

上面两种状态是Java内部搞出来的状态,和操作系统中的PCB里的状态没啥关系。

  1. RUNNABLE: 可工作的,又可以分成正在工作中和即将开始工作

这个也就是我们所说的就绪状态(意味着两种情况:一个是它正在cpu上执行,一个是它还没执行,随时可以被调用到cpu上执行),处于这个状态的线程,就是在就绪队列中,随时可以被调度到CPU上。
如果代码中没有进行sleep,也没有进行其他的可能导致阻塞的操作,那么代码大概率是处在Runnable状态的。

示例如下:

package thread;

public class Demo {
    public static void main(String[] args) throws InterruptedException {
        Thread t=new Thread(()->{
            while (true){
                //这里什么都不能有,否则就可能得不到这个状态
                //此时一直持续不断的执行这里的循环,系统随时想调度它上cpu都是可以的
            }
        });
        t.start();
        Thread.sleep(1000);
        System.out.println(t.getState());
    }
}

结果图如下:
学新通

  1. TIMED_WAITING: 这几个都表示排队等着其他事情——(阻塞状态之一)(一定时间到了以后,阻塞状态解除)

在代码中调用了sleep,就会进入到TIMED_WAITING;join(超时时间)也会进入到。
意思就是当前的线程在一定时间之内,是阻塞的状态

示例如下:

package thread;
public class Demo {
    public static void main(String[] args) throws InterruptedException {
        Thread t=new Thread(()->{
            while (true){
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });
        t.start();
        Thread.sleep(1000);//由于不知道是main先执行还是t先执行,所以为了拿到该状态,让它一秒钟之后再来拿
        System.out.println(t.getState());
    }
}
学新通

结果图如下:
学新通

  1. BLOCKED: 这几个都表示排队等着其他事情

当前线程在等待锁,导致了阻塞(阻塞状态之一)

  1. WAITING: 这几个都表示排队等着其他事情

当前线程在等待唤醒,导致了阻塞(阻塞状态之一)

线程状态转换图(简图)

学新通

多线程带来的的风险-线程安全 (重点)

线程安全的概念

想给出一个线程安全的确切定义是复杂的,但我们可以这样认为:

如果多线程环境下代码运行的结果是符合我们预期的,即在单线程环境一样的结果,则说这个程序是线程安全的。

线程不安全示例

package thread;
class Counter {
    //这个变量就是两个线程要去自增的变量
    public int count = 0;
    public void increase() {
        count  ;
    }
}
public class Demo1 {
    private static Counter counter = new Counter();
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 50000; i  ) {
                counter.increase();
            }
        });
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 50000; i  ) {
                counter.increase();
            }
        });
        t1.start();
        t2.start();
        //必须要在t1和t2都执行完了之后,再打印count的结果
        //否则,main和t1 t2之间都是并发的关系,导致t1和t2还没执行,就先执行了下面的打印操作
        t1.join();
        t2.join();
        System.out.println(counter.count);
    }
}
学新通

结果图如下:
学新通

线程不安全的原因

上面的线程不安全的代码中, 涉及到多个线程针对 counter.count 变量进行修改. 此时这个counter.count是一个多个线程都能访问到的 “共享数据”。

那么count 到底干了啥呢?站在CPU的角度来看待,count ,实际上三个CPU指令:
1.把内存中的count的值,加载到CPU寄存器中(load);
2.把寄存器中的值,给 1(add);
3.把寄存器的值写回到内存的count中(save)
学新通

学新通
除去上述造成线程不安全原因之外,还有其他原因也有可能造成线程不安全:

  1. 线程是抢占式执行,线程间的调度充满随机性(线程不安全的万恶之源!!)
  2. 多个线程对同一个变量进行修改操作——上述不安全示例代码中不安全原因(如果是多个线程针对不同的变量进行修改的话没事!如果多个线程针对同一个变量读,也没事!)
  3. 针对变量的操作不是原子的

针对有些操作,比如读取变量的值,只是对应一条机器指令,此时这样的操作本身就可以视为是原子的–
但是线程主要有三个操作:
1.把内存中的count的值,加载到CPU寄存器中(load);
2.把寄存器中的值,给 1(add);
3.把寄存器的值写回到内存的count中(save)
所以可以通过加锁操作,把线程操作过程中的三个操作打包成一个整体,也就是把好几个指令给打包成一个原子的,禁止线程互相穿插执行,此时就可以保证线程安全了 。

  1. 内存可见性(是Java编译器进行代码优化产生的效果)
    举个具体的栗子:
    针对同一个变量,一个线程进行读操作(循环进行很多次);一个线程进行修改操作(合适的时候执行一次)
    学新通
    t1 这个线程在循环读这个变量,按照之前的介绍,读取内存操作,相比于读取寄存器,是一个非常低效的操作!!(慢3-4个数量级)因此在 t1 中频繁的读取这里的的内存的值,就会非常低效,而且如果 t2 线程迟迟不修改,t1 线程读到的值又始终是一样的值,因此,t1 就有个大胆的想法!!!就会不再从内存读取数据了,而是直接从寄存器里读(不在执行load了),一旦 t1 做出这种大胆的假设,此时万一 t2 修改了 count的值,t1 就不能感知到了
  2. 指令重排序(也是编译器优化中的一种操作)

咱们写的很多代码,彼此的顺序,谁在前谁在后无所谓(当然,也有的代码是依赖前后顺序的),此时 编译器就会智能的调整这里代码的前后顺序,从而提高程序的效率,在保证逻辑不变的前提,再去调整顺序;如果代码是单线程的程序,编译器的判定一般都是很准,但是如果代码是多线程的,编译器也可能产生误判。

如何解决线程安全问题

1.synchronized关键字
2.lock锁

synchronized的基本使用:

synchronized 本质上要修改指定对象的 “对象头”,从使用角度来看, synchronized 也势必要搭配一个具体的对象来使用。

  1. 直接修饰普通方法(此时锁对象指的是 this(当前类的对象) )
    学新通
    直接使用synchronized的时候,本质上是在针对某个“对象”进行加锁~
    学新通
    以上述的那个例子来说:
    学新通
  2. 修饰一个代码块
    学新通
    需要显示指定针对哪个对象加锁(Java中的任意对象都可以作为锁对象)
  3. 修饰一个静态方法
    相当于针对当前类的类对象加锁(Counter.class)-反射

synchronized 关键字-监视器锁monitor lock

synchronized 的特性:

  1. 互斥

synchronized 会起到互斥效果, 某个线程执行到某个对象的 synchronized 中时, 其他线程如果也执行到同一个对象 synchronized 就会阻塞等待。

  • 进入 synchronized 修饰的代码块, 相当于 加锁
  • 退出 synchronized 修饰的代码块, 相当于 解锁
    学新通
  1. 刷新内存
    synchronized 的工作过程:
    1)获得互斥锁
    2)从主内存拷贝变量的最新副本到工作的内存
    3)执行代码
    4)将更改后的共享变量的值刷新到主内存
    5)释放互斥锁

所以 synchronized 也能保证内存可见性. 具体代码参见后面 volatile 部分

  1. 可重入

简单来说,当同一个线程针对同一个锁,连续加锁两次时,如果出现死锁,就是不可重入,如果不会死锁,就是可重入的。

代码示例如下:

synchronized public void increase() {
        synchronized (this){
            count  ;
        }
    }

在上述代码中,外层先加了一次锁,里层又对同一个对象再加一次锁
外层锁: 进入方法,则开始加锁;这次能够加锁成功,当前锁是没有人占用的。
里层锁: 进入代码块,开始加锁,这次加锁不能加锁成功,因为锁被外层占用着呢。
得等外层锁释放了之后,里层锁才能加锁成功;但是外层锁要执行完整个方法,才能释放;而要想执行完整个方法,就得让里层锁加锁成功继续往下走~~
这就死锁了!!

可重入锁的意义:降低了程序猿的负担(使用成本,提高了开发效率);但是也带来了代价,程序中需要更高的开销(维护锁属于哪个线程,并且加减计数,降低了运行效率)

死锁的其他场景

一个线程一把锁,两个线程两把锁,那么N个线程就有M把锁

下面举一个教科书上的经典例子(哲学家吃饭问题)
学新通

死锁的四个必要条件

  1. 互斥使用:一个锁被一个线程占用了之后,其他线程占用不了(锁的本质,保证原子性)
  2. 不可抢占:一个锁被一个线程占用了以后,其他的线程不能把这个锁给抢走
  3. 请求和保持:当一个线程占据了多把锁之后,除非显示的释放锁,否则这些锁始终都是被该线程持有的
  4. 环路等待:等待关系,A等B,B等C,C又等A,形成了一个环

那么如何避免环路等待呢?

只要约定好,针对多把锁加锁的时候,有固定的的顺序即可!!所有的线程都遵守同样的规则顺序,就不会出现环路等待!!!

volatile 关键字

volatile 修饰的变量,能够保证 “内存可见性”。

代码在写入 volatile 修饰的变量的时候:

  • 改变线程工作内存中volatile变量副本的值
  • 将改变后的副本的值从工作内存刷新到主内存

代码在读取 volatile 修饰的变量的时候:

  • 从主内存中读取volatile变量的最新值到线程的工作内存中
  • 从工作内存中读取volatile变量的副本

前面我们讨论内存可见性时说了, 直接访问工作内存(实际是 CPU 的寄存器或者 CPU 的缓存), 速度非常快,但是可能出现数据不一致的情况. 加上 volatile , 强制读写内存. 速度是慢了, 但是数据变的更准确了

代码示例:

在这个代码中

  • 创建两个线程 t1 和 t2
  • t1 中包含一个循环, 这个循环以 flag == 0 为循环条件.
  • t2 中从键盘读入一个整数, 并把这个整数赋值给 flag.
  • 预期当用户输入非 0 的值的时候, t1 线程结束.
static class Counter {
    public int flag = 0;
}
public static void main(String[] args) {
    Counter counter = new Counter();
    Thread t1 = new Thread(() -> {
        while (counter.flag == 0) {
            // do nothing
       }
        System.out.println("循环结束!");
   });
    Thread t2 = new Thread(() -> {
        Scanner scanner = new Scanner(System.in);
        System.out.println("请输入一个整数:");
        counter.flag = scanner.nextInt();
   });
    t1.start();
    t2.start();
}
// 执行效果
// 当用户输入非0值时, t1 线程循环不会结束. (这显然是一个 bug)
学新通

t1 读的是自己工作内存中的内容.
当 t2 对 flag 变量进行修改, 此时 t1 感知不到 flag 的变化.

如果给 flag 加上 volatile:

static class Counter{
        public volatile int flg=0;
    }

结果图如下:
学新通

volatile 和 synchronized 有着本质的区别:volatile保证的是内存可见性,并不能保证原子性,只处理一个线程读,一个线程写的情况,而 synchronized 都能处理。

wait 和 notify

由于线程之间是抢占式执行的, 因此线程之间执行的先后顺序难以预知;
但是实际开发中有时候我们希望合理的协调多个线程之间的执行先后顺序。

完成这个协调工作, 主要涉及到三个方法:

  • wait() / wait(long timeout): 让当前线程进入等待状态;
  • notify() / notifyAll(): 唤醒在当前对象上等待的线程。

注意: wait, notify, notifyAll 都是 Object 类的方法。

wait()方法

wait 做的事情:

  • 使当前执行代码的线程进行等待. (把线程放到等待队列中)
  • 释放当前的锁
  • 满足一定条件时被唤醒,重新尝试获取这个锁。

wait 要搭配 synchronized 来使用,脱离 synchronized 使用 wait 会直接抛出异常。

wait 结束等待的条件:

  • 其他线程调用该对象的 notify 方法.
  • wait 等待时间超时 (wait 方法提供一个带有 timeout 参数的版本, 来指定等待时间).
  • 其他线程调用该等待线程的 interrupted 方法, 导致 wait 抛出InterruptedException 异常.

代码示例:

public class Demo {
    public static void main(String[] args) throws InterruptedException {
        Object object=new Object();
        synchronized(object){
        System.out.println("wait前");
        object.wait();
        System.out.println("wait后");
        }
    }
}

学新通

这样在执行到object.wait()之后就一直等待下去,那么程序肯定不能一直这么等待下去了。这个时候就需要使用到了另外一个方法唤醒的方法notify()。

注意: wait 哪个对象就需要对哪个对象进行加锁

notify()方法

notify 方法是唤醒等待的线程。

  • 方法notify()也要在同步方法或同步块中调用,该方法是用来通知那些可能等待该对象的对象锁的
    其它线程,对其发出通知notify,并使它们重新获取该对象的对象锁。
  • 如果有多个线程等待,则有线程调度器随机挑选出一个呈 wait 状态的线程。(并没有 “先来后到”)
  • 在notify()方法后,当前线程不会马上释放该对象锁,要等到执行notify()方法的线程将程序执行
    完,也就是退出同步代码块之后才会释放对象锁。
    代码示例:
public class Demo1 {
    private static Object locker = new Object();//锁对象
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(()-> {
            //进行wait
            synchronized (locker) {
                System.out.println("wait 之前");
                try {
                    locker.wait();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println("wait 之后");
            }
        });
        t1.start();
        //为了让现象更明显 在 t1 和 t2 之间加个sleep操作
        Thread.sleep(3000);
        Thread t2 = new Thread(()->{
            //进行 notify
            synchronized (locker) {
                System.out.println("notify 之前");
                locker.notify();
                System.out.println("notify 之后");
            }
        });
        t2.start();
    }
}
学新通

结果图如下:
学新通
wait 和 notify 都是针对同一个对象来操作的

例如现在有一个对象 t有10个线程,都调用了t.wait,此时10个线程都是阻塞状态,如果调用了t.notify,就会把10个其中的一个唤醒(唤醒哪个是不确定的) ;
针对 notifyAll,就会把所有的10个线程都给唤醒,wait 唤醒之后,就会重新尝试获取到锁(这个过程就会发生竞争) 。
因此,相对来说,更常用的还是 notify

wait 和 sleep 的对比(面试题)

其实理论上 wait 和 sleep 完全是没有可比性的,因为一个是用于线程之间的通信的,一个是让线程阻塞一段时间,唯一的相同点就是都可以让线程放弃执行一段时间。
但是为了面试的目的,我们还是来总结下:

  1. wait 需要搭配 synchronized 使用,sleep 不需要;
  2. wait 是 Object 的方法 sleep 是 Thread 的静态方法。
    学新通

这篇好文章是转载于:学新通技术网

  • 版权申明: 本站部分内容来自互联网,仅供学习及演示用,请勿用于商业和其他非法用途。如果侵犯了您的权益请与我们联系,请提供相关证据及您的身份证明,我们将在收到邮件后48小时内删除。
  • 本站站名: 学新通技术网
  • 本文地址: /boutique/detail/tanhgeikbe
系列文章
更多 icon
同类精品
更多 icon
继续加载