java多线程总结

文章目录

      • 1、JUC中locks相关问题
        • 1.1、AQS和独占模式下的实现ReentrantLock
          • 1.1.1、AbstractQueuedSynchronizer和ReentrantLock介绍
          • 1.1.2、ReentrantLock中非公平锁的实现方式
          • 1.1.3、ReentrantLock中公平锁的实现方式
          • 1.1.4、非公平锁和公平锁的区别
          • 1.1.5、Condition机制
        • 1.2、synchronized锁
          • 1.2.1、synchronized和reentrantLock的区别
          • 1.2.2、synchronized用在代码块、方法、静态方法时锁的都是什么
          • 1.2.3、Java虚拟机对synchronized的优化
        • 1.3、volatile
        • 1.3、volatile作用和多线程下不安全性的情况
          • 1.3.2、volatile是如何保证可见性和有序性
          • 1.3.3、volatile一个应用场景例子
      • 2、线程相关问题
        • 2.1、线程堵塞和唤醒
        • 2.2、线程中断
        • 2.3、线程状态
        • 2.4、线程池
          • 2.4.1、如何创建线程
          • 2.4.2、线程池介绍
          • 2.4.3、创建一个自定义的线程池
          • 2.4.4、常用线程池模板
          • 2.4.5、线程实现原理详解
        • 2.5、异步获取结果
        • 2.6、守护线程
      • 3、并发容器
        • 3.1、HashMap
        • 3.2、ConcurrentHashMap
      • 4、多线程下ThreadLocal问题

1、JUC中locks相关问题

1.1、AQS和独占模式下的实现ReentrantLock

1.1.1、AbstractQueuedSynchronizer和ReentrantLock介绍

AbstractQueuedSynchronizer又称为队列同步器(后面简称AQS),它是用来构建锁或其他同步组件的基础框架,内部通过一个int类型的成员变量state来控制同步状态,当state=0时,则说明没有任何线程占有共享资源的锁,当state=1时,则说明有线程目前正在使用共享变量,其他线程必须加入同步队列进行等待,AQS内部通过内部类Node构成FIFO的同步队列来完成线程获取锁的排队工作,同时利用内部类ConditionObject构建等待队列,当Condition调用wait()方法后,线程将会加入等待队列中,而当Condition调用signal()方法后,线程将从等待队列转移动同步队列中进行锁竞争。注意这里涉及到两种队列,一种的同步队列,当线程请求锁而等待的后将加入同步队列等待,而另一种则是等待队列(可有多个),通过Condition调用await()方法释放锁后,将加入等待队列。

在AQS中没有实现tryAcquire和tryRelease获取独占形式下的锁,当然也没有实现tryAcquireShared和tryReleaseShared获取共享模式下的锁。我们先来看下独占模式下的实现ReentrantLock,ReentrantLock内部存在3个实现类,分别是Sync、NonfairSync、FairSync,其中Sync继承自AQS实现了解锁tryRelease()方法,而NonfairSync(非公平锁)、 FairSync(公平锁)则继承自Sync,实现了获取锁的tryAcquire()方法,ReentrantLock的所有方法调用都通过间接调用AQS和Sync类及其子类来完成的。

在AQS中怎么样表示一个线程占用了这把锁?通过AQS中state值和exclusiveOwnerThread值来通过表示的,首先state如果为0则表示当前锁还没有被任何线程占用,如果为正数,则表示当前的锁已经被某一个线程占用了,注意这个state不一定为1,因为ReentrantLock支持可重入,所以这个state可以大于1,state为n表示这个锁已经重入了n次,在之后释放的也需要释放n次。这里再说明一个ReentrantLock和synchronized之间的不同点,就是synchronized是不需要手动释放锁的,synchronized会自动进行释放,即使在碰到异常时jvm也会帮我们自动释放掉这个锁,但是ReentrantLock是自己实现的一种锁,synchronized是java中的一个关键字。ReentrantLock需要我们自己进行手动释放,我们需要考虑在碰到异常的情况下也保证ReentrantLock能够释放锁,所以这里一般情况下需要将释放锁的操作放在finally语句中。

1.1.2、ReentrantLock中非公平锁的实现方式

在非公平锁模式下首先使用cas去尝试判断state状态值是否为0,如果为0则修改为1,将state从0修改为1则表示获取锁成功,如果从0修改为1不成功说明已经有线程占用这把锁,这时候还会去判断当前准备获取锁的线程是否是占有锁的线程,如果是的话,则state+1进行了锁重入,注意因为是在多线程情况下,所以凡是涉及到修改值的操作都是用到了cas来保证(这里非公平锁是只要用户去调用lock就马上去尝试获取锁,不同于公平锁的判断前面是否有其他线程在等待获取锁)。如果没有获取到锁,则将当前线程包裹成的node加入AQS中的同步队列的末尾,(这边因为涉及到修改AQS中的同步队列,所以也用到了cas)在加入到AQS的同步队列后还会去判断当前节点的前置节点是否为head,如果是head会再次去获取锁,因为在AQS中的同步队列head是不指向真正的节点的,如果这是新加入的前置节点是head,说明当前是同步队列中的第一个节点。如果这时候还是获取不到锁或者前置节点不为head,就会通过shouldParkAfterFailedAcquire方法 将head节点的waitStatus变为了SIGNAL=-1,最后执行parkAndChecknIterrupt方法,调用LockSupport.park()挂起当前线程。将head节点的waitStatus变为了SIGNAL=-1有什么用呢 就是表示当前的队列中的节点的等待状态,如果waitStatus为-1表示当前head指向的节点需要先进行unparking,就是需要先唤醒。具体调用release函数,首先会设置state和exclusiveOwnerThread的值,然后调用unparkSuccessor唤醒同步队列中的下一个节点。在上面在获取锁时没有成功获取到从而挂起当前线程的方法是放在for循环中的,所以当前一个节点释放掉锁后会唤醒下一个节点的线程,下一个节点的线程继续for缓存去尝试获取锁,如果获取到直接返回,如果还是没有获取到则接着挂起(注意因为是在非公平锁的情况下,所以刚被唤醒的线程也不一定能够获取到锁,可能会被刚去获取锁的线程抢先)。

1.1.3、ReentrantLock中公平锁的实现方式

