Java多线程编程

2020-09-09  本文已影响0人  bobozhangshao

java并发编程基础知识

线程概述

进程:是CPU分配资源的最小单元,是程序的一次动态执行,它对应着从代码加载,执行至完成的一个完整的过程,它有自己的生命周期。它是应用程序的执行实例,每个进程都是由私有的虚拟地址空间、代码、数据和其它系统资源组成。进程在运行时创建的资源随着进程的终止而死亡.

线程:是CPU调度和指派的基本单元, 是进程中的一个实体,每个线程都有独立的生命周期.

线程与进程的关系:线程是进程内的一个执行单元,一个进程可以包含多个线程,进程只提供资源加载空间,其具体资源调度是由进程中的线程来完成的.

线程五大状态

新建状态(new)

就绪状态(Runnable)

运行状态(Running)

阻塞状态(Blocked)

死亡状态(Dead)

线程创建与启动

线程创建:

1.继承Thread类 如: class MyThread extends Thread {...}

2.实现Runnable接口 如: class MyRunnable implements Runnable{...}

启动:启动线程使用start()方法

线程之间的协作

wait/notify/notifyAll

wait:阻塞当前线程,直到 notify 或者 notifyAll 来唤醒​​​​

notify:只能唤醒一个处于 wait 的线程

notifyAll: notifyAll唤醒全部处于 wait 的线程​

notify与notifyAll区别:notifyAll使所有原来在该对象上等待被notify的线程统统退出wait的状态,变成等待该对象上的锁,一旦该对象被解锁,他们就会去竞争;而notify只是选择一个wait状态线程进行通知,并使它获得该对象上的锁,当第一个线程运行完毕以后释放对象上的锁,此时如果该对象没有再次使用notify语句,即便该对象已经空闲,其他wait状态等待的线程由于没有得到该对象的通知,继续处在wait状态,直到这个对象发出一个notify或notifyAll.

sleep/yield/join

sleep:让当前线程暂停指定时间,只是让出CPU的使用权,并不释放锁

yield:暂停当前线程的执行,让出CPU的使用权,让其他线程有机会执行,不能指定时间。会让当前线程从运行状态转变为就绪状态, 一般情况使用比较少.

join:等待调用join方法的线程执行结束,才执行后面的代码其调用一定要在 start 方法之后,使用场景: 当父线程需要等待子线程执行结束才执行后面内容或者需要某个子线程的执行结果会用到join方法​

线程优先级

每个线程执行时都有一个优先级的属性,优先级高的线程可以获得较多的执行机会,而优先级低的线程则获得较少的执行机会。与线程休眠类似,线程的优先级仍然无法保障线程的执行次序。只不过,优先级高的线程获取CPU资源的概率较大,优先级低的也并非没机会执行。

每个线程默认的优先级都与创建它的父线程具有相同的优先级,在默认情况下,main线程具有普通优先级。

Thread类提供了setPriority(int newPriority)和getPriority()方法来设置和返回一个指定线程的优先级。

默认常量 MAX_PRIORITY  =10    MIN_PRIORITY  =1  NORM_PRIORITY  =5

并发线程三要素

原子性 : 即一个不可再分割的颗粒,在java中原子性一般指一个或者多个操作要么全部执行成功,要么全部执行失败

可见性: 当多线程访问同一个变量时,如果其中某一个线程对其做了修改,其它线程能立即获取到最新的值

有序性:程序执行的顺序按照代码的先后顺序执行(处理器可能对内部指令重新排序)

锁描述

在java1.5之前都是使用synchronized关键字保证同步的,通过使用一致的锁定协议来协调对共享状态的访问,都采用独占的方式来访问这些变量,即一个线程只有拿到该共享变量的锁,才可以访问该变量,对共享变量进行操作。

悲观锁

描述:每次操作都会加锁,会造成线程阻塞(Synchronized实现)。

悲观锁缺点:

1. 悲观锁在多线程的竞争下,加锁和释放锁会导致比较多的上下文切换和调度延迟从而引发性能问题,

