并行与并发的概念

单核cpu下,操作系统有一个组件叫任务调度器,将cpu的时间片分给不同的程序使用,只是由于cpu在线程的切换非常快,让人感觉是同时运行的。
一般会将这种线程轮流使用cpu的做法称为并发,concurrent.

多核cpu下,每个核都可以调度运行线程,这时候线程是可以并行的。
image.png
并发是同一时间应对多件事情的能力
并行是用以时间动手做多件事情的能力

1. 单核 cpu 下,多线程不能实际提高程序运行效率,只是为了能够在不同的任务之间切换,不同线程轮流使用cpu ,不至于一个线程总占用 cpu,别的线程没法干活

  1. 多核 cpu 可以并行跑多个线程,但能否提高程序运行效率还是要分情况的
    有些任务,经过精心设计,将任务拆分,并行执行,当然可以提高程序的运行效率。但不是所有计算任务都能拆分,也不是所有任务都需要拆分,任务的目的如果不同,谈拆分和效率没啥意义

  2. IO 操作不占用 cpu,只是我们一般拷贝文件使用的是【阻塞 IO】,这时相当于线程虽然不用 cpu,但需要一直等待 IO 结束,没能充分利用线程。所以才有后面的【非阻塞 IO】和【异步 IO】优化.

PS:DMA(Direct Memory Access), 直接存储器
I/O模块增加DMA控制器.之前的阶段是每次只传输一个字, 就通知CPU, 就发起一次中断, CPU放到寄存器中, 再放到内存中. 这样CPU就会被连续的中断打断, 不断切换进程, 上下文, 效率很低.DMA控制器类似于一个小的CPU, 有自己的寄存器(记录主存地址和取到的字的count等). CPU可以发起一个DMA请求, 传入读写操作类型,相关I/O设备地址, 内存的起始地址, 要操作的字数.然后DMA就可以获取总线的控制权, 将一大块内存和外部I/O读入或写出.等操作完成后, 再通知CPU. 释放总线控制权。

创建线程的方式

方法一,直接使用 Thread

  1. //创建线程对象
  2. Thread t= new Thread(){
  3. public void run(){
  4. //执行的任务...
  5. }
  6. };
  7. //启动线程
  8. t.start();

方法二,使用 Runnable 配合 Thread

把线程和任务分开

  • Thread 代表线程
  • Runnable 可运行的任务
    1. Runnable runnable = new Runnable(){
    2. public void run(){
    3. //要执行的任务
    4. }
    5. };
    6. //创建线程对象
    7. Thread t= new Thrad(runnable,"name");
    8. //启动线程
    9. t.start();
    在jdk1.8后使用lambada精简代码
    1. Runnable runnable=()-> log.info("执行的任务");
    2. Thread t=new Thread(runnable,"name");
    3. t.start();

    Thead和Runnable的关系?

    1. @FunctionalInterface //函数式编程 说明可以使用lambda表达式
    2. public interface Runnable {
    3. /**
    4. * When an object implementing interface <code>Runnable</code> is used
    5. * to create a thread, starting the thread causes the object's
    6. * <code>run</code> method to be called in that separately executing
    7. * thread.
    8. * <p>
    9. * The general contract of the method <code>run</code> is that it may
    10. * take any action whatsoever.
    11. *
    12. * @see java.lang.Thread#run()
    13. */
    14. public abstract void run();
    15. }
    1. public class Thread implements Runnable{...}
    Thread是一个类,而Runnable是一个接口。
    Thread类实现了Runnable接口,Runnable接口里只有一个抽象的run()方法。说明Runnable不具备多线程的特性。Runnable依赖Thread类的start方法创建一个子线程,再在这个子线程里调用run()方法,才能让Runnable接口具备多线程的特性。

方法三,FutureTask 配合 Thread

FutureTask 能够接收 Callable 类型的参数,用来处理有返回结果的情况

  1. // 创建任务对象
  2. FutureTask<Integer> task3 = new FutureTask<>(() -> {
  3. log.debug("hello");
  4. return 100;
  5. });
  6. // 参数1 是任务对象; 参数2 是线程名字,推荐
  7. new Thread(task3, "t3").start();
  8. // 主线程阻塞,同步等待 task 执行完毕的结果
  9. Integer result = task3.get();
  10. log.debug("结果是:{}", result);

