Java多线程常见问题

自闭症网瘾萝莉.ら 提交于 2021-02-04 04:22:50

记录Java多线程的常见概念和原理

参考:

如何停止一个线程

  • 使用volatile变量终止正常运行的线程 + 抛异常法/Return法
  • 组合使用interrupt方法与interruptted/isinterrupted方法终止正在运行的线程 + 抛异常法/Return法
  • 使用interrupt方法终止 正在阻塞中的 线程

线程安全

当多个线程访问某个类时,不管运行时环境采用何种调度方式或者这些线程将如何交替执行,并且在主调代码中不需要任何额外的同步或协同,这个类都能表现出正确的行为,那么这个类就是线程安全的。

为什么wait(), notify()和notifyAll()被定义在Object类里

Wait-notify机制是在获取对象锁的前提下不同线程间的通信机制。在Java中,任意对象都可以当作锁来使用,由于锁对象的任意性,所以这些通信方法需要被定义在Object类里。

为什么wait(), notify()和notifyAll()必须在同步方法或者同步块中被调用

wait/notify机制是依赖于Java中Synchronized同步机制的,其目的在于确保等待线程从Wait()返回时能够感知通知线程对共享变量所作出的修改。如果不在同步范围内使用,就会抛出java.lang.IllegalMonitorStateException的异常。

原子性

Java中只有对基本类型变量的赋值和读取是原子操作,如i = 1的赋值操作,但是像j = i或者i++这样的操作都不是原子操作,因为他们都进行了多次原子操作,比如先读取i的值,再将i的值赋值给j,两个原子操作加起来就不是原子操作了。所以,如果一个变量被volatile修饰了,那么肯定可以保证每次读取这个变量值的时候得到的值是最新的,但是一旦需要对变量进行自增这样的非原子操作,就不会保证这个变量的原子性了。

volatile关键字

  • 概念:为了提高处理速度,处理器不直接和内存进行通信,而是先将系统内存的数据读到内部缓存(L1,L2或其他)后再进行操作,但操作完不知道何时会写到内存。如果对声明了volatile的变量进行写操作,JVM就会向处理器发送一条Lock前缀的指令,将这个变量所在缓存行的数据写回到系统内存。一个处理器的缓存回写到内存会导致其他处理器的缓存失效; 当处理器发现本地缓存失效后,就会从内存中重读该变量数据,即可以获取当前最新值。这样针对volatile变量通过这样的机制就使得每个线程都能获得该变量的最新值。
  • 特点:保证了可见性,不能保证原子性

如何保证线程安全

  • 通过加锁(Lock/Synchronized)保证对临界资源的同步互斥访问
  • 使用volatile关键字,轻量级同步机制,但不保证原子性
  • 使用不变类 和 线程安全类(原子类,并发容器,同步容器等)

ThreadLocal及其引发的内存泄露

ThreadLocal是Java中的一种线程绑定机制,可以为每一个使用该变量的线程都提供一个变量值的副本,并且每一个线程都可以独立地改变自己的副本,而不会与其它线程的副本发生冲突。每个线程内部有一个 ThreadLocal.ThreadLocalMap 类型的成员变量 threadLocals,这个 threadLocals 存储了与该线程相关的所有 ThreadLocal 变量及其对应的值,也就是说,ThreadLocal 变量及其对应的值就是该Map中的一个 Entry,更直白地,threadLocals中每个Entry的Key是ThreadLocal 变量本身,而Value是该ThreadLocal变量对应的值。 实线代表强引用,虚线代表弱引用。map中的key为一个ThreadLocal实例。这个Map的确使用了弱引用,不过弱引用只是针对key,每个key都弱引用指向ThreadLocal对象。一旦把threadlocal实例置为null以后,那么将没有任何强引用指向ThreadLocal对象,因此ThreadLocal对象将会被 Java GC 回收。但是value却不能回收,因为存在一条从current thread连接过来的强引用。 只有当前thread结束以后, current thread就不会存在栈中,强引用断开,Current Thread、Map及value将全部被Java GC回收。 得出一个结论就是:只要这个线程对象被Java GC回收,就不会出现内存泄露。但是如果只把ThreadLocal引用指向null而线程对象依然存在,那么此时Value是不会被回收的,这就发生了我们认为的内存泄露。比如,在使用线程池的时候,线程结束是不会销毁的而是会再次使用的,这种情形下就可能出现ThreadLocal内存泄露。