公平锁在线程去获取锁的时候不会像非公平锁那样直接去获取锁,而是首先去判断当前AQS的同步队列中是否有节点,如果有同步队列中有节点的话,那么当前节点的线程不会尝试去获取锁,然后直接将当前线程包裹正node加入到同步队列中,在同步队列中会按照同步队列中节点的顺序依次去获取锁,没有获取到的就像非公平锁那样会被挂起。

1.1.4、非公平锁和公平锁的区别

非公平锁性能高于公平锁性能。非公平锁可以减少CPU唤醒线程的开销(因为唤醒一个线程A所需要的时间相对还是挺久的,这段时间非公平锁可能会有线程B在占用这个锁,然后在线程A真正醒过来之前线程B使用完后释放掉锁,这样也不影响线程A去占用锁,合理的利用了线程A被唤醒的这段时间),整体的吞吐效率会高点,CPU也不必取唤醒所有线程,会减少唤起线程的数量。非公平锁性能虽然优于公平锁,但是会存在导致线程饥饿的情况。在最坏的情况下,可能存在某个线程一直获取不到锁。不过相比性能而言,饥饿问题可以暂时忽略,这可能就是ReentrantLock默认创建非公平锁的原因之一了。

1.1.5、Condition机制

在并发编程中,每个Java对象都存在一组监视器方法,如wait()、notify()以及notifyAll()方法,通过这些方法,我们可以实现线程间通信与协作(也称为等待唤醒机制),如生产者-消费者模式,而且这些方法必须配合着synchronized关键字使用,与synchronized的等待唤醒机制相比Condition具有更多的灵活性以及精确性,这是因为notify()在唤醒线程时是随机(同一个锁),而Condition则可通过多个Condition实例对象建立更加精细的线程控制,也就带来了更多灵活性了。

Condition的具体实现类是AQS的内部类ConditionObject,注意在使用Condition前必须获得锁(因为如果你没有获取锁是不能操作这个函数的,线程还处于被挂起或者等待的状态),同时在Condition的等待队列上的结点与前面同步队列的结点是同一个类即Node。在实现类ConditionObject中有两个结点分别是firstWaiter和lastWaiter,firstWaiter代表等待队列第一个等待结点,lastWaiter代表等待队列最后一个等待结点,从而能够构造出一个等待队列。每个Condition都对应着一个等待队列,也就是说如果一个锁上创建了多个Condition对象,那么也就存在多个等待队列。等待队列是一个FIFO的队列,在队列中每一个节点都包含了一个线程的引用,而该线程就是Condition对象上等待的线程。

实现原理
具体的操作过程是挂起是使用了ConditionObject#await()函数,这个函数首先会将当前节点加入到Condition的等待队列中,然后尝试释放当前线程占用的锁并唤醒后继结点的线程,接着判断当前节点是否在AQS的同步队列中,如果没有在同步队列中,这时候需要将这个线程挂起,如果在同步队列中了,说明这个节点已经被ConditionObject#signal唤醒放入到同步队列中了。这时候需要去尝试获取锁了。当前节点就这样会在Condition的等待队列中默默的等待,直到被唤醒、中断、超时才从队列中移出。等待队列中结点的状态只有两种即CANCELLED和CONDITION,前者表示线程已结束需要从等待队列中移除,后者表示条件结点等待被唤醒。再次强调每个Codition对象对于一个等待队列,也就是说AQS中只能存在一个同步队列,但可拥有多个等待队列。唤醒是通过ConditionObject#signal来进行的,这个函数首先是去判断当前线程是否是占用锁的线程,如果不是则直接抛出异常,然后尝试从等待队列中移除被唤醒的节点,然后尝试将唤醒的节点加入到同步队列中,如果尝试加入同步队列失败并且等待队列中还有节点,那么就会接着唤醒下一个节点并尝试加入同步队列中,如果如果成功加入同步队列,那么如果其前驱结点如果已结束或者设置前驱节点状态为Node.SIGNAL状态失败,那么就会调用LockSupport.unpark()唤醒被通知节点代表的线程,然后尝试去获取锁。
java多线程总结_第1张图片

注意在上面整个过程中尝试获取锁的函数是acquireQueued(final Node node, int arg)函数,这个函数里用了for循环执行自旋操作争取锁,如果获取不到就会调用LockSupport.park来挂起函数,之后调用LockSupport.unpark唤醒的时候仍然在自旋争取锁,知道获取到锁。

优秀博客:
我画了35张图就是为了让你深入 AQS
深入理解(9)Java基于并发AQS的(独占锁)重入锁(ReetrantLock)及其Condition实现原理述

1.2、synchronized锁

1.2.1、synchronized和reentrantLock的区别
  • synchronized是java里的关键字,java虚拟机会自动释放掉这个锁,reentrantLock是实现在JUC中的一种锁,需要自己手动进行释放锁。
  • reentrantLock实现了等待可中断,就是线程A占用了一把reentrantLock锁,然后线程B在等待线程A释放,这时候如果线程B一致等不到线程A释放,可以停止等待获取锁,转而去做其他事情。而使用synchronized,如果ThreadA不释放,ThreadB将一直等待,不能被中断。
  • synchronized的锁是非公平锁,ReentrantLock默认情况下也是非公平锁,但可以通过带布尔值的构造函数要求使用公平锁。
  • 锁绑定多个条件,ReentrantLock可以同时绑定多个Condition对象,只需多次调用newCondition方法即可。synchronized中,锁对象的wait()和notify()或notifyAll()方法可以实现一个隐含的条件。但如果要和多于一个的条件关联的时候,就不得不额外添加一个锁。
1.2.2、synchronized用在代码块、方法、静态方法时锁的都是什么
  • 修饰实例方法,作用于当前实例加锁,进入同步代码前要获得当前实例的锁。注意这个当前实例也就是运行到当前程序时的实例对象,可以用this来获取,所以在多线程中我们如果创建两个对象分别用线程去运行,这时候synchronized是锁不住这两个对象中调用被synchronized锁住的函数,因为这时候锁的对象不同。
  • 修饰静态方法,作用于当前类对象加锁,进入同步代码前要获得当前类对象的锁。注意这个时候锁住的是Class对象,不同于实例对象,这样即使不同的实例对象调用这个锁住的静态方法,synchronized都是由效果的,但是如果一个线程调用synchronized锁住的静态方法,一个线程调用synchronized锁住的实例方法,这个时候synchronized是没有效果的。
  • 修饰代码块,指定加锁对象,对给定对象加锁,进入同步代码库前要获得给定对象的锁。