输出

  1. 19:22:27 [t3] c.ThreadStarter - hello
  2. 19:22:27 [main] c.ThreadStarter - 结果是:100
  1. public class FutureTask<V> implements RunnableFuture<V> //FutureTask实现了
  2. Runnable接口
  1. @FunctionalInterface //函数式编程 属于lambda表达式
  2. public interface Callable<V> {
  3. /**
  4. * Computes a result, or throws an exception if unable to do so.
  5. *
  6. * @return computed result
  7. * @throws Exception if unable to compute a result
  8. */
  9. V call() throws Exception;
  10. }

具体的lambda表达式使用在后面会讲。

查看进程线程的方法

windows

  • 任务管理器可以查看进程和线程数,也可以用来杀死进程
  • tasklist 查看进程
  • taskkill 杀死进程

linux

  • ps -fe 查看所有进程
  • ps -fT -p 查看某个进程(PID)的所有线程
  • kill杀死进程
  • top 按大写 H 切换是否显示线程
  • top -H -p 查看某个进程(PID)的所有线程

Java

  • jps 命令查看所有 Java 进程
  • jstack 查看某个 Java 进程(PID)的所有线程状态
  • jconsole 来查看某个 Java 进程中线程的运行情况(图形界面)

线程上下文切换(Thread Context Switch)

因为以下一些原因导致 cpu 不再执行当前的线程,转而执行另一个线程的代码

  1. 线程的 cpu 时间片用完
  2. 垃圾回收
  3. 有更高优先级的线程需要运行
  4. 线程自己调用了 sleep、yield、wait、join、park、synchronized、lock 等方法

当 Context Switch 发生时,需要由操作系统保存当前线程的状态,并恢复另一个线程的状态,Java 中对应的概念 就是程序计数器(Program Counter Register),它的作用是记住下一条 jvm 指令的执行地址,是线程私有的。
状态包括程序计数器、虚拟机栈中每个栈帧的信息,如局部变量、操作数栈、返回地址等。
Context Switch 频繁发生会影响性能。

线程常用的方法

方法名 功能说明 注意
start() 启动一个新线
程,在新的线程
运行 run 方法
中的代码
start 方法只是让线程进入就绪,里面代码不一定立刻
运行(CPU 的时间片还没分给它)。每个线程对象的
start方法只能调用一次,如果调用了多次会出现
IllegalThreadStateException
run() 新线程启动后会
调用的方法
如果在构造 Thread 对象时传递了 Runnable 参数,则
线程启动后会调用 Runnable 中的 run 方法,否则默
认不执行任何操作。但可以创建 Thread 的子类对象,
来覆盖默认行为
join([long n])

getId() 获取线程长整型
的 id
id 唯一
getName() 获取线程名
setName(String) 修改线程名
getPriority() 获取线程优先级
setPriority(int) 修改线程优先级 java中规定线程优先级是1~10 的整数,较大的优先级
能提高该线程被 CPU 调度的机率
getState() 获取线程状态 Java 中线程状态是用 6 个 enum 表示,分别为:
NEW, RUNNABLE, BLOCKED, WAITING,
TIMED_WAITING, TERMINATED
isInterrupted() 判断当前线程是否被打
不会清除 打断标记
interrupted() 该方法由static修饰 判断当前线程是
否被打断
会清除 打断标记
isAlive() 线程是否存活
(还没有运行完
毕)

interrupt() 打断线程 如果被打断线程正在 sleep,wait,join 会导致被打断
的线程抛出 InterruptedException,并清除 打断标
记 ;如果打断的正在运行的线程,则会设置 打断标
记 ;park 的线程被打断,也会设置 打断标记
currentThread() 该方法由static修饰 获取当前正在执
行的线程

sleep(long n) 该方法由static修饰 让当前执行的线
程休眠n毫秒,
休眠时让出 cpu
的时间片给其它
线程

yield() 该方法由static修饰 提示线程调度器
让出当前线程对
CPU的使用
主要是为了测试和调试

调用start与run的区别

  1. public static void main(String[] args) {
  2. Thread t1 = new Thread("t1") {
  3. @Override
  4. public void run() {
  5. log.debug(Thread.currentThread().getName());
  6. FileReader.read(Constants.MP4_FULL_PATH);
  7. }
  8. };
  9. t1.run();
  10. log.debug("do other things ...");
  11. }