线程池

一个线程池管理了一组工作线程,同时它还包括了一个用于放置等待执行的任务的队列。线程池可以避免线程的频繁创建与销毁,降低资源的消耗,提高系统的反应速度。涉及到四个对象:线程池-Executor;工作线程-Worker;任务-Runable和Callable;阻塞队列:BlockingQueue

  • 线程池:Executor及其实现类是用户级的线程调度器,也是对任务执行机制的抽象,其将任务的提交与任务的执行分离开来,核心实现类包括ThreadPoolExecutor(用来执行被提交的任务)和ScheduledThreadPoolExecutor(可以在给定的延迟后执行任务或者周期性执行任务)。
  • 任务-Runable和Callable:Runnable(run)和Callable(call)都是对任务的抽象,但是Callable可以返回任务执行的结果或者抛出异常。
//使用Callable+Future获取执行结果
ExecutorService executor = Executors.newCachedThreadPool();
    Task task = new Task();
    Future<Integer> result = executor.submit(task);
    System.out.println("task运行结果" + result.get());    

    class Task implements Callable<Integer>{
            @Override
            public Integer call() throws Exception {
                System.out.println("子线程在进行计算");
                Thread.sleep(3000);
                int sum = 0;
                for(int i=0;i<100;i++)
                    sum += i;
                return sum;
            }
  • 常见线程池
    • FixedThreadPool:用于创建使用固定线程数的ThreadPool,corePoolSize = maximumPoolSize = n(固定的含义),阻塞队列为LinkedBlockingQueue。
    • SingleThreadExecutor:用于创建一个单线程的线程池,corePoolSize = maximumPoolSize = 1,阻塞队列为LinkedBlockingQueue。
    • CachedThreadPool:用于创建一个可缓存的线程池,corePoolSize = 0, maximumPoolSize = Integer.MAX_VALUE,阻塞队列为SynchronousQueue(没有容量的阻塞队列,每个插入操作必须等待另一个线程对应的移除操作,反之亦然)。
    • ScheduledThreadPoolExecutor:用于创建一个大小无限的线程池,此线程池支持定时以及周期性执行任务的需求。阻塞队列是DelayedWorkQueue
  • 线程池饱和策略 以下是系统策略,用于也可以实现RejectedExecutionHandler接口,自定义饱和策略。
    • AbortPolicy:直接抛出异常,默认策略;
    • DiscardPolicy:直接丢弃任务;
    • DiscardOldestPolicy:丢弃阻塞队列中最老的任务,并执行当前任务;
    • CallerRunsPolicy:用调用者所在的线程来执行任务;

CAS

CAS,Compare and Swap即比较并交换,设计并发算法时常用到的一种技术。CAS有3个操作数,内存值V,旧的预期值A,新值B。当且仅当预期值A和内存值V相同时,将内存值V修改为B,否则什么都不做。CAS是通过unsafe类的compareAndSwap (JNI, Java Native Interface) 方法实现的,该方法包括四个参数:第一个参数是要修改的对象,第二个参数是对象中要修改变量的偏移量,第三个参数是修改之前的值,第四个参数是预想修改后的值。

  • ABA问题:因为CAS需要在操作值的时候检查下值有没有发生变化,如果没有发生变化则更新,但是如果一个值原来是A,变成了B,又变成了A,那么使用CAS进行检查时会发现它的值没有发生变化,但是实际上却变化了。ABA问题的解决思路就是使用版本号。在变量前面追加上版本号,每次变量更新的时候把版本号加一,那么A-B-A 就会变成1A-2B-3A。
  • 不适用于竞争激烈的情形中:并发越高,失败的次数会越多,CAS如果长时间不成功,会极大的增加CPU的开销。因此CAS不适合竞争十分频繁的场景。
  • 只能保证一个共享变量的原子操作:当对一个共享变量执行操作时,我们可以使用循环CAS的方式来保证原子操作,但是对多个共享变量操作时,循环CAS就无法保证操作的原子性,这个时候就可以用锁,或者有一个取巧的办法,就是把多个共享变量合并成一个共享变量来操作。

AQS:队列同步器

队列同步器(AbstractQueuedSynchronizer)是用来构建锁和其他同步组件的基础框架,技术是 CAS自旋Volatile变量:它使用了一个Volatile成员变量表示同步状态,通过CAS修改该变量的值,修改成功的线程表示获取到该锁;若没有修改成功,或者发现状态state已经是加锁状态,则通过一个Waiter对象封装线程,添加到等待队列中,并挂起等待被唤醒。 同步器是实现锁的关键,子类通过继承同步器并实现它的抽象方法来管理同步状态,利用同步器实现锁的语义。特别地,锁是面向锁使用者的,它定义了使用者与锁交互的接口,隐藏了实现细节;同步器面向的是锁的实现者,它简化了锁的实现方式,屏蔽了同步状态管理、线程排队、等待与唤醒等底层操作。锁和同步器很好地隔离了锁的使用者与锁的实现者所需关注的领域。 一般来说,自定义同步器要么是独占方式,要么是共享方式,他们也只需实现tryAcquire-tryRelease、tryAcquireShared-tryReleaseShared中的一种即可。但AQS也支持自定义同步器同时实现独占和共享两种方式,如ReentrantReadWriteLock。 同步器的设计是基于 模板方法模式 的,也就是说,使用者需要继承同步器并重写指定的方法,随后将同步器组合在自定义同步组件的实现中,并调用同步器提供的模板方法,而这些模板方法将会调用使用者重写的方法。 AQS维护了一个volatile int state(代表共享资源)和一个FIFO线程等待队列(多线程争用资源被阻塞时会进入此队列)。这里volatile是核心关键词,具体volatile的语义,在此不述。state的访问方式有三种:getState()、setState()以及compareAndSetState()。 AQS定义了两种资源共享方式:Exclusive(独占,只有一个线程能执行,如ReentrantLock)和Share(共享,多个线程可同时执行,如Semaphore/CountDownLatch)。不同的自定义同步器争用共享资源的方式也不同。自定义同步器在实现时只需要实现共享资源state的获取与释放方式即可,至于具体线程等待队列的维护(如获取资源失败入队/唤醒出队等),AQS已经在顶层实现好了。自定义同步器实现时主要实现以下几种方法:

  • isHeldExclusively():该线程是否正在独占资源。只有用到condition才需要去实现它;
  • tryAcquire(int):独占方式。尝试获取资源,成功则返回true,失败则返回false;
  • tryRelease(int):独占方式。尝试释放资源,成功则返回true,失败则返回false;
  • tryAcquireShared(int):共享方式。尝试获取资源。负数表示失败;0表示成功,但没有剩余可用资源;正数表示成功,且有剩余资源;
  • tryReleaseShared(int):共享方式。尝试释放资源,成功则返回true,失败则返回false。 以ReentrantLock为例,state初始化为0,表示未锁定状态。A线程lock()时,会调用tryAcquire()独占该锁并将state+1。此后,其他线程再tryAcquire()时就会失败,直到A线程unlock()到state=0(即释放锁)为止,其它线程才有机会获取该锁。当然,释放锁之前,A线程自己是可以重复获取此锁的(state会累加),这就是可重入的概念。但要注意,获取多少次就要释放多么次,这样才能保证state是能回到零态的。

常见的锁的概念

  • 公平锁:加锁前先查看是否有排队等待的线程,有的话优先处理排在前面的线程,先来先得
  • 非公平锁:线程加锁时直接尝试获取锁,获取不到就自动到队尾等待。
  • 可重入锁:一个线程A获得了锁,它可以获得,但是它也得释放相应次数的锁

Lock接口和Synchronized关键字

synchronized是Java的关键字,是Java的内置特性,在JVM层面实现了对临界资源的同步互斥访问。Synchronized的语义底层是通过一个monitor对象来完成的,线程执行monitorenter/monitorexit指令完成锁的获取与释放。而Lock是一个Java接口(API如下图所示),是基于JDK层面实现的,通过这个接口可以实现同步访问,它提供了比synchronized关键字更灵活、更广泛、粒度更细的锁操作,底层是由AQS实现的。二者之间的差异总结如下:

  • 实现层面:synchronized(JVM层面)、Lock(JDK层面)
  • 可实现公平锁:Lock可以实现公平锁,而sychronized天生就是非公平锁
  • 显式获取和释放:synchronized在发生异常时,会自动释放线程占有的锁,因此不会导致死锁现象发生;而Lock在发生异常时,如果没有主动通过unLock()去释放锁,则很可能造成死锁现象,因此使用Lock时需要在finally块中释放锁;
  • 读写锁:Lock可以提高多个线程进行读操作的效率(读锁允许并发访问,写锁则要求独占访问)

Condition

Condition可以用来实现线程的分组通信与协作。以生产者/消费者问题为例,

  • wait/notify/notifyAll:在队列为空时,通知所有线程;在队列满时,通知所有线程,防止生产者通知生产者,消费者通知消费者的情形产生。
  • await/signal/signalAll:将线程分为消费者线程和生产者线程两组:在队列为空时,通知生产者线程生产;在队列满时,通知消费者线程消费。

阻塞队列

java.util.concurrent.BlockingQueue的特性是:当队列是空的时,从队列中获取或删除元素的操作将会被阻塞,或者当队列是满时,往队列里添加元素的操作会被阻塞。特别地,阻塞队列不接受空值,当你尝试向队列中添加空值的时候,它会抛出NullPointerException。另外,阻塞队列的实现都是线程安全的,所有的查询方法都是原子的并且使用了内部锁或者其他形式的并发控制。 BlockingQueue 接口是java collections框架的一部分,它主要用于实现生产者-消费者问题。特别地,SynchronousQueue是一个没有容量的阻塞队列,每个插入操作必须等待另一个线程的对应移除操作,反之亦然。CachedThreadPool使用SynchronousQueue把主线程提交的任务传递给空闲线程执行。

  • SynchronousQueue:SynchronousQueue没有容量,是无缓冲等待队列,是一个不存储元素的阻塞队列,会直接将任务交给消费者,必须等队列中的添加元素被消费后才能继续添加新的元素。
  • LinkedBlockingQueue:LinkedBlockingQueue是一个无界缓存等待队列。当前执行的线程数量达到corePoolSize的数量时,剩余的元素会在阻塞队列里等待。(所以在使用此阻塞队列时maximumPoolSizes就相当于无效了),每个线程完全独立于其他线程。生产者和消费者使用独立的锁来控制数据的同步,即在高并发的情况下可以并行操作队列中的数据。
  • ArrayBlockingQueue:ArrayBlockingQueue是一个有界缓存等待队列,可以指定缓存队列的大小,当正在执行的线程数等于corePoolSize时,多余的元素缓存在ArrayBlockingQueue队列中等待有空闲的线程时继续执行,当ArrayBlockingQueue已满时,加入ArrayBlockingQueue失败,会开启新的线程去执行,当线程数已经达到最大的maximumPoolSizes时,再有新的元素尝试加入ArrayBlockingQueue时会报错。

同步容器(强一致性)

同步容器指的是 Vector、Stack、HashTable及Collections类中提供的静态工厂方法创建的类。其中,Vector实现了List接口,Vector实际上就是一个数组,和ArrayList类似,但是Vector中的方法都是synchronized方法,即进行了同步措施;Stack也是一个同步容器,它的方法也用synchronized进行了同步,它实际上是继承于Vector类;HashTable实现了Map接口,它和HashMap很相似,但是HashTable进行了同步处理,而HashMap没有。 Collections类是一个工具提供类,注意,它和Collection不同,Collection是一个顶层的接口。在Collections类中提供了大量的方法,比如对集合或者容器进行排序、查找等操作。最重要的是,在它里面提供了几个静态工厂方法来创建同步容器类,如下图所示:

CopyOnWrite容器(弱一致性)

CopyOnWrite容器即写时复制的容器,适用于读操作远多于修改操作的并发场景中。通俗的理解是当我们往一个容器添加元素的时候,不直接往当前容器添加,而是先将当前容器进行Copy,复制出一个新的容器,然后新的容器里添加元素,添加完元素之后,再将原容器的引用指向新的容器。这样做的好处是我们可以对CopyOnWrite容器进行并发的读,而不需要加锁,因为当前容器不会添加任何元素。所以CopyOnWrite容器也是一种读写分离的思想,读和写不同的容器。 从JDK1.5开始Java并发包里提供了两个使用CopyOnWrite机制实现的并发容器,它们是CopyOnWriteArrayList和CopyOnWriteArraySet。CopyOnWrite容器主要存在两个弱点:

  • 容器对象的复制需要一定的开销,如果对象占用内存过大,可能造成频繁的YoungGC和Full GC;
  • CopyOnWriteArrayList不能保证数据实时一致性,只能保证最终一致性。

ConcurrentHashMap(弱一致性)

ConcurrentHashMap的弱一致性主要是为了提升效率,也是一致性与效率之间的一种权衡。要成为强一致性,就得到处使用锁,甚至是全局锁,这就与Hashtable和同步的HashMap一样了。ConcurrentHashMap的弱一致性主要体现在以下几方面:

  • get操作是弱一致的:get操作只能保证一定能看到已完成的put操作;
  • clear操作是弱一致的:在清除完一个segments之后,正在清理下一个segments的时候,已经清理的segments可能又被加入了数据,因此clear返回的时候,ConcurrentHashMap中是可能存在数据的。
  • ConcurrentHashMap中的迭代操作是弱一致的(未遍历的内容发生变化可能会反映出来):在遍历过程中,如果已经遍历的数组上的内容变化了,迭代器不会抛出ConcurrentModificationException异常。如果未遍历的数组上的内容发生了变化,则有可能反映到迭代过程中。

happen-before

happens-before 指定了两个操作间的执行顺序:如果 A happens before B,那么Java内存模型将向程序员保证 —— A 的执行顺序排在 B 之前,并且 A 操作的结果将对 B 可见,其具体包括如下8条规则:

  • 程序顺序规则:单线程内,按照程序代码顺序,书写在前面的操作先行发生于书写在后面的操作;
  • 管程锁定规则:一个unlock操作先行发生于对同一个锁的lock操作;
  • volatile变量规则:对一个Volatile变量的写操作先行发生于对这个变量的读操作;
  • 线程启动规则:Thread对象的start()方法先行发生于此线程的其他动作;
  • 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生;
  • 线程终止规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行;
  • 对象终结规则:一个对象的初始化完成先行发生于它的finalize()方法的开始;
  • 传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C

锁优化

锁优化技术的目的在于线程之间更高效的共享数据,解决竞争问题,更好提高程序执行效率。

  • 自旋锁(上下文切换代价大):互斥锁 -> 阻塞 –> 释放CPU,线程上下文切换代价较大 + 共享变量的锁定时间较短 == 让线程通过自旋等一会儿,自旋锁
  • 锁粗化(一个大锁优于若干小锁):一系列连续操作对同一对象的反复频繁加锁/解锁会导致不必要的性能损耗,建议粗化锁一般而言,同步范围越小越好,这样便于其他线程尽快拿到锁,但仍然存在特例。
  • 偏向锁(有锁但当前情形不存在竞争):消除数据在无竞争情况下的同步原语,提高带有同步但无竞争的程序性能。
  • 锁消除(有锁但不存在竞争,锁多余):JVM编译优化,将不存在数据竞争的锁消除

主线程等待子线程运行完毕再运行的方法

  • join:Thread提供了让一个线程等待另一个线程完成的方法 — join()方法。当在某个程序执行流程中调用其它线程的join()方法时,调用线程将被阻塞,直到被join()方法加入的join线程执行完毕为止,在继续运行。join()方法的实现原理是不停检查join线程是否存活,如果join线程存活则让当前线程永远等待。直到join线程完成后,线程的this.notifyAll()方法会被调用。
  • CountDownLatch:Countdown Latch允许一个或多个线程等待其他线程完成操作。CountDownLatch的构造函数接收一个int类型的参数作为计数器,如果你想等待N个点完成,这里就传入N。当我们调用countDown方法时,N就会减1,await方法会阻塞当前线程,直到N变成0。这里说的N个点,可以使用N个线程,也可以是1个线程里的N个执行步骤。
  • Sleep:用sleep方法,让主线程睡眠一段时间,当然这个睡眠时间是主观的时间,是我们自己定的,这个方法不推荐,但是在这里还是写一下,毕竟是解决方法。
标签
易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!