1.2.3、Java虚拟机对synchronized的优化

锁的状态总共有四种,无锁状态、偏向锁、轻量级锁和重量级锁。随着锁的竞争,锁可以从偏向锁升级到轻量级锁,再升级的重量级锁,但是锁的升级是单向的,也就是说只能从低到高升级,不会出现锁的降级。

偏向锁
偏向锁是Java 6之后加入的新锁,它是一种针对加锁操作的优化手段,经过研究发现,在大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,因此为了减少同一线程获取锁(会涉及到一些CAS操作,耗时)的代价而引入偏向锁。偏向锁的核心思想是,如果一个线程获得了锁,那么锁就进入偏向模式,此时Mark Word 的结构也变为偏向锁结构,当这个线程再次请求锁时,无需再做任何同步操作,即获取锁的过程,这样就省去了大量有关锁申请的操作,从而也就提供程序的性能。

轻量级锁
倘若偏向锁失败,虚拟机并不会立即升级为重量级锁,它还会尝试使用一种称为轻量级锁的优化手段(1.6之后加入的),此时Mark Word 的结构也变为轻量级锁的结构。轻量级锁能够提升程序性能的依据是“对绝大部分的锁,在整个同步周期内都不存在竞争”,注意这是经验数据。需要了解的是,轻量级锁所适应的场景是线程交替执行同步块的场合,如果存在同一时间访问同一锁的场合,就会导致轻量级锁膨胀为重量级锁。

自旋锁
轻量级锁失败后,虚拟机为了避免线程真实地在操作系统层面挂起,还会进行一项称为自旋锁的优化手段。这是基于在大多数情况下,线程持有锁的时间都不会太长,如果直接挂起操作系统层面的线程可能会得不偿失,毕竟操作系统实现线程之间的切换时需要从用户态转换到核心态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高,因此自旋锁会假设在不久将来,当前的线程可以获得锁,因此虚拟机会让当前想要获取锁的线程做几个空循环(这也是称为自旋的原因),一般不会太久,可能是50个循环或100循环,在经过若干次循环后,如果得到锁,就顺利进入临界区。如果还不能获得锁,那就会将线程在操作系统层面挂起,这就是自旋锁的优化方式,这种方式确实也是可以提升效率的。最后没办法也就只能升级为重量级锁了。

锁消除
消除锁是虚拟机另外一种锁的优化,这种优化更彻底,Java虚拟机在JIT编译时(可以简单理解为当某段代码即将第一次被执行时进行编译,又称即时编译),通过对运行上下文的扫描,去除不可能存在共享资源竞争的锁,通过这种方式消除没有必要的锁,可以节省毫无意义的请求锁时间,如下StringBuffer的append是一个同步方法,但是在add方法中的StringBuffer属于一个局部变量,并且不会被其他线程所使用,因此StringBuffer不可能存在共享资源竞争的情景,JVM会自动将其锁消除。

优秀博客:深入理解Java并发之synchronized实现原理

1.3、volatile

1.3、volatile作用和多线程下不安全性的情况

因为volatile功能和上面的锁功能类似,都是多线程之间同步问题的解决方案,所以这里将这个也放在这里。volatile是Java虚拟机提供的轻量级的同步机制。volatile关键字有如下两个作用

  • 保证被volatile修饰的共享变量对所有线程总数可见的,也就是当一个线程修改了一个被volatile修饰共享变量的值,新值总数可以被其他线程立即得知
  • 禁止指令重排序优化

要注意volatile是Java虚拟机提供的轻量级的同步机制,所以它并不能保证多线程操作的安全性,例如在

public class VolatileVisibility {
     
    public static volatile int i =0;

    public static void increase(){
     
        i++;
    }
}

这种情况下多个线程同时调用increase函数并不能保证最后的结果一定是准确的,毕竟i++;操作并不具备原子性,该操作是先读取值,然后写回一个新值,相当于原来的值加上1,分两步完成,如果第二个线程在第一个线程读取旧值和写回新值期间读取i的域值,那么第二个线程就会与第一个线程一起看到同一个值,并执行相同值的加1操作,这也就造成了线程安全失败,因此对于increase方法必须使用synchronized修饰,以便保证线程安全,需要注意的是一旦使用synchronized修饰方法后,由于synchronized本身也具备与volatile相同的特性,即可见性,因此在这样种情况下就完全可以省去volatile修饰变量。

所以上面这种情况就是保证了有序性和可见性,但是原子性没有保证,导致了多线程下的数据异常。如果我们把上面i++换成i=2,因为赋值操作是具有原子性的,所以更换以后这三个性质都能保证,这样也就能保证在多线程下的安全性。

1.3.2、volatile是如何保证可见性和有序性

可见性
JMM是如何实现让volatile变量对其他线程立即可见的呢?实际上,当写一个volatile变量时,JMM会把该线程对应的工作内存中的共享变量值刷新到主内存中,当读取一个volatile变量时,JMM会把该线程对应的工作内存置为无效,那么该线程将只能从主内存中重新读取共享变量。volatile变量正是通过这种写-读方式实现对其他线程可见(但其内存语义实现则是通过内存屏障)

有序性
这里主要简单说明一下volatile是如何实现禁止指令重排优化的。先了解一个概念,内存屏障(Memory Barrier)。
内存屏障,又称内存栅栏,是一个CPU指令,它的作用有两个,一是保证特定操作的执行顺序,二是保证某些变量的内存可见性(利用该特性实现volatile的内存可见性)。由于编译器和处理器都能执行指令重排优化。如果在指令间插入一条Memory Barrier则会告诉编译器和CPU,不管什么指令都不能和这条Memory Barrier指令重排序,也就是说通过插入内存屏障禁止在内存屏障前后的指令执行重排序优化。Memory Barrier的另外一个作用是强制刷出各种CPU的缓存数据,因此任何CPU上的线程都能读取到这些数据的最新版本。总之,volatile变量正是通过内存屏障实现其在内存中的语义,即可见性和禁止重排优化。

1.3.3、volatile一个应用场景例子
public class DoubleCheckLock {
     

    private static DoubleCheckLock instance;

    private DoubleCheckLock(){
     }

    public static DoubleCheckLock getInstance(){
     

        //第一次检测
        if (instance==null){
     
            //同步
            synchronized (DoubleCheckLock.class){
     
                if (instance == null){
     
                    //多线程环境下可能会出现问题的地方
                    instance = new DoubleCheckLock();
                }
            }
        }
        return instance;
    }
}

上述代码一个经典的单例的双重检测的代码,这段代码在单线程环境下并没有什么问题,但如果在多线程环境下就可以出现线程安全问题。原因在于某一个线程执行到第一次检测,读取到的instance不为null时,instance的引用对象可能没有完成初始化。因为instance = new DoubleCheckLock();可以分为以下3步完成(伪代码)

memory = allocate(); //1.分配对象内存空间
instance(memory);    //2.初始化对象
instance = memory;   //3.设置instance指向刚分配的内存地址,此时instance!=null

由于步骤1和步骤2间可能会重排序,如下:

memory = allocate(); //1.分配对象内存空间
instance = memory;   //3.设置instance指向刚分配的内存地址,此时instance!=null,但是对象还没有初始化完成!
instance(memory);    //2.初始化对象

由于步骤2和步骤3不存在数据依赖关系,而且无论重排前还是重排后程序的执行结果在单线程中并没有改变,因此这种重排优化是允许的。但是指令重排只会保证串行语义的执行的一致性(单线程),但并不会关心多线程间的语义一致性。所以当一条线程访问instance不为null时,由于instance实例未必已初始化完成,也就造成了线程安全问题。那么该如何解决呢,很简单,我们使用volatile禁止instance变量被执行指令重排优化即可。

//禁止指令重排优化
  private volatile static DoubleCheckLock instance;

2、线程相关问题

2.1、线程堵塞和唤醒

在java中wait、sleep和yield函数可以将线程休眠从而堵塞线程。这三者之间还是有些区别的。首先是sleep和yield函数是定义在Thread类中,而wait方法是定义在Object类中的。

wait方法的特殊性
在使用wait方法的时候,必须处于synchronized代码块或者synchronized方法中,否则就会抛出IllegalMonitorStateException异常,这是因为调用这几个方法前必须拿到当前对象的监视器monitor对象,也就是说notify/notifyAll和wait方法依赖于monitor对象
。我们知道monitor 存在于对象头的Mark Word 中(存储monitor引用指针),而synchronized关键字可以获取 monitor ,所以会有这种要求。例如

synchronized (obj) {
     
       obj.wait();
       obj.notify();
       obj.notifyAll();         
 }

在这里这三个函数就可以正常使用。

wait()和sleep()的关键的区别,在于wait()是用于线程间通信的(Java中的wait/notify/notifyAll可用来实现线程间通信),而sleep()是用于短时间暂停当前线程。更加明显的一个区别在于,当一个线程调用wait()方法的时候,会释放它锁持有的对象的管程和锁,但是调用sleep()方法的时候,不会释放他所持有的管程和锁。

yield()方法的不同性,与wait()和sleep()方法有一些区别,它仅仅释放线程所占有的CPU资源,从而让其他线程有机会运行,但是并不能保证某个特定的线程能够获得CPU资源。谁能获得CPU完全取决于调度器,在有些情况下调用yield方法的线程甚至会再次得到CPU资源。所以,依赖于yield方法是不可靠的,它只能尽力而为。

Thread.sleep(0)的作用, 在操作系统中,cpu竞争有很多中策略,Unix系统使用的是时间分片法,而Windows则属于抢占式。时间分片法就是操作系统给所有的进程排序,然后依次给每一个进程分配一段时间。而抢占式系统中,则操作系统会根据他们的优先级饥饿时间给他们算出一个总的优先级来,然后操作系统就会把CPU交给总优先级最高的这个进程。如果一个进程的优先度很高,那么操作系统可能一直会让这个进程占用cpu,但是如果这个进程不想使用了,这时就可以使用Thread.sleep(n),就是告诉操作系统在n时间内我不抢占cpu,不要让我获取到。但是如果只是让操作系统重新选择合适的进程来占用cpu呢?我们可以使用yield告诉操作系统重新选择合适的进程来占用cpu,也可以通过Thread.sleep(0),这个也是告诉操作系统和重新选择进程占用cpu。

优秀博客:
Java中Wait、Sleep和Yield方法的区别
Java线程间通信之wait/notify

2.2、线程中断

在Java中,提供了以下3个有关线程中断的方法

//中断线程(实例方法)
public void Thread.interrupt();

//判断线程是否被中断(实例方法)
public boolean Thread.isInterrupted();

//判断是否被中断并清除当前中断状态(静态方法)
public static boolean Thread.interrupted();

线程中断的两种情况
一种是当线程处于阻塞状态或者试图执行一个阻塞操作时,我们可以使用实例方法interrupt()进行线程中断,执行中断操作后将会抛出interruptException异常(该异常必须捕捉无法向外抛出)并将中断状态复位。例如我们开启一个线程后调用sleep将这个线程堵塞,这时候这个线程就会抛出interruptException异常,但是必须要在这个函数中取捕获这个异常,然后重置中断状态,利用interrupted可以重置状态。还有一种情况是当线程处于运行状态时,我们也可调用实例方法interrupt()进行线程中断,但同时必须手动判断中断状态,并编写中断线程的代码(其实就是结束run方法体的代码)有时我们在编码时可能需要兼顾以上两种情况,那么就可以如下编写

public void run(){
     
    try {
     
    //判断当前线程是否已中断,注意interrupted方法是静态的,执行后会对中断状态进行复位
    while (!Thread.interrupted()) {
     
        TimeUnit.SECONDS.sleep(2);
    }
    } catch (InterruptedException e) {
     
    }
}

上面是线程在运行或者堵塞的时候进行线程中断的情况,那如果线程正在等待获取synchronized锁的时候进行中断是什么情况? 事实上线程的中断操作对于正在等待获取的锁对象的synchronized方法或者代码块并不起作用,也就是对于synchronized来说,如果一个线程在等待锁,那么结果只有两种,要么它获得这把锁继续执行,要么它就保存等待,即使调用中断线程的方法,也不会生效。

一文搞懂 Java 线程中断

2.3、线程状态

线程一共有五个状态,分别如下

  • 新建(new):当创建Thread类的一个实例(对象)时,此线程进入新建状态(未被启动)。例如:Thread t1 = new Thread()
  • 可运行(runnable):线程对象创建后,其他线程(比如 main 线程)调用了该对象的 start 方法。该状态的线程位于可运行线程池中,等待被线程调度选中,获取 cpu 的使用权。例如:t1.start()
  • 运行(running):线程获得 CPU 资源正在执行任务(#run() 方法),此时除非此线程自动放弃 CPU 资源或者有优先级更高的线程进入,线程将一直运行到结束
  • 死亡(dead):当线程执行完毕或被其它线程杀死,线程就进入死亡状态,这时线程不可能再进入就绪状态等待执行
    自然终止:正常运行完 #run()方法,终止
    异常终止:调用 #stop() 方法,让一个线程终止运行
  • 堵塞(blocked):由于某种原因导致正在运行的线程让出 CPU 并暂停自己的执行,即进入堵塞状态。直到线程进入可运行(runnable)状态,才有机会再次获得 CPU 资源,转到运行(running)状态。阻塞的情况有三种:
    正在睡眠:调用 #sleep(long t) 方法,可使线程进入睡眠方式。一个睡眠着的线程在指定的时间过去可进入可运行(runnable)状态
    正在等待:调用 #wait() 方法。调用 notify() 方法,回到就绪状态
    被另一个线程所阻塞:调用 #suspend() 方法。调用 #resume() 方法,就可以恢复。

java多线程总结_第2张图片

2.4、线程池

2.4.1、如何创建线程

Java 中创建线程主要有三种方式:

  • 继承 Thread 类创建线程类
    • 优点:编写简单,如果需要访问当前线程,则无需使用 Thread#currentThread() 方法,直接使用 this 即可获得当前线程。
    • 缺点:线程类已经继承了 Thread 类,所以不能再继承其他父类。
  • 通过 Runnable 接口创建线程类
  • 通过 Callable 和 Future 创建线程
    • 优点:线程类只是实现了 Runnable 接口或 Callable 接口,还可以继承其他类。
      在这种方式下,多个线程可以共享同一个 target 对象,所以非常适合多个相同线程来处理同一份资源的情况,从而可以将 CPU、代码和数据分开,形成清晰的模型,较好地体现了面向对象的思想。并且可以使用线程池。
    • 缺点:编程稍微复杂,如果要访问当前线程,则必须使用Thread#currentThread() 方法。
2.4.2、线程池介绍

Executor 框架,是一个根据一组执行策略调用,调度,执行和控制的异步任务的框架。

无限制的创建线程,会引起应用程序内存溢出。所以创建一个线程池是个更好的的解决方案,因为可以限制线程的数量并且可以回收再利用这些线程。利用 Executor 框架,可以非常方便的创建一个线程池。

直接new Thread的弊端

  • 每次执行任务创建线程 new Thread() 比较消耗性能,创建一个线程是比较耗时、耗资源的。
  • 调用 new Thread() 创建的线程缺乏管理,被称为野线程,而且可以无限制的创建,线程之间的相互竞争会导致过多占用系统资源而导致系统瘫痪,还有线程之间的频繁交替也会消耗很多系统资源。
  • 接使用 new Thread() 启动的线程不利于扩展,比如定时执行、定期执行、定时定期执行、线程中断等都不便实现。

线程池执行任务过程

  • 如果正在运行的线程数量小于核心参数 corePoolSize ,继续创建线程运行这个任务
  • 否则,如果正在运行的线程数量大于或等于 corePoolSize ,将任务加入到阻塞队列中。
  • 否则,如果队列已满,同时正在运行的线程数量小于核心参数 maximumPoolSize ,继续创建线程运行这个任务。
  • 否则,如果队列已满,同时正在运行的线程数量大于或等于 maximumPoolSize ,根据设置的拒绝策略处理。
2.4.3、创建一个自定义的线程池

Executors 提供了创建线程池的常用模板,实际场景下,我们可能需要自动以更灵活的线程池,此时就需要使用 ThreadPoolExecutor 类。

public ThreadPoolExecutor(int corePoolSize,
                          int maximumPoolSize,
                          long keepAliveTime,
                          TimeUnit unit,
                          BlockingQueue<Runnable> workQueue,
                          ThreadFactory threadFactory,
                          RejectedExecutionHandler handler) {
     
    if (corePoolSize < 0 ||
        maximumPoolSize <= 0 ||
        maximumPoolSize < corePoolSize ||
        keepAliveTime < 0)
        throw new IllegalArgumentException();
    if (workQueue == null || threadFactory == null || handler == null)
        throw new NullPointerException();
    this.corePoolSize = corePoolSize;
    this.maximumPoolSize = maximumPoolSize;
    this.workQueue = workQueue;
    this.keepAliveTime = unit.toNanos(keepAliveTime);
    this.threadFactory = threadFactory;
    this.handler = handler;
}
  • corePoolSize 参数,核心线程数大小,当线程数 < corePoolSize ,会创建线程执行任务。
  • maximumPoolSize 参数,最大线程数, 当线程数 >= corePoolSize 的时候,会把任务放入 workQueue 队列中。
  • keepAliveTime 参数,保持存活时间,当线程数大于 corePoolSize 的空闲线程能保持的最大时间。
  • unit 参数,时间单位。
  • workQueue 参数,保存任务的阻塞队列。
  • threadFactory 参数,创建线程的工厂。
  • handler 参数,超过阻塞队列的大小时,使用的拒绝策略。java中拒绝策略有四种分别是
    • ThreadPoolExecutor.AbortPolicy() ,直接抛出异常 RejectedExecutionException 。
    • ThreadPoolExecutor.CallerRunsPolicy() ,直接调用 run 方法并且阻塞执行。在java中的实现就是直接调用ThreadPoolExecutor#execute方法。
    • hreadPoolExecutor.DiscardPolicy() ,直接丢弃后来的任务。在java中实现就是在rejectedExecution函数中什么也不操作,包括调用执行器ThreadPoolExecutor执行execute方法
    • ThreadPoolExecutor.DiscardOldestPolicy() ,丢弃在队列中队首的任务。在java代码中实现就是直接丢弃等待队列中的队首任务,然后调用ThreadPoolExecutor#execute执行接下去的代码
2.4.4、常用线程池模板

java 类库提供一个灵活的线程池以及一些有用的默认配置,我们可以通过Executors 的静态方法来创建线程池。Executors 创建的线程池,分成普通任务线程池,和定时任务线程池。

普通线程池

  • newFixedThreadPool(int nThreads) 方法,创建一个固定长度的线程池。每当提交一个任务就创建一个线程,直到达到线程池的最大数量,这时线程规模将不再变化。当线程发生未预期的错误而结束时,线程池会补充一个新的线程。具体调用
//若没有填写拒绝策略,ThreadPoolExecutor会调用另一个构造函数其中拒绝策略为默认的AbortPolicy,直接抛出异常,下面几种情况也也一样
public static ExecutorService newFixedThreadPool(int nThreads) {
     
        return new ThreadPoolExecutor(nThreads, nThreads,
                                      0L, TimeUnit.MILLISECONDS,
                                      new LinkedBlockingQueue<Runnable>());
    }
  • newCachedThreadPool() 方法,创建一个可缓存的线程池。如果线程池的规模超过了处理需求,将自动回收空闲线程。当需求增加时,则可以自动添加新线程。线程池的规模不存在任何限制。
public static ExecutorService newCachedThreadPool() {
     
        return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
                                      60L, TimeUnit.SECONDS,
                                      new SynchronousQueue<Runnable>());
    }
  • newSingleThreadExecutor() 方法,创建一个单线程的线程池。它创建单个工作线程来执行任务,如果这个线程异常结束,会创建一个新的来替代它。它的特点是,能确保依照任务在队列中的顺序来串行执行。
public static ExecutorService newSingleThreadExecutor() {
     
        return new FinalizableDelegatedExecutorService
            (new ThreadPoolExecutor(1, 1,
                                    0L, TimeUnit.MILLISECONDS,
                                    new LinkedBlockingQueue<Runnable>()));
    }

定时任务线程池

  • newScheduledThreadPool(int corePoolSize) 方法,创建了一个固定长度的线程池,而且以延迟或定时的方式来执行任务,类似 Timer 。
public ScheduledThreadPoolExecutor(int corePoolSize) {
     
        super(corePoolSize, Integer.MAX_VALUE, 0, NANOSECONDS,
              new DelayedWorkQueue());
    }

首先他会创建一个延时队列,具体执行为周期性的函数时调用ScheduledThreadPoolExecutor#scheduleAtFixedRate函数,在这个函数中会将当前任务添加到延迟队列中,在后面调用exector函数时会调用runwork函数,然后这个方法会先判断时间是否符合,如果符合就会去延迟队列(实际上也就是工作队列)中取出并调用Thread#run方法,最后执行到ScheduledThreadPoolExecutor.ScheduledFutureTask#run方法,在这个方法里会判断是否是周期性函数,如果是周期性函数,那么就设置下一个调用的时间,并将当前任务重新又扔到延迟队列中,这样就可以重复调用。
java多线程总结_第3张图片
优秀博客:详解scheduleAtFixedRate与scheduleWithFixedDelay原理

2.4.5、线程实现原理详解

ThreadPoolExecutor是如何运行,如何同时维护线程和执行任务的呢?其运行机制如下图所示:
java多线程总结_第4张图片
接下来,我们会按照以下三个部分去详细讲解线程池运行机制:

  • 线程池如何维护自身状态。
  • 线程池如何管理任务。
  • 线程池如何管理线程。

生命周期管理
线程池运行的状态,并不是用户显式设置的,而是伴随着线程池的运行,由内部来维护。线程池内部使用一个变量维护两个值:运行状态(runState)和线程数量 (workerCount)。在实现中线程池将运行状态(runState)、线程数量 (workerCount)两个关键参数的维护放在了一起。见如下

private final AtomicInteger ctl = new AtomicInteger(ctlOf(RUNNING, 0));

ctl它同时包含两部分的信息:线程池的运行状态 (runState) 和线程池内有效线程的数量 (workerCount),高3位保存runState,低29位保存workerCount,两个变量之间互不干扰。用一个变量去存储两个值,可避免在做相关决策时,出现不一致的情况,不必为了维护两者的一致,而占用锁资源。

ThreadPoolExecutor的运行状态有5种,分别为:
java多线程总结_第5张图片java多线程总结_第6张图片
在主循环中会去判断当前task是否为空,如果为空,则去任务队列中去取任务,同时需要定时判断是否当前线程池的状态为stop,如果线程池的状态为stop,需要对当前线程进行interrupted操作。

线程管理
线程池为了掌握线程的状态并维护线程的生命周期,设计了线程池内的工作线程Worker

private final class Worker extends AbstractQueuedSynchronizer implements Runnable{
     
    final Thread thread;//Worker持有的线程
    Runnable firstTask;//初始化的任务,可以为null
}

Worker这个工作线程,实现了Runnable接口,并持有一个线程thread,一个初始化的任务firstTask。thread是在调用构造方法时通过ThreadFactory来创建的线程,可以用来执行任务;firstTask用它来保存传入的第一个任务,这个任务可以有也可以为null。如果这个值是非空的,那么线程就会在启动初期立即执行这个任务,也就对应核心线程创建时的情况;如果这个值是null,那么就需要创建一个线程去执行任务列表(workQueue)中的任务,也就是非核心线程的创建。
java多线程总结_第7张图片
线程池需要管理线程的生命周期,需要在线程长时间不运行的时候进行回收。线程池使用一张Hash表去持有线程的引用,这样可以通过添加引用、移除引用这样的操作来控制线程的生命周期。这个时候重要的就是如何判断线程是否在运行。

程池在执行shutdown方法或tryTerminate方法时会调用interruptIdleWorkers方法来中断空闲的线程,interruptIdleWorkers方法会使用tryLock方法来判断线程池中的线程是否是空闲状态;如果线程是空闲状态则可以安全回收。因为空闲线程是属于堵塞状态的,所以直接调用interrupt函数会使目标线程运行时抛出一个异常从而可以安全退出。如果我们需要将这个异常返回给上一级,注意需要将这个异常再次手动抛出。

线程池中线程的销毁依赖JVM自动的回收,线程池做的工作是根据当前线程池的状态维护一定数量的线程引用,防止这部分线程被JVM回收,当线程池决定哪些线程需要回收时,只需要将其引用消除即可。Worker被创建出来后,就会不断地进行轮询,然后获取任务去执行,核心线程可以无限等待获取任务,非核心线程要限时获取任务。当Worker无法获取到任务,也就是获取的任务为空时,循环会结束,Worker会主动消除自身在线程池内的引用。