输出

  1. 19:39:14 [main] c.TestStart - main
  2. 19:39:14 [main] c.FileReader - read [1.mp4] start ...
  3. 19:39:18 [main] c.FileReader - read [1.mp4] end ... cost: 4227 ms
  4. 19:39:18 [main] c.TestStart - do other things ...

程序仍在main线程运行,并没有在t1线程运行,t1设置的程序是同步的。

将上述代码的 t1.run() 改为t1.start()

  1. 19:41:30 [main] c.TestStart - do other things ...
  2. 19:41:30 [t1] c.TestStart - t1
  3. 19:41:30 [t1] c.FileReader - read [1.mp4] start ...
  4. 19:41:35 [t1] c.FileReader - read [1.mp4] end ... cost: 4542 ms

程序在t1线程运行,t1程序是异步的

总结:

  • 直接调用 run 是在主线程中执行了 run,没有启动新的线程
  • 使用 start 是启动新的线程,通过新的线程间接执行 run 中的代码

调用sleep 与 yield的区别?


  • sleep
  1. 调用 sleep 会让当前线程从 Running进入 Timed Waiting 状态(阻塞)
    2. 其它线程可以使用 interrupt 方法打断正在睡眠的线程,这时 sleep 方法会抛出 InterruptedException
    3. 睡眠结束后的线程未必会立刻得到执行(还得获取时间片)
    补充:建议用 TimeUnit 的 sleep 代替 Thread 的 sleep 来获得更好的可读性
  • yield
  1. 调用 yield 会让当前线程从 Running 进入 Runnable就绪状态,然后调度执行其它线程。所以执行yield()方法的线程可能在进入可执行状态后马上又被执行。
    2. 具体的实现依赖于操作系统的任务调度器(不可控,可移植性差)
    3.yield()会让优先级同级或优先级更高的 有更高的执行机会。而sleep()不会考虑线程优先级。

设置线程优先级?

  • 线程优先级会提示调度器优先调度该线程,但仅仅是一个提示,调度器完全可以忽略它。
  • 如果cpu比较忙,那么优先级高的线程会获得更多的时间片,但cpu闲时,优先级是没作用的

线程为什么需要调用join方法?

  1. static int r = 0;
  2. public static void main(String[] args) throws InterruptedException {
  3. test1();
  4. }
  5. private static void test1() throws InterruptedException {
  6. log.debug("开始");
  7. Thread t1 = new Thread(() -> {
  8. log.debug("开始");
  9. sleep(1);
  10. log.debug("结束");
  11. r = 10;
  12. });
  13. t1.start();
  14. log.debug("结果为:{}", r);
  15. log.debug("结束");
  16. }

分析

  • 因为主线程和线程 t1 是并行执行的,t1 线程需要 1 秒之后才能算出 r=10
  • 而主线程一开始就要打印 r 的结果,所以只能打印出 r=0

解决方法

  • 用 sleep 行不行?不行,等待时间不好把握。
  • 用 join,加在 t1.start() 之后即可
  1. static int r1 = 0;
  2. static int r2 = 0;
  3. public static void main(String[] args) throws InterruptedException {
  4. test2();
  5. }
  6. private static void test2() throws InterruptedException {
  7. Thread t1 = new Thread(() -> {
  8. sleep(1);
  9. r1 = 10;
  10. });
  11. Thread t2 = new Thread(() -> {
  12. sleep(2);
  13. r2 = 20;
  14. });
  15. long start = System.currentTimeMillis();
  16. t1.start();
  17. t2.start();
  18. t1.join();
  19. t2.join();
  20. long end = System.currentTimeMillis();
  21. log.debug("r1: {} r2: {} cost: {}", r1, r2, end - start);
  22. }

cost大约多少秒?

join的意思是使得放弃当前线程的执行,并返回对应的线程,代码的意思就是:程序在main线程中调用t1线程的join方法,则main线程放弃cpu控制权,并返回t1线程继续执行直到线程t1执行,t2线程同时也在运行。等t1运行完,main线程继续执行,调用了t2线程join方法,main线程等待t2线程执行,因为之前执行了1s,所以再运行1s 后 t2线程就执行完毕。(同步)