2. 一个线程持有锁会导致其它需要此锁的线程挂起,如果一个优先级高的线程调用一个优先级低的线程线程释放的锁会导致优先级倒置,引发性能问题

CAS乐观锁

描述:每次操作不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败而重试,直到成功为止,不会造成线程阻塞。(CAS实现)

CAS乐观锁:当多个线程尝试使用CAS同时更新同一个变量时,只有其中一个线程能更新变量的值,而其它线程都失败,失败的线程并不会被挂起,而是被告知这次竞争中失败,并可以再次尝试。

CAS原理:CAS操作包含3个数值,需要读写内存的地址(V)、进行比较的预期原值(A)和拟写入的新值(B)。如果内存位置V的值与预期原值A相匹配,那么处理器会自动将该位置值更新为新值B。否则处理器不做任何操作。无论哪种情况,它都会在 CAS 指令之前返回该位置的值。

CAS缺点: 1.ABA问题 (通过AtomicStampedReference类解决,即通过该类的compareAndSet方法首先检查当前引用是否等于预期引用,并且当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值);2.自旋CAS导致CPU消耗非常大;

CAS与Synchronized使用场景

1.对于资源竞争较少(线程冲突较轻)的情况,使用synchronized同步锁进行线程阻塞和唤醒切换以及用户态内核态间的切换操作额外浪费消耗cpu资源;而CAS基于硬件实现,不需要进入内核,不需要切换线程,操作自旋几率较少,因此可以获得更高的性能。

2.对于资源竞争严重(线程冲突严重)的情况,CAS自旋的概率会比较大,从而浪费更多的CPU资源,效率低于synchronized。CAS在判断两次读取的值不一样的时候会放弃操作,但为了保证结果正确,通常都会继续尝试循环再次发起CAS操作。

public final int getAndIncrement() {

        for (;;) {

            int current = get();

            int next = current + 1;

            if (compareAndSet(current, next))

                return current;

        }

}

如果compareAndSet(current, next)方法成功执行,则直接返回;如果线程竞争激烈,导致compareAndSet(current, next)方法一直不能成功执行,则会一直循环等待,直到耗尽cpu分配给该线程的时间片,从而大幅降低效率。

公平锁与非公平锁

公平锁是指多个线程按照申请锁的顺序来获取锁,ReentrantLock提供了公平锁与非公平锁实现

描述: 如果线程A获取锁,这时候B线程请求持有该锁时,会被挂起,这时如果有线程C也来竞争该锁,那么如果是使用公平锁的情况下,B线程获取该锁,如果是非公平锁场景下,B线程与C线程互相竞争获取A线程释放的锁

公平锁: ReentrantLock rLock = new ReentrantLock(true);

非公平锁: ReentrantLock rLock = new ReentrantLock(false);  默认为非公平锁

使用场景: ReentrantLock公平锁会带来性能开销,如果没有公平性场景下建议尽量不要使用公平锁,同时ReentrantLock也是个一个独占锁,同一时刻只允许一个线程获取锁的场景下可使用该锁(如:消息生产-消费模型)

自旋锁(Spin Lock)

自旋锁即如果持有锁的线程可以在短时间内释放锁资源,那么等待竞争锁的那些线程不需要在内核状态和用户状态之间进行切换。 它只需要等待,并且锁可以在释放锁之后立即获得锁。这样好处是可以避免消耗用户线程和内核切换。

使用场景:

自旋锁是一种耗费CPU资源的锁。 如果自旋锁的对象一直无法获得临界资源,则线程也无法在没有执行实际计算的情况下一直进行这样就会导致CPU空转,因此需要设置自旋锁的最大等待时间。如果持有锁的线程在旋转等待的最大时间没有释放锁,则自旋锁线程将停止旋转进入阻塞状态。

JDK1.6开启自旋锁 -XX:+UseSpinning,1.7之后控制器收回到JVM自主控制

偏向锁(Biased Lock)/轻量级锁(Lightweight Lock)/重量级锁(Heavyweight Lock)

Java 5引入的一种状态锁机制,通过锁升级来实现高效的synchronized。

其原理是通过对象监视器中的状态属性来标示锁的状态,然后一级一级的旋转对锁进行升级。

偏向锁:偏向锁是指一段同步代码一直被一个线程所访问,不存在线程竞争的情况下,那么该线程会自动获得一个偏向锁。这样可以降低获取锁的代价。如果在运行过程中,遇到了其他线程抢占锁,则持有偏向锁的线程会被挂起,JVM会消除它身上的偏向锁,将锁恢复到标准的轻量级锁。

轻量级锁:轻量级锁是指当锁是偏向锁的时候,被另一个线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,提高性能。

重量级锁:重量级锁是指当锁为轻量级锁的时候,另一个线程虽然是自旋,但自旋不会一直持续下去,当自旋一定次数的时候,还没有获取到锁,就会进入阻塞,该锁膨胀为重量级锁。重量级锁会让其他申请的线程进入阻塞,性能降低。

可重入锁

可重入锁是指在同一个线程在外层方法获取锁的时候,在进入内层方法会自动获取锁。RenntrantLock/sunchronized在某种意义上可以任务是一种可重入锁,重入锁可以一定程度避免死锁

//当线程A调用方法methodA时,会自动获取methodB的锁,从而避免死锁

synchronized void methodA(){

    methodB();

}

synchronized void methodB(){

}

读写锁

ReentrantReadWriteLock提供了读写锁实现,一般适用于读多写少的场景

ReentrantReadWriteLock readWriteLock = new ReentrantReadWriteLock();

Lock readLock = readWriteLock.readLock();

Lock writeLock = readWriteLock.writeLock();

同步控制

RenntrantLock

ReadWriteLock

Condition

Semaphore

CountDownLatch

CyclicBarrier

LockSupport

线程死锁

描述:死锁是指两个或者两个以上的线程在执行过程中,因争夺资源中造成的互相等待的现象,由于线程被无限期地阻塞,因此程序不能正常运行。

死锁产生条件

互斥条件:

线程对已经获取的资源进行排它性使用,即该资源同时只由一个线程占用,如果此时还有其他线程请求获取该资源,该请求只能等待直到占有改资源的线程释放该资源

请求并持有条件:

一个线程已经持有了一个资源,但又提出了新的资源请求,而新资源已被其他线程占有,所以当前线程会被阻塞,阻塞的同时并不获取自己已获取的资源.

不可剥夺条件:

线程获取到资源在自己使用完成未释放前其它线程不能抢占该资源,只有自己释放后才能有CPU自由调度分配.

循环等待条件:

若干线程之间形成一种头尾相接的循环等待资源情况,即线程A—>线程B—> 线程C —> 线程D —> 线程A

死锁避免与处理

线程死锁必出满足上述四个必要条件,只要其中一个被破坏就不会产生死锁,所以,在系统设计、进程调度等方面应注意避免让这四个必要条件成立,在确定资源的合理分配算法时,避免进程永久占据系统资源才能最大可能地避免、预防和解除死锁。此外,也要防止进程在处于等待状态的情况下占用资源。因此,对资源的分配要给予合理的规划。

由于资源竞争受CPU控制,所以一般我们在请求并持有条件和循环等待这两个阶段对线程死锁做处理.

关键字

volatile关键字

定义:java编程语言允许线程访问共享变量,为了确保共享变量能被准确和一致地更新,java语言提供了volatile关键字,它能保证所有线程对该变量访问的可见性.

原理: 使用volatile修改的共享变量会将当前处理器缓存行的数据写回到内存,同时会使在其他cpu里缓存了该内存地址的数据无效.

作用:保证共享变量访问的可见性,但不保证原子性

常用场景: 对一个变量的写操作先行发生于后面对这个变量的读操作 即1)对变量的写操作不依赖于当前值 2)该变量没有包含在具有其他变量的不变式中

synchronized关键字

定义:在java中每个对象都拥有一个锁标记,也称为监视器,多线程同时访问某个对象时,线程只有获得该对象的锁才能访问,java可以使用synchronized关键字来标记一个方法或者代码块,当某个线程调用该对象的synchronized方法或者访问synchronized代码块时,这个线程便获得了该对象的锁,其他线程暂时无法访问这个方法,只有等待这个方法执行完毕或者代码块执行完毕,这个线程才会释放该对象的锁,其他线程才能执行这个方法或者代码块.

原理: sychronized分为同步代码块和同步方法两类,原理有部分区别

      1)同步代码块是使用MonitorEnter和MoniterExit指令实现的,在编译时,MonitorEnter指令被插入到同步代码块的开始位置,MoniterExit指令被插入到同步代码块的结束位置和异常位置。任何对象都有一个Monitor与之关联,当Monitor被持有后将处于锁定状态。MonitorEnter指令会尝试获取Monitor的持有权,即尝试获取锁。

      2)同步方法依赖flags标志ACC_SYNCHRONIZED实现,ACC_SYNCHRONIZED标志表示方法为同步方法,如果为非静态方法(没有ACC_STATIC标志),使用调用该方法的对象作为锁对象;如果为静态方法(有ACC_STATIC标志),使用该方法所属的Class类在JVM的内部对象表示class作为锁对象。

作用: synchronized关键字保障方法或者代码块在运行时,同一时刻只有一个方法可以进入临界区,同时它还可以保证共享变量的内存可见性

常用场景:1.修饰实例方法,作用于当前实例加锁,进入同步代码前要获得当前实例的锁;

2.修饰静态方法,作用于当前类对象加锁,进入同步代码前要获得当前类对象的锁;

3.修饰代码块,指定加锁对象,对给定对象加锁,进入同步代码库前要获得给定对象的锁.

线程池Thread Pool

线程池概述:线程池是指在初始化一个多线程应用程序过程中创建一个线程集合,然后在需要执行新的任务时重用这些线程而不是新建一个线程。线程池的容量通常完全取决于可用内存数量和应用程序的需求。线程池中的每个线程都有被分配一个任务,一旦任务已经完成了,线程回到池子中并等待下一次分配任务。使用线程池可以降低资源消耗。通过重复利用已创建的线程降低线程创建和销毁造成的消耗; 同时可以提高响应速度,当任务到达时,任务可以不需要的等到线程创建就能立即执行; 还有提高线程的可管理性,线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一的分配,调优和监控。可以通过ThreadPoolExecutor创建线程池。

线程池生命周期

RUNNING:接收新任务,并且处理任务队列中的任务.

SHUTDOWN:不接收新任务,但是处理任务队列的任务.

STOP:不接收新任务,不处理任务队列,同时中断所有进行中的任务.

TIDYING:所有任务已经被终止,工作线程数量为 0,到达该状态会执行terminated().

TERMINATED: terminated()执行完毕.

核心参数

corePoolSize:最小存活的工作线程数量,当提交一个任务到线程池时,线程池会创建一个线程来执行任务,即使其他空闲的基本线程能够执行新任务也会创建线程,等到需要执行的任务数大于线程池基本大小时就不再创建。如果调用了线程池的prestartAllCoreThreads方法,线程池会提前创建并启动所有基本线程。

maximumPoolSize:最大的线程数量,线程池允许创建的最大线程数。如果队列满了,并且已创建的线程数小于最大线程数,则线程池会再创建新的线程执行任务。值得注意的是如果使用了无界的任务队列这个参数就没什么效果。

keepAliveTime:线程活动保持时间,线程池的工作线程空闲后,保持存活的时间。所以如果任务很多,并且每个任务执行的时间比较短,可以调大这个时间,提高线程的利用率,时间单位由TimeUnit指定

workQueue:工作队列,存储待执行的任务

直接提交队列: synchronousQueue,又称呼为无缓冲等待队列,它不会保存提交的任务,而是将任务直接提交给线程。如果不存在可用于立即运行任务的线程,则试图把任务加入队列将失败,因此会构造一个新的线程。此策略可以避免在处理可能具有内部依赖性的请求集合时出现锁定。直接提交通常要求无界即 maximumPoolSizes 设定成Integer.MAX_VALUE以避免拒绝新提交的任务。当命令以超过队列所能处理的平均数连续到达时,此策略允许无界线程具有增长的可能性.

有界任务队列:ArrayBlockingQueue,基于数组的先进先出队列,此队列创建时必须指定大小.如果没有达到corePoolSize的值,则新建线程(核心线程)执行任务,如果达到了,则入队等候,如果队列已满,则新建线程(非核心线程)执行任务。

无界任务队列: LinkedBlockingQueue,基于链表的先进先出队列,如果创建时没有指定此队列大小,则默认为Integer.MAX_VALUE.如果当前线程数小于核心线程数,则新建线程(核心线程)处理任务;如果当前线程数等于核心线程数,则进入队列等待。

DelayQueue中的元素只有当其指定的延迟时间到了,才能够从队列中获取到该元素。DelayQueue是一个没有大小限制的队列,因此往队列中插入数据的操作(生产者)永远不会被阻塞,而只有获取数据的操作(消费者)才会被阻塞.

使用场景:DelayQueue使用场景较少,但都相当巧妙,常见的例子比如使用一个DelayQueue来管理一个超时未响应的连接队列.

PriorityBlockingQueue: 基于优先级的阻塞队列(优先级的判断通过构造函数传入的Compator对象来决定),但需要注意的是PriorityBlockingQueue并不会阻塞数据生产者,而只会在没有可消费的数据时,阻塞数据的消费者。因此使用的时候要特别注意,生产者生产数据的速度绝对不能快于消费者消费数据的速度,否则时间一长,会最终耗尽所有的可用堆内存空间.

RejectExecutionHandler:拒绝策略,线程池满后会触发

AbortPolicy:默认策略,终止任务,抛出RejectedException

CallerRunsPolicy:在调用者线程执行当前任务,不抛异常(弊端:任务提交线程的性能极有可能会在某些业务场景下急剧下降)

DiscardPolicy: 抛弃策略,直接丢弃任务,不抛异常

DiscardOldersPolicy:抛弃最老的任务,执行当前任务,不抛异常

线程池四种模型

CachedThreadPool模型: 一个可缓存的线程池,如果线程池的当前规模超过了处理需求时,那么将回收空闲的线程,当需求增加时,则可以添加新的线程,线程池的规模不存在任何的限制.

示例: ExecutorService cachedThreadPool = Executors.newCachedThreadPool();

源码:

public static ExecutorService newCachedThreadPool() {

    return new ThreadPoolExecutor(0, Integer.MAX_VALUE,

            60L, TimeUnit.SECONDS, new SynchronousQueue<Runnable>());

}

FixedThreadPool模型: 一个固定大小的线程池,提交一个任务时就创建一个线程,直到达到线程池的最大数量,这时线程池的大小将不再变化.

示例:

ExecutorService fixedThreadPool = Executors.newFixedThreadPool(int nThreads);

ExecutorService fixedThreadPool = Executors.newFixedThreadPool(int nThreads, ThreadFactory threadFactory);

源码:

public static ExecutorService newFixedThreadPool(int nThreads) {

    return new ThreadPoolExecutor(nThreads, nThreads,

                                  0L, TimeUnit.MILLISECONDS,

                                  new LinkedBlockingQueue<Runnable>());

}

SingleThreadPool: 一个单线程的线程池,它只有一个工作线程来执行任务,可以确保按照任务在队列中的顺序来串行执行,如果这个线程异常结束将创建一个新的线程来执行任务.

示例:

ExecutorService singleThreadPool = Executors.newSingleThreadPool();

源码:

public static ExecutorService newSingleThreadExecutor() {

    return new FinalizableDelegatedExecutorService

        (new ThreadPoolExecutor(1, 1,

                                0L, TimeUnit.MILLISECONDS,

                                new LinkedBlockingQueue<Runnable>()));

}

ScheduledThreadPool:一个固定大小的线程池,并且以延迟或者定时的方式来执行任务,类似于Timer(推荐).

示例:

ExecutorService scheduledThreadPool = Executors.newScheduledThreadPool(int corePoolSize);

源码:

public static ScheduledExecutorService newScheduledThreadPool(int corePoolSize) {

    return new ScheduledThreadPoolExecutor(corePoolSize);

}

//ScheduledThreadPoolExecutor():

public ScheduledThreadPoolExecutor(int corePoolSize) {

    super(corePoolSize, Integer.MAX_VALUE,

          DEFAULT_KEEPALIVE_MILLIS, MILLISECONDS,

          new DelayedWorkQueue());

}

使用规范与场景

使用规范:

线程池不允许使用Executors去创建,而是通过ThreadPoolExecutor的方式,这样的处理方式让写的同学更加明确线程池的运行规则,规避资源耗尽的风险。

说明:Executors各个方法的弊端:

1)newFixedThreadPool和newSingleThreadExecutor: 主要问题是堆积的请求处理队列可能会耗费非常大的内存,甚至OOM。

2)newCachedThreadPool和newScheduledThreadPool: 主要问题是线程数最大数是Integer.MAX_VALUE,可能会创建数量非常多的线程,甚至OOM。

手动创建线程池注意点:

1) 任务独立。如何任务依赖于其他任务,那么可能产生死锁。例如某个任务等待另一个任务的返回值或执行结果,那么除非线程池足够大,否则将发生线程饥饿死锁。

2) 合理配置阻塞时间过长的任务。如果任务阻塞时间过长,那么即使不出现死锁,线程池的性能也会变得很糟糕。在Java并发包里可阻塞方法都同时定义了限时方式和不限时方式。例如Thread.join,BlockingQueue.put,CountDownLatch.await等,如果任务超时,则标识任务失败,然后中止任务或者将任务放回队列以便随后执行,这样,无论任务的最终结果是否成功,这种办法都能够保证任务总能继续执行下去。

3)设置合理的线程池大小。多线程应用并非线程越多越好,需要根据系统运行的软硬件环境以及应用本身的特点决定线程池的大小,一般来说,如果代码结构合理的话,线程数目与CPU 数量相适合即可。如果线程运行时可能出现阻塞现象,可相应增加池的大小;如有必要可采用自适应算法来动态调整线程池的大小,以提高CPU 的有效利用率和系统的整体性能。 公式线程池大小=NCPU *UCPU(1+W/C)。

4) 选择合适的阻塞队列。newFixedThreadPool和newSingleThreadExecutor都使用了无界的阻塞队列,无界阻塞队列会有消耗很大的内存,如果使用了有界阻塞队列,它会规避内存占用过大的问题,但是当任务填满有界阻塞队列,新的任务该怎么办?在使用有界队列是,需要选择合适的拒绝策略,队列的大小和线程池的大小必须一起调节。对于非常大的或者无界的线程池,可以使用SynchronousQueue来避免任务排队,以直接将任务从生产者提交到工作者线程。

主要场景:

高并发、任务执行时间短的业务怎样使用线程池?并发不高、任务执行时间长的业务怎样使用线程池?并发高、业务执行时间长的业务怎样使用线程池?

1.高并发、任务执行时间短的业务,线程池线程数可以设置为CPU核数+1,减少线程上下文的切换

2.并发不高、任务执行时间长的业务要区分开看:

  a)假如是业务时间长集中在IO操作上,也就是IO密集型的任务,因为IO操作并不占用CPU,所以不要让所有的CPU闲下来,可以加大线程池中的线程数目,让CPU处理更多的业务

  b)假如是业务时间长集中在计算操作上,也就是计算密集型任务,这个就没办法了,可以和(1)一样,线程池中的线程数设置得少一些,减少线程上下文的切换

3.并发高、业务执行时间长,解决这种类型任务的关键不在于线程池而在于整体架构的设计,看看这些业务里面某些数据是否能做缓存是第一步,增加服务器是第二步,至于线程池的设置,设置参考(2)。最后,业务执行时间长的问题,也可能需要分析一下,看看能不能使用中间件对任务进行拆分和解耦

线程池执行过程

线程安全容器

Vector/HashTable/StringBuffer

通过synchronized给方法加内置锁来实现线程安全

原子类 AtomicXXX

ConcurrentHashMap

BlockingQueue/BlockingDeque

CopyOnWriteArrayList/CopyOnWriteArraySet

ThreadLocal

上一篇下一篇

猜你喜欢

热点阅读