Loading

多线程

date: 2020-07-20 16:09:00
updated: 2020-08-20 15:50:00

多线程

多线程的目的不是提升执行速度等,而是提高资源利用效率,当有的线程不占用cpu时让出来资源,从而有可能达到提高效率的结果(线程太多有可能会变慢)。

JVM虚拟机的启动是多线程的,至少有两个线程:main 和垃圾回收机制。

线程状态
创建状态(new一个线程) -- Thread.State.NEW
就绪状态(调用 start() 方法,等cpu调度) -- Thread.State.RUNNABLE
运行状态(cpu开始调度)
阻塞状态(调用sleep、wait或同步锁定时,阻塞状态解除后,重新进入就绪状态)
死亡状态(线程中断或结束)

反映到jstack中的状态
RUNNABLE,在虚拟机内执行的。运行中状态,可能里面还能看到locked字样,表明它获得了某把锁。
BLOCKED,受阻塞并等待监视器锁。被某个锁(synchronizers)給block住了。
WATING,无限期等待另一个线程执行特定操作。等待某个condition或monitor发生,一般停留在park(), wait(), sleep(),join() 等语句里。
TIMED_WATING,有时限的等待另一个线程的特定操作。和WAITING的区别是wait() 等语句加上了时间限制 wait(timeout)。
TERMINATED,已退出的。

每个对象都有一个锁,sleep不会释放锁

yield 线程礼让 -- 暂停当前线程,从运行状态回到就绪状态,不会造成阻塞,让cpu重新调度,但不一定礼让成功
join 线程合并 -- 待此线程执行完成后,再执行其他线程,会造成其他线程阻塞,类似于插队

线程分为用户线程(main线程以及创建的线程)和守护线程(gc线程等)
thread.setDaemon(true);// 默认为false即用户线程,虚拟机不会等待守护线程执行完毕,只需要保证用户线程执行结束,程序就结束了

1. 实现方法

1.1 通过 extends Thread类,重写Thread的run()方法,将线程运行的逻辑放在其中。

1.2 通过 implement Runnable接口,实例化Thread类,如果要有返回值的话,就 implement Callable接口,通过 Future 来接收所有的返回值

1.3 通过线程池

ExecutorService继承树

接口 Executor 只有一个方法 void execute(Runnable command);
在这个接口的基础上,提供一个更有拓展性的接口 ExecutorService,里面的方法有 submit(Callable<T> task) submit(Runnable task); shutdown() 等等
ThreadPoolExecutor 类是提供了一个更有拓展性的线程池实现类。在ThreadPoolExecutor的execute方法中会判断线程池是否可用,如果可用,就会获取线程池的锁(ReentrantLock),然后将任务加入任务队列。
Executors 类提供了方便的工厂方法

submit 和 execute 最大的区别在于异常处理上,使用 execute 的时候,如果没有实现一个 handler,那么就是用默认的handler来处理异常,如果实现了一个handler就会使用实例化的handler。
还可以改写 ThreadPoolExecutor.afterExecute() 中自定义异常。但是对于 submit 来说,异常是绑定到 Future 上,只有调用 future.get() 的时候,才会抛出异常,这意味着自定义的handler是没有用的。
如果关心线程执行的结果,那么就使用 submit, 在通过 future.get() 取结果的时候拿到异常;如果不关心这个任务的结果,就直接使用 ExecutorService.execute(Runnable) 方法,实际是继承了 Executor 接口来直接执行任务。

2. 具体实现

2.1 将需要多次调用的方法封装成一个类,实现 Runnable 或 Callable 接口。每调用一次相当于多一个线程。比如读取多个文件,for循环遍历目录,对文件的处理就封装成一个类,不断调用,相当于多个不同的线程同时在处理不同的文件。

2.2 创建线程池

public ThreadPoolExecutor(int corePoolSize,
    int maximumPoolSize,
    long keepAliveTime,
    TimeUnit unit,
    BlockingQueue<Runnable> workQueue,
    RejectedExecutionHandler handler) {
    this(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue,
        Executors.defaultThreadFactory(), handler);
}

corePoolSize:核心线程数 => 线程池能维护的最小线程数
maxPoolSize:最大线程数 => 线程池能维护的最大线程数 = 核心线程数+非核心线程数
keepAliveTime:线程池维护线程所允许的空闲时间 => 非核心线程闲置超时时长
unit:线程池维护线程所允许的空闲时间的单位
workQueue:线程池所使用的缓冲队列
handler:线程池对拒绝任务的处理策略