补充:join(time)时间内如果 线程执行未完成则退出
该线程,等待它的线程继续运行。如果线程执行
完直接退出,不会继续无意义 time等待。

interrupt 方法详解

  • 打断 sleep,wait,join 的线程

这几个方法都会让线程进入阻塞状态
打断 正在sleep 的线程(该线程打断睡眠抛出异常后会继续运行), 会清空打断状态,以 sleep 为例

  1. java.lang.InterruptedException: sleep interrupted
  2. at java.lang.Thread.sleep(Native Method)
  3. at java.lang.Thread.sleep(Thread.java:340)
  4. at java.util.concurrent.TimeUnit.sleep(TimeUnit.java:386)
  5. at cn.itcast.n2.util.Sleeper.sleep(Sleeper.java:8)
  6. at cn.itcast.n4.TestInterrupt.lambda$test1$3(TestInterrupt.java:59)
  7. at java.lang.Thread.run(Thread.java:745)
  8. 21:18:10.374 [main] c.TestInterrupt - 打断状态: false

sleep,wait,join被打断后,打断状态一定为 false,且抛出异常。

  • 打断正常运行的线程

    1. 20:57:37.964 [t2] c.TestInterrupt - 打断状态: true

    那么会将该线程的中断标志设置为 true,仅此而已。被设置中断标志的线程将继续正常运行,不受影响。

  • 打断 park 线程

打断 park 线程, 不会清空打断状态

  1. private static void test3() throws InterruptedException {
  2. Thread t1 = new Thread(() -> {
  3. log.debug("park...");
  4. LockSupport.park();
  5. log.debug("unpark...");
  6. log.debug("打断状态:{}", Thread.currentThread().isInterrupted());
  7. }, "t1");
  8. t1.start();
  9. sleep(0.5);
  10. t1.interrupt();
  11. }
  1. 21:11:52.795 [t1] c.TestInterrupt - park...
  2. 21:11:53.295 [t1] c.TestInterrupt - unpark...
  3. 21:11:53.295 [t1] c.TestInterrupt - 打断状态:true

补充:如果已经打断过park线程,打断标记为true,那该线程再次park会失效。

  1. private static void test4() {
  2. Thread t1 = new Thread(() -> {
  3. for (int i = 0; i < 5; i++) {
  4. log.debug("park...");
  5. LockSupport.park();
  6. log.debug("打断状态:{}", Thread.currentThread().isInterrupted());
  7. }
  8. });
  9. t1.start();
  10. sleep(1);
  11. t1.interrupt();
  12. }

输出

  1. 21:13:48.783 [Thread-0] c.TestInterrupt - park...
  2. ----------------------------------------------------------
  3. 21:13:49.809 [Thread-0] c.TestInterrupt - 打断状态:true
  4. 21:13:49.812 [Thread-0] c.TestInterrupt - park...
  5. 21:13:49.813 [Thread-0] c.TestInterrupt - 打断状态:true
  6. 21:13:49.813 [Thread-0] c.TestInterrupt - park...
  7. 21:13:49.813 [Thread-0] c.TestInterrupt - 打断状态:true
  8. 21:13:49.813 [Thread-0] c.TestInterrupt - park...
  9. 21:13:49.813 [Thread-0] c.TestInterrupt - 打断状态:true
  10. 21:13:49.813 [Thread-0] c.TestInterrupt - park...
  11. 21:13:49.813 [Thread-0] c.TestInterrupt - 打断状态:true

可以使用 Thread.interrupted() 清除打断标记

不推荐的方法
还有一些不推荐使用的方法,这些方法已过时,容易破坏同步代码块,造成线程死锁
image.png

两阶段终止模式的应用

在一个线程 T1 中如何“优雅”终止线程 T2?这里的【优雅】指的是给 T2 一个料理后事的机会。

  1. 错误思路

    • 使用线程对象的 stop() 方法停止线程

stop 方法会真正杀死线程,如果这时线程锁住了共享资源,那么当它被杀死后就再也没有机会释放锁, 其它线程将永远无法获取锁

  • 使用 System.exit(int) 方法停止线程

目的仅是停止一个线程,但这种做法会让整个程序都停止