try {
     
  while (task != null || (task = getTask()) != null) {
     
    //执行任务
  }
} finally {
     
  processWorkerExit(w, completedAbruptly);//获取不到任务时,主动回收自己
}

线程回收的工作是在processWorkerExit方法完成的。
在这里插入图片描述
事实上,在这个方法中,将线程引用移出线程池就已经结束了线程销毁的部分。但由于引起线程销毁的可能性有很多,线程池还要判断是什么引发了这次销毁,是否要改变线程池的现阶段状态,是否要根据新状态,重新分配线程。

2.5、异步获取结果

Callable
这是一个接口类似于 Runnable ,从名字就可以看出来了,但是Runnable 不会返回结果,并且无法抛出返回结果的异常,而 Callable 功能更强大一些,被线程执行后,可以返回值,这个返回值可以被 Future 拿到,也就是说,Future 可以拿到异步执行任务的返回值。简单来说,可以认为是带有回调的 Runnable 。

Future
Future 接口,表示异步任务,是还没有完成的任务给出的未来结果。Future就是对于具体的Runnable或者Callable任务的执行结果进行取消、查询是否完成、获取结果。必要时可以通过get方法获取执行结果,该方法会阻塞直到任务返回结果。也就是说Future提供了三种功能:

  • 判断任务是否完成;
  • 能够中断任务;
  • 能够获取任务执行结果

因为Future只是一个接口,所以是无法直接用来创建对象使用的,因此就有了下面的FutureTask。

FutureTask
在 Java 并发程序中,FutureTask 表示一个可以取消的异步运算。

它有启动和取消运算、查询运算是否完成和取回运算结果等方法。只有当运算完成的时候结果才能取回,如果运算尚未完成 get 方法将会阻塞。
一个 FutureTask 对象,可以对调用了 Callable 和 Runnable 的对象进行包装,由于 FutureTask 也是继承了 Runnable 接口,所以它可以提交给 Executor 来执行。

使用Callable+Future获取执行结果示例

ExecutorService executor = Executors.newCachedThreadPool();
        Task task = new Task();
        Future<Integer> result = executor.submit(task);
        executor.shutdown();
                  
        System.out.println("主线程在执行任务");
         
        try {
     
            System.out.println("task运行结果"+result.get());
        } catch (InterruptedException e) {
     
            e.printStackTrace();
        } catch (ExecutionException e) {
     
            e.printStackTrace();
        }
         
        System.out.println("所有任务执行完毕");

使用Callable+FutureTask获取执行结果

ExecutorService executor = Executors.newCachedThreadPool();
        Task task = new Task();
        FutureTask<Integer> futureTask = new FutureTask<Integer>(task);
        executor.submit(futureTask);
        executor.shutdown();
         
        //第二种方式,注意这种方式和第一种方式效果是类似的,只不过一个使用的是ExecutorService,一个使用的是Thread
        /*Task task = new Task();
        FutureTask futureTask = new FutureTask(task);
        Thread thread = new Thread(futureTask);
        thread.start();*/       
         
        System.out.println("主线程在执行任务");
         
        try {
     
            System.out.println("task运行结果"+futureTask.get());
        } catch (InterruptedException e) {
     
            e.printStackTrace();
        } catch (ExecutionException e) {
     
            e.printStackTrace();
        }
         
        System.out.println("所有任务执行完毕");

优秀博客:Java并发编程:Callable、Future和FutureTask

2.6、守护线程

Java 中的线程分为两种:守护线程(Daemon)和用户线程(User)。任何线程都可以设置为守护线程和用户线程,通过方法Thread#setDaemon(boolean on) 设置。true 则把该线程设置为守护线程,反之则为用户线程。Thread#setDaemon(boolean on) 方法,必须在Thread#start() 方法之前调用,否则运行时会抛出异常。

唯一的区别是:程序运行完毕,JVM 会等待非守护线程完成后关闭,但是 JVM 不会等待守护线程。

判断虚拟机(JVM)何时离开,Daemon 是为其他线程提供服务,如果全部的 User Thread 已经撤离,Daemon 没有可服务的线程,JVM 撤离。也可以理解为守护线程是 JVM 自动创建的线程(但不一定),用户线程是程序创建的线程。比如,JVM 的垃圾回收线程是一个守护线程,当所有线程已经撤离,不再产生垃圾,守护线程自然就没事可干了,当垃圾回收线程是 Java 虚拟机上仅剩的线程时,Java 虚拟机会自动离开。

Thread Dump 打印出来的线程信息,含有 daemon 字样的线程即为守护进程。

3、并发容器

3.1、HashMap

注意hashmap并不是一种并发容器,而是为了引出concurrenthashmap需要先了解hashmap。HashMap是kv类型的容器,它的节点实现是Node类型,实现是数组加链表。里面有四个参数,分别为hash值:这个是通过hash函数对key进行hash求值获得的,用来判断一个节点应该存放在数组的哪一个位置,也用来快速判断一个节点是否key相等,首先可以判断hash值是否相等,如果hash值不相等的话那节点的key必定不想等。然后的key值,这个是final类型的,说明一旦赋值后是不能进行改变的,然后的value值,最后的指向下一个节点的next对象,通过next可以在数组的一个位置向下建立一条链表。

hashmap是通过懒加载的方式创建的,在一开始初始化的时候并不会创建这个数组只是做了一些参数的初始化,而是在putvalue的时候才会去创建,一开始创建的容量为16,之后如果在添加节点的过程中容量不够的话,就会进行扩容,每一次扩展为原来的两倍,但是不会只要大于或等于1>>30后,就不会在进行扩容了。

为什么每次需要扩容为2倍 为了能让 HashMap 存取高效,首先可以想到我们在判断一个节点放在数组的哪一个位置时会根据这个节点key的hash值求余来判断这个节点应该存放的位置,那么如果数组容量为2的幂的时候取余(%)操作等价于与其除数减一的与(&)操作(也就是说 hash%length==hash&(length-1)的前提是 length 是2的 n 次方。在用二进制来进行计算可以提高运算效率。

在hashMap中有一个参数threshold,是用capacity * loadfactor来求得的。这个loadfactor默认值为0.75f。这个threshold的作用是如果hashmap中的节点数量超过这个threshold值时就可以进行hashmap的扩容了。为什么这个loadfactor选择为0.75f 首先因为我们hashmap的容量为2的幂,这样选择0.75f和容量(容量大于等于4的时候)想乘得到的threshold一定为整数。还有一点就是0.75f算是平均扩容带来的系统消耗和hashmap中冲撞带来的消耗的一种较好的选择,因为如果loadfactor选择太小,那么就会频繁引起hashmap的扩容,给系统带来平白的消耗,如果loadfactor选择太大,那么在hashmap中碰撞已经很厉害了,也没有进行扩容,注意这里0.75f并不是一个最优解,只是相对较好的一种选择。

接着需要考虑的是如果在hashmap的数组上发生冲突,那么就需要在发生数组冲突的位置建立链表来解决冲突,但是如果链表太长的话,这样在极端情况下又退化成了单链表。在jdk1.8中对这个进行了优化,如果链表的长度为8的时候的就开始将这个链表转换成红黑树,这样有助于接下来的查找,在继续想这条链表上添加数据的时候回在这个红黑树上进行添加。如果删除节点使得这条链表上的节点数量小于8个,那么这时候又会自动将这个红黑树退化成链表。

hashmap的扩容中如何迁移节点? 这个也是通过hash值来进行判断的,我们知道扩容后的hashmap的容量为原来的两倍,在原来数组a位置的节点映射到新数组时,只有两种可能,将新的数组切成两边,映射到这两边的位置和老的位置一致。例如原来的数组大小为4,节点在3位置上(下标从1开始),那么映射到新表时,位置只会为3和7。所以在hashmap中创建了loHead,loTail,hiHead,hiTail四个节点来描述新hashmap中表两边的情况。因为hashmap是不支持在多线程情况下操作的,如果在多线程下操作,需要加上锁来保护,所以不需要考虑在扩容的时候添加节点进来,只会在扩容结束后考虑添加节点。

3.2、ConcurrentHashMap

ConcurrentHashMap同样也是在添加节的时候才会初始化数组,那么ConcurrentHashMap为了保证多线程的安全性做了哪些努力呢?

首先是并发操作某一条链表时是如何处理的 例如添加节点时需要将这个节点放置在数组的相应位置,如果这个位置上已经有值了,那么就需要在这个数组节点的后面添加链表,那么如果在添加节点的同时有其他线程去操作数组的这个位置,例如删除一个节点,那么就会发生错误,在ConcurrentHashMap中1.7下是使用了分段锁,分成了n段就支持n个并发操作,在1.8里是直接用synchronized来锁住当前链表或红黑二叉树的首节点,这样只要hash不冲突,就不会产生并发。

在多线程情况下如何初始化ConcurrentHashMap 因为是多线程下操作,所以在修改数据的时候为了保证安全性引入了CAS(乐观锁),例如在初始化ConcurrentHashMap,首先去尝试获取锁,如果没有获取到锁,那么说明已经有其他线程在进行初始化操作了,这时候调用Thread.yield()表示出让本次线程掌控的cpu时间,注意这不是挂起,而仅仅是出让本次线程对cpu的占用,到下一次的时候这个线程还会去竞争抢占cpu,如果抢占到了cpu会继续执行下面的程序,这时会继续尝试获取乐观锁,如果获取到锁了,有两种情况,就是刚才线程初始化ConcurrentHashMap失败了,还有一种就是刚才其他线程已经初始化好了ConcurrentHashMap。这些通过判断table是否为null来获取结果,如果已经初始化成功了,那么初始化操作。注意一个线程成功初始化ConcurrentHashMap会释放掉这个锁。

如果在ConcurrentHashMap进行扩容迁移节点时有线程添加节点怎么处理 这时候这个本来是添加节点的线程会暂时不进行原来的操作,而是加入到扩容迁移节点的工程中,先帮助成功迁移节点,然后执行添加节点的操作。那么在ConcurrentHashMap中是如何保证多个线程同时迁移节点而保证线程之间不会发生冲突的呢?这个是在ConcurrentHashMap中对数组进行了分组,例如原来数组为32个,这里称为32个桶,那么可以将这32个桶每4个分成一组,这样就分成了8组,那么线程从后往前进行处理,第一个线程获取29-32个桶,去进行处理,然后第二个线程进来了,首先会尝试将表示当前有几个线程在帮助扩容迁移锁的标识加一,表示当前多了一个线程去进行扩容,然后判断当前已经处理到了第几组了,例如上面第一个线程还在处理第8组,那么这个线程就去处理第7组,依次下去,这样可以使得多个线程之间不会造成影响。

4、多线程下ThreadLocal问题

场景就是threadLocal在项目中使用时,出现取值错误的情况。花了不少时间排查,最终还是排查到线程池上。之前一直没有问题,或许是因为并发不高。

我们都知道threadLocal中维护了一个线程和value的映射,当前线程的threadLocal即为key,value为引用的对象。

t.threadLocals = new ThreadLocalMap(this, firstValue);

每个线程保存一份,达到线程安全。
但是在线程复用的情况下,threadLocal并不能保证按照预期执行,很有可能出现数据错乱。原因就是线程池中的线程在还未销毁的情况下,新的请求进来,会继续复用线程池中的线程,而这些线程在之前处理的过程中,对应的threadLocal有可能已经有值,导致出错。

这里只是模拟服务器线程池的执行流程,当web服务器接收到一个请求处理结束,未清理掉threadLocal中的变量,此时另一个请求进来,同样用该线程去处理。这个时候发现threadLocal中已有变量,如果使用不慎,会出现数据错误的情形。

正确做法
正确姿势是在threadLocal变量使用之后,调用remove()方法。这么做也可以避免内存泄露,如果没有调用该方法,那当map中当前线程被回收,对应的value得不到回收,容易引起内存泄露。

这样又引出了新问题,就是我们怎么区分在什么地方进行remove()呢?假如一个接口调用了remove(),然后结束了代码逻辑,这没问题。但是有可能另一个接口也执行完该代码块remove(),接着又在别的地方调用了get()方法,这种场景还是会出错。
所以尽量避免这种使用场景,要保证remove()的代码块的调用链后面,不会再执行get方法。

你可能感兴趣的:(面试)