private ThreadPoolExecutor executor;
executor = new ThreadPoolExecutor(corePoolSize, maxPoolSize, 0L, TimeUnit.MILLISECONDS,
    new ArrayBlockingQueue<Runnable>(1000), new ThreadPoolExecutor.CallerRunsPolicy());
  • 如果此时线程池中的数量小于corePoolSize,即使线程池中的线程都处于空闲状态,也要创建新的线程来处理被添加的任务。
  • 如果此时线程池中的数量等于corePoolSize,但是缓冲队列 workQueue未满,那么任务被放入缓冲队列。
  • 如果此时线程池中的数量大于corePoolSize,缓冲队列workQueue满,并且线程池中的数量小于maximumPoolSize,建新的线程来处理被添加的任务。
  • 如果此时线程池中的数量大于corePoolSize,缓冲队列workQueue满,并且线程池中的数量等于maximumPoolSize,那么通过 handler所指定的策略来处理此任务。也就是:处理任务的优先级为:核心线程corePoolSize、任务队列workQueue、最大线程maximumPoolSize,如果三者都满了,使用handler处理被拒绝的任务。
  • 当线程池中的线程数量大于 corePoolSize时,如果某线程空闲时间超过keepAliveTime,线程将被终止。

shutdown(): 线程池不会立即关闭,只是不再接收新的任务(不再添加新的线程),直到所有已提交的线程都执行完成才会关闭。
shutdownnow(): 跳过所有正在执行的任务和已提交还没有执行的任务,正在执行的任务可能会停止也可能执行完成。

3. 线程安全和线程同步

为了提高资源利用效率 -> 多线程会对同一个资源同时进行操作(线程异步)-> 这样的线程在运行时是不安全的,所以引入同步 —> 当线程A对某一个资源进行操作时,其他线程必须等待。

多线程访问一个类,如果不用考虑这些线程在运行时环境下的调度和交替执行,并且不需要额外的同步,这个类的行为仍然是正确的,那么称这个类是线程安全的。

线程同步的实现条件:队列(线程排队来获取一个对象进行操作)+锁(每一个对象都有一个锁)

3.1 synchronized

关于锁的用法1
关于锁的用法2
synchronized和对象头

方法锁、类锁,能确保同步的前提是,锁的那部分代码指向的应该是内存中的同一个地址

synchronized(需要加锁的共享变量){
    // 同步代码块
}

public synchronized void xxx(){
    // 同步方法
}

所以某一个线程进入synchronized代码块前后,执行过程入如下:
a.线程获得互斥锁
b.清空工作内存
c.从主内存拷贝共享变量最新的值到工作内存成为副本
d.执行代码
e.将修改后的副本的值刷新回主内存中
f.线程释放锁
随后,其他代码在进入synchronized代码块的时候,所读取到的工作内存上共享变量的值都是上一个线程修改后的最新值。

synchronized 的实现依赖于对象头。对象在内存中存储的布局可以分为三块区域:

  • 对象头(Header)
    • 第一部分是类型指针,用于表示是哪一个类的对象
    • 第二部分存储了关于对象运行时的数据,比如GC年龄,hashcode,锁状态标志等,这一部分也被称为Mark Word。
  • 实例数据(Instance Data)
  • 对齐填充(Padding)

一个对象可以是无锁状态,偏向锁状态(当前线程检查对象头没有存储其他线程,那么当前线程用CAS替换Mark Word,将对象头中的Mark Word指向当前线程自己),轻量级锁(对象头中存储了其他线程,当前线程自旋来获取锁),重量级锁(如果当前线程一直自旋却始终无法获取锁,那么锁会膨胀到重量级锁)

3.2 volatile

当一个变量定义为 volatile 之后,它将具备两种特性:

  • 保证此变量对所有线程的可见性
    volatile变量每次被线程访问时,都强迫线程从主内存中重读该变量的最新值,而当该变量发生修改变化时,也会强迫线程将最新的值刷新回主内存中。这样一来,不同的线程都能及时的看到该变量的最新值。
  • 禁止指令重排序优化
    指令重排序是JVM为了优化指令,提高运行效率,在不影响 单线程程序 执行结果的前提下,尽可能地提高并行度。但是在多线程情况下,指令重排序可能会影响结果。
    • 重排序遵守两个规则
      • as-if-serial规则:指不管如何重排序(编译器与处理器为了提高并行度),单线程程序的结果不能被改变。
      • happens-before规则:
        • 程序顺序规则:一个线程中的每个操作,happens-before于线程中的任意后续操作。
        • 监视器锁规则:一个锁的解锁,happens-before于随后对这个锁的加锁。
        • volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读。
        • 传递性:如果(A)happens-before(B),且(B)happens-before(C),那么(A)happens-before(C)。
        • 线程start()规则:主线程A启动线程B,线程B中可以看到主线程启动B之前的操作。也就是start() happens-before 线程B中的操作。
        • 线程join()规则:主线程A等待子线程B完成,当子线程B执行完毕后,主线程A可以看到线程B的所有操作。也就是说,子线程B中的任意操作,happens-before join()的返回。
        • 中断规则:一个线程调用另一个线程的interrupt,happens-before于被中断的线程发现中断。
        • 终结规则:一个对象的构造函数的结束,happens-before于这个对象finalizer的开始。
        • 概念:前一个操作的结果可以被后续的操作获取。讲直白点就是前面一个操作把变量a赋值为1,那后面一个操作肯定能知道a已经变成了1

但是volatile不能保证变量更改的原子性:
比如number++,这个操作实际上是三个操作的集合(读取number,number加1,将新的值写回number),volatile只能保证每一步的操作对所有线程是可见的,但是假如两个线程都需要执行number++,那么这一共6个操作集合,之间是可能会交叉执行的,那么最后导致 number 的结果可能会不是所期望的。所以对于number++这种非原子性操作,推荐用synchronized

synchronized(this){
     number++;   
}

volatile适用情况

  • 对变量的写入操作不依赖当前值
    • 比如自增自减、number = number + 5等是不适用的;如果是new Date() 这样的,是可以使用的;直接对变量进行赋值比如修改布尔值,是可以使用的
  • 当前volatile变量不依赖于别的volatile变量
    • 比如 volatile_var > volatile_var2这个不等式是不适用的

synchronized和volatile比较

  1. volatile不需要同步操作,所以效率更高,不会阻塞线程,但是适用情况比较窄
  2. volatile读变量相当于加锁(即进入synchronized代码块),而写变量相当于解锁(退出synchronized代码块)
  3. synchronized既能保证共享变量可见性,也可以保证锁内操作的原子性;volatile只能保证可见性

volatile是如何防止指令重排序优化的呢?
答:

volatile关键字通过 “内存屏障” 的方式来防止指令被重排序,为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。大多数的处理器都支持内存屏障的指令。

对于编译器来说,发现一个最优布置来最小化插入屏障的总数几乎不可能,为此,Java内存模型采取保守策略。下面是基于保守策略的JMM内存屏障插入策略:

在每个volatile写操作的前面插入一个StoreStore屏障。
在每个volatile写操作的后面插入一个StoreLoad屏障。
在每个volatile读操作的后面插入一个LoadLoad屏障。
在每个volatile读操作的后面插入一个LoadStore屏障。

知识拓展:内存屏障:

内存屏障(Memory Barrier,或有时叫做内存栅栏,Memory Fence)是一种CPU指令,用于控制特定条件下的重排序和内存可见性问题。Java编译器也会根据内存屏障的规则禁止重排序。

内存屏障可以被分为以下几种类型:

LoadLoad屏障:对于这样的语句Load1; LoadLoad; Load2,在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。
StoreStore屏障:对于这样的语句Store1; StoreStore; Store2,在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见。
LoadStore屏障:对于这样的语句Load1; LoadStore; Store2,在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕。
StoreLoad屏障:对于这样的语句Store1; StoreLoad; Load2,在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。它的开销是四种屏障中最大的。在大多数处理器的实现中,这个屏障是个万能屏障,兼具其它三种内存屏障的功能。

3.3 原子类

如果需要用到自增等,可以使用 AtomicInteger 类,可以自动保证变量累加的原子性(设置为 static,所有线程只保留一份)
通过 AtomicInteger.addAndGet(int num)) 来进行增加,通过 get() 方法来获取当前的值

线程睡眠时,它所持的任何锁都不会释放

线程池是如何保证线程安全的?
1.线程池任务调度使用ReentrantLock保证任务不会被重复执行
任务队列必须是BlockQueue类型的,BlockQueue的子类保证队列的出入的线程安全。
2.线程池的worker节点继承了AbstractQueueSynchronizer()
当worker在运行任务前上锁,在任务运行结束后解锁。上锁后,不会响应中断,保证开始运行的任务不会被其他线程中断。只有任务结束,才会被中断。
3.worker的锁是不可重入的锁
防止线程池操作调整大小,获取数量等操作时,中断线程,导致任务没有完整的被执行。

同步的前提:
  1、必须要有两个或者两个以上的线程。
  2、必须是多个线程使用同一个锁。
  3、必须保证同步中只能有一个线程在运行。
  4、只能同步方法,不能同步变量和类。
  5、不必同步类中所有方法,类可以拥有同步和非同步的方法。
  6、如果一个线程在对象上获得一个锁,就没有任何其他线程可以进入(该对象的)类中的任何一个同步方法。
  7、线程睡眠时,它所持的任何锁都不会释放。

  好处:解决了多线程的安全问题。
  弊端:多个线程需要判断,消耗资源,降低效率。

  如何找问题?
  1、明确哪些代码是多线程运行代码。
  2、明确共享数据。
  3、明确多线程运行代码中哪些语句是操作共享数据的。

posted @ 2020-10-22 11:07  猫熊小才天  阅读(76)  评论(0编辑  收藏  举报