2.使用isInterrupted

  1. public class 两阶段中止模式 {
  2. public static void main(String[] args) {
  3. TwoPhaseTermination twoPhaseTermination = new TwoPhaseTermination();
  4. twoPhaseTermination.start();
  5. }
  6. }
  7. class TwoPhaseTermination{
  8. private Thread monitor;
  9. //启动监控线程
  10. public void start(){
  11. monitor=new Thread(()->{
  12. while (true){//因为是监控线程 所以得一直运行 且为了有间隔得运行 设置sleep
  13. Thread currentThread = Thread.currentThread();
  14. if(currentThread.isInterrupted()){
  15. System.out.println("料理后事");
  16. break;
  17. }
  18. try {
  19. Thread.sleep(1);//若该监控线程在sleep被打断,则会执行catch异常处理,打断标记清除 为false
  20. System.out.println("执行监控功能");//若该监控线程正常运行,则打断标记为true
  21. } catch (InterruptedException e) {
  22. e.printStackTrace();
  23. //重新设置打断标记 为true
  24. currentThread.interrupt();
  25. }
  26. }
  27. });
  28. }
  29. //停止监控线程
  30. public void stop(){
  31. monitor.interrupt();
  32. }
  33. }
  1. 11:49:42.915 c.TwoPhaseTermination [监控线程] - 将结果保存
  2. 11:49:43.919 c.TwoPhaseTermination [监控线程] - 将结果保存
  3. 11:49:44.919 c.TwoPhaseTermination [监控线程] - 将结果保存
  4. 11:49:45.413 c.TestTwoPhaseTermination [main] - stop
  5. 11:49:45.413 c.TwoPhaseTermination [监控线程] - 料理后事

也可以使用 volatile描述变量stop来判断程序是否该停止,volatile是为了保证该变量再多个线程之间的可见性。

  1. public class AfterNodeAccess {
  2. private Thread thread;
  3. private volatile boolean stop = false;
  4. public void start(){
  5. thread = new Thread(() -> {
  6. while(true) {
  7. Thread current = Thread.currentThread();
  8. if(stop) {
  9. System.out.println("料理后事");
  10. break;
  11. }
  12. try {
  13. Thread.sleep(5000);
  14. System.out.println("将结果保存");
  15. } catch (InterruptedException e) {}
  16. // 执行监控操作
  17. }
  18. },"监控线程");
  19. thread.start();
  20. }
  21. public void stop() {
  22. stop = true;
  23. thread.interrupt();
  24. }
  25. public static void main(String[] args) {
  26. AfterNodeAccess afterNodeAccess = new AfterNodeAccess();
  27. afterNodeAccess.start();
  28. Scanner scanner = new Scanner(System.in);
  29. if(scanner.hasNextInt()){
  30. afterNodeAccess.stop();
  31. }
  32. }
  33. }

有个细节,就是在sleep期间,如果使用了stop程序,则不会等待睡眠时间直接退出。

统筹规划模式

image.png

  1. public class 烧水泡茶 {
  2. public static void main(String[] args) {
  3. Thread t1=new Thread(()->{
  4. try {
  5. System.out.println("洗水壶");
  6. Thread.sleep(1000);
  7. System.out.println("烧开水");
  8. Thread.sleep(5000);
  9. } catch (InterruptedException e) {
  10. e.printStackTrace();
  11. }
  12. },"老王");
  13. Thread t2=new Thread(()->{
  14. try {
  15. System.out.println("洗茶壶");
  16. Thread.sleep(1000);
  17. System.out.println("洗茶杯");
  18. Thread.sleep(2000);
  19. System.out.println("拿茶叶");
  20. Thread.sleep(1000);
  21. t1.join();//必须等老王烧开水完就可以泡茶了(等T1线程执行完)
  22. System.out.println("泡茶");
  23. } catch (InterruptedException e) {
  24. e.printStackTrace();
  25. }
  26. },"小王");
  27. t1.start();
  28. t2.start();
  29. }
  30. }


Balking(犹豫)模式

Balking (犹豫)模式用在一个线程发现另一个线程或本线程已经做了某一件相同的事,那么本线程就无需再做了,直接结束返回
image.png
单例模式也是Balking模式的实现。

线程的状态

从Java层描述的六种状态:
image.png

重新理解一下线程状态(了解)

  1. 情况1NEW->RUNNABLE<br /> 当调用t.start()方法时,由NEW->RUNNABLE
  2. 情况2RUNNABLE->WAITING<br /> t线程调用synchronized(obj)获得对象锁后<br /> 。调用obj.wait()方法时,t线程从RUNNABLE->WAITING<br /> 。调用obj.notify(),obj.notifyAll() ,t.interrupt() 时<br /> 竞争锁成功,t线程从WAITING->RUNNABLE<br /> 竞争锁失败,t线程从WAITING->BLOCKED

解释一下在有锁的情况下打断正在占有锁的线程会发生什么:

  1. public class test {
  2. public static void main(String[] args) throws InterruptedException {
  3. Object a=1;
  4. Thread t1 = new Thread(() -> {
  5. synchronized (a) {
  6. Thread currentThread = Thread.currentThread();
  7. for (int i = 0; i < 100; i++) {
  8. try {
  9. TimeUnit.SECONDS.sleep(1);
  10. System.out.println("t1正在运行");
  11. // if (currentThread.isInterrupted()){
  12. // //让t1线程睡一会,t2线程就可以抢
  13. // TimeUnit.SECONDS.sleep(5);
  14. // }
  15. } catch (InterruptedException e) {
  16. e.printStackTrace();
  17. //因为打断正在sleep的线程 打断标记为false 所以重新设置打断标记
  18. // currentThread.interrupt();
  19. }
  20. }
  21. }
  22. });
  23. Thread t2 = new Thread(() -> {
  24. synchronized (a) {
  25. for (int i = 0; i < 1000; i++) {
  26. try {
  27. TimeUnit.SECONDS.sleep(1);
  28. System.out.println("t2正在运行");
  29. } catch (InterruptedException e) {
  30. e.printStackTrace();
  31. }
  32. }
  33. }
  34. });
  35. t1.start();
  36. t2.start();
  37. TimeUnit.SECONDS.sleep(2);
  38. t1.interrupt();
  39. }
  40. }

如果t1线程(占有锁)被打断后,如果没有任何操作导致t1线程不会马上继续运行,那么它抢占锁的速度绝对比t2快。如果有操作延迟t1的打断时间,则t2会先抢占锁。

  1. 情况3: RUNNABLE -> WAITING
  • 当前线程调用 t.join() 方法时,当前线程从 RUNNABLE —> WAITING

    1. 注意是当前线程在t 线程对象的监视器上等待<br /> t 线程运行结束,或调用了当前线程的 interrupt() 时,当前线程从 WAITING --> RUNNABLE
  • 当前线程调用 LockSupport.park() 方法会让当前线程从 RUNNABLE —> WAITING

    1. 调用 LockSupport.unpark(目标线程) 或调用了线程 interrupt() ,会让目标线程从 WAITING -> RUNNABLE
  1. 情况2*: RUNNABLE -> TIMED_WAITING(同情况2一样)<br /> t 线程用 synchronized(obj) 获取了对象锁后<br /> 调用 obj.wait(long n) 方法时,t 线程从 RUNNABLE --> TIMED_WAITING<br /> t 线程等待时间超过了 n 毫秒,或调用 obj.notify() obj.notifyAll() t.interrupt() 时<br /> 竞争锁成功,t 线程从 TIMED_WAITING --> RUNNABLE<br /> 竞争锁失败,t 线程从 TIMED_WAITING --> BLOCKED
  2. 情况3*:RUNNABLE -> TIMED_WAITING
  • 当前线程调用 t.join(long n) 方法时,当前线程从 RUNNABLE —> TIMED_WAITING

    1. 注意是当前线程在t 线程对象的监视器上等待<br /> 当前线程等待时间超过了 n 毫秒,或t 线程运行结束,或调用了当前线程的 interrupt() 时,当前程从TIMED_WAITING-> RUNNABLE
  • 当前线程调用 LockSupport.parkNanos(long nanos) 或 LockSupport.parkUntil(long millis时,当前线程从 RUNNABLE —> TIMED_WAITING

    1. 调用 LockSupport.unpark(目标线程) 或调用了线程 interrupt() ,或是等待超时,会让线程从TIMED_WAITING--> RUNNABLE

    情况4:RUNNABLE -> TIMED_WAITING
    当前线程调用 Thread.sleep(long n) ,当前线程从 RUNNABLE —> TIMED_WAITING
    当前线程等待时间超过了 n 毫秒,当前线程从 TIMED_WAITING —> RUNNABLE