Java 并发编程总结篇


线程和进程有什么区别?

线程具有许多传统进程所具有的特征,故又称为轻型进程(Light—Weight Process)或进程元;而把传统的进程称为重型进程(Heavy—Weight Process),它相当于只有一个线程的任务。在引入了线程的操作系统中,通常一个进程都有若干个线程,至少包含一个线程

  • 根本区别:进程是操作系统资源分配的基本单位,而线程是处理器任务调度和执行的基本单位
  • 资源开销:每个进程都有独立的代码和数据空间(程序上下文),程序之间的切换会有较大的开销;线程可以看做轻量级的进程,同一类线程共享代码和数据空间,每个线程都有自己独立的运行栈和程序计数器,线程之间切换的开销小。
  • 包含关系:如果一个进程内有多个线程,则执行过程不是一条线的,而是多条线(线程)共同完成的;线程是进程的一部分,所以线程也被称为轻量级进程。
  • 内存分配:同一进程的线程共享本进程的地址空间和资源,而线程之间的地址空间和资源是相互独立的
  • 影响关系:一个进程崩溃后,在保护模式下不会对其他进程产生影响,但是一个线程崩溃整个进程都死掉。所以多进程要比多线程健壮。
  • 执行过程:每个独立的进程有程序运行的入口、顺序执行序列和程序出口。但是线程不能独立执行,必须依存在应用程序中,由应用程序提供多个线程执行控制,两者均可并发执行

创建线程的方式及实现

  • 继承 Thread 类,重写 run() 方法。
  • 实现 Runnable 接口,实现 run() 方法。
  • 实现 Callable 接口,实现 call() 方法;和 Runnable 的区别是,Callable 支持返回值。

为什么要使用多线程呢?

单核时代: 在单核时代多线程主要是为了提高 CPU 和 IO 设备的综合利用率。举个例子:当只有一个线程的时候会导致 CPU 计算时,IO 设备空闲;进行 IO 操作时,CPU 空闲。我们可以简单地说这两者的利用率目前都是 50% 左右。但是当有两个线程的时候就不一样了,当一个线程执行 CPU 计算时,另外一个线程可以进行 IO 操作,这样两个的利用率就可以在理想情况下达到 100% 了。

多核时代:多核时代多线程主要是为了提高 CPU 利用率。举个例子:假如我们要计算一个复杂的任务,我们只用一个线程的话,只有一个 CPU 被利用到,而创建多个线程就可以让多个 CPU 被利用到,这样就提高了 CPU 的利用率。


线程的状态流转(生命周期)

请参考:线程的 6 个状态(生命周期)


wait()、sleep() 、join()、yield() 有什么区别?

wait():线程等待,能释放锁,Object 对象的方法

sleep():线程等待,不释放锁

join():其他线程插队加入,当前线程等待

yield():执行该方法的线程让出 CPU 资源给其他线程

请参考:Thread 和 Object 类中的重要方法详解


如何正确停止一个线程?

在 Java 中,最好的停止线程的方式是使用中断 interrupt,但是这仅仅是会通知到被终止的线程“你该停止运行了”,被终止的线程自身拥有决定权(决定是否、以及何时停止)

可以使用 Thread.currentThread().isInterrupted() 判断是否被打断,或者有些方法也能够响应中断,比如 sleep()、wait() 、join() 等

请参考:如何正确停止线程之 interrupt 的使用


什么是线程死锁?如何避免死锁?

死锁:多个线程同时被阻塞,它们中的一个或者全部都在等待某个资源被释放。由于线程被无限期地阻塞,因此程序不可能正常终止。
死锁
死锁必须具备以下四个条件:

  • 互斥条件:该资源任意一个时刻只由一个线程占用。
  • 请求与保持条件:一个线程因请求某个资源而阻塞时,另一个线程对这个资源保持不放。
  • 不剥夺条件:线程已获得的资源在未使用完之前不能被其他线程强行剥夺,只有自己使用完毕后才释放资源。
  • 循环等待条件:若干线程之间形成一种头尾相接的循环等待资源关系。

如何避免线程死锁?只要破坏产生死锁的四个条件中的其中一个就可以了

  • 破坏互斥条件,这个条件我们没有办法破坏,因为我们用锁本来就是想让他们互斥的(临界资源需要互斥访问)
  • 破坏请求与保持条件,一次性申请所有的资源,使得其他线程不会占用掉当前线程所需的资源。
  • 破坏不剥夺条件,占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放自己占有的资源。
  • 破坏循环等待条件,可以按照顺序申请资源来预防;按某一顺序申请资源,释放资源则反序释放。

具体避免死锁的方式

  • 通过指定锁的获取顺序,比如规定,只有获得 A 锁的线程才有资格获取 B 锁,按顺序获取锁就可以避免死锁。这通常被认为是解决死锁很好的一种方法
  • 使用显式锁中的 ReentrantLock.try(long,TimeUnit) 来申请锁

为什么我们调用 start() 方法时会执行 run() 方法,为什么我们不能直接调用 run() 方法

  • 直接执行 run() 方法,会把 run 方法当成一个 main 线程下的普通方法去执行,并不会在某个线程中执行它,所以这并不是多线程工作。 调用 start 方法方可启动线程并使线程进入就绪状态,而 run 方法只是 thread 的一个普通方法调用,还是在主线程里执行。

ThreadLocal 是什么?有什么用?

  • ThreadLocal 是一个本地线程变量工具类。主要用于将私有线程和该线程存放的对象做一个映射,各个线程之间的变量互不干扰,在高并发场景下,可以实现无状态的调用,特别适用于各个线程依赖不通的变量值完成操作的场景。
  • 简单说 ThreadLocal 就是一种以空间换时间的做法,在每个 Thread 里面维护了一个以开放定址法实现的 ThreadLocal.ThreadLocalMap,把数据进行隔离,数据不共享,自然就没有线程安全方面的问题了。

ThreadLocal 原理分析

请参考:深入理解 Java 本地线程变量 ThreadLocal


synchronized 和 ReentrantLock 的区别

两者都是可重入锁

  • 可重入锁:重入锁,也叫做递归锁,可重入锁指的是在一个线程中可以多次获取同一把锁,比如:一个线程在执行一个带锁的方法,该方法中又调用了另一个需要相同锁的方法,则该线程可以直接执行调用的方法,而无需重新获得锁, 两者都是同一个线程每进入一次,锁的计数器都自增 1,所以要等到锁的计数器下降为 0 时才能释放锁

synchronized 依赖于 JVM 而 ReentrantLock 依赖于 JDK

  • synchronized 是依赖于 JVM 实现的,前面我们也讲到了 虚拟机团队在 JDK1.6 为 synchronized 关键字进行了很多优化,但是这些优化都是在虚拟机层面实现的
  • ReentrantLock 是 JDK 层面实现的(也就是 API 层面,需要 lock() 和 unlock() 方法配合 try/finally 语句块来完成)

相比 synchronized,ReentrantLock 增加了一些高级功能。主要来说主要有三点:

  • ①等待可中断:通过 lock.lockInterruptibly() 来实现这个机制;也就是说正在等待的线程可以选择放弃等待,改为处理其他事情。
  • ②可实现公平锁:ReentrantLock 可以指定是公平锁还是非公平锁,而 synchronized 只能是非公平锁。所谓的公平锁就是先等待的线程先获得锁。ReentrantLock 默认情况是非公平的,可以通过 ReentrantLock 类的 ReentrantLock(boolean fair) 构造方法来制定是否是公平的。
  • ③可实现选择性通知:ReentrantLock 类线程对象可以注册在指定的 Condition 中,从而可以有选择性的进行线程通知,在调度线程上更加灵活。 在使用 notify()/notifyAll() 方法进行通知时,被通知的线程是由 JVM 选择的,用 ReentrantLock 类结合 Condition 实例可以实现“选择性通知”

除非需要使用 ReentrantLock 的高级功能,否则优先使用 synchronized

synchronized 是 JVM 实现的一种锁机制,JVM 原生地支持它,而 ReentrantLock 不是所有的 JDK 版本都支持。并且使用 synchronized 不用担心没有释放锁而导致死锁问题,因为 JVM 会确保锁的释放。


说说 synchronized 锁升级和锁降级的过程

流程
流程:

  • 在线程运行过程中,线程先会去抢对象的监视器,这个监视器是对象独有的就相当于一把钥匙,抢到了,那么你就获得了当前代码块的执行权;
  • 其他没有抢到的线程就会进入队列(SynchronizedQueue)当中等待,等待当前线程执行完后,释放锁,再去抢监视器;
  • 最后当前线程执行完毕后通知出队然后继续重复次过程;
  • 从 jvm 的角度来看 monitorenter 和 monitorexit 指令代表着代码的执行与结束 。

SynchronizedQueue:

  • 一个比较特殊的队列,它没有存储功能,它的功能就是维护一组线程,其中每个插入操作必须等待另一个线程的移除操作,同样任何一个移除操作都需要等待另一个线程的插入操作。因此队列内部其实是没有任何一个元素的,或者说容量为 0;严格说并不是一个容器。由于队列没有容量,因此不能进行 peek 操作,因为只有移除元素的时候才有元素。

jdk1.6之后的锁升级过程:

  • 无锁:对象一开始就是无锁的状态;
  • 偏向锁:相当于给对象贴了一个标签(将自己的线程 Id 存入对象头中),下次我在进来的时候,发现标签就是我的,我就可以继续使用了
  • 轻量级锁(自旋锁):自旋锁,说白了就是自旋,想象一下有一个厕所,里面有一个人在,你很想上但是只有一个坑位,所以你只能徘徊等待,等那个人出来以后,你就可以使用了;这个自旋是使用 CAS 来保证原子性的;
  • 重量级锁:直接向 cpu 去申请锁 ,其他的线程都进入队列中等待。

锁升级是什么情况发生的:

  • 偏向锁:只有一个线程获取锁时会由无锁升级为偏向锁,主要减少无谓的 CAS 操作;
  • 轻量级锁(自旋锁):当产生线程竞争时由偏向锁升级为自旋锁,减少线程阻塞唤醒带来的 CPU 资源消耗;
  • 重量级锁:当线程竞争到达一定数量或超过一定时间时,晋升为重量级锁。

锁降级:

  • 在 HotSpot 虚拟机中是有锁降级的, 但是仅仅只发生在 STW 的时候 ,只有垃圾回收线程能够观测到它,也就是说,在我们正常使用的过程中是不会发生锁降级的,只有在 GC 的时候才会降级。

谈谈 volatile 的使用及其原理

请参考:volatile 关键字


Java 中的 fork join 框架是什么?

  • fork join 框架是 JDK1.7 中出现的一款高效的工具,Java 开发人员可以通过它充分利用现代服务器上的多处理器。它是专门为了那些可以递归划分成许多子模块设计的,目的是将所有可用的处理能力用来提升程序的性能。fork join 框架一个巨大的优势是它使用了工作窃取算法,可以完成更多任务的工作线程可以从其它线程中窃取任务来执行。

说说 CountDownLatch 原理

CountDownLatch 是同步工具类之一,可以指定一个计数值,在并发环境下由线程进行减 1 操作,当计数值变为 0 之后,被 await 方法阻塞的线程将会唤醒,实现线程间的同步。

原理:

  • CountDownLatch 类中只提供了一个构造器:
// 参数 count 为计数值
public CountDownLatch(int count) {  };  
  • 类中有三个方法是最重要的:
// 调用 await() 方法的线程会被挂起,它会等待直到 count 值为 0 才继续执行
public void await() throws InterruptedException { };   
// 和 await() 类似,只不过等待一定的时间后 count 值还没变为 0 的话就会继续执行
public boolean await(long timeout, TimeUnit unit) throws InterruptedException { };  
// 将 count 值减 1
public void countDown() { };  

说说 CyclicBarrier 原理

循环栅栏,它的作用就是会让一组线程都等待完成后才会继续下一步行动,而且它是可以被重用的。比如可以用于多线程计算数据,最后合并计算结果的场景。

CyclicBarrier 在使用一次后,下面依然有效,可以继续当做计数器使用,这是与 CountDownLatch 的区别之一。

重点方法:

// 等到所有的线程都到达指定的临界点
await() throws InterruptedException, BrokenBarrierException 

// 与上面的 await 方法功能基本一致,只不过这里有超时限制,阻塞等待直至到达超时时间为止
await(long timeout, TimeUnit unit) throws InterruptedException, BrokenBarrierException, TimeoutException 

// 获取当前有多少个线程阻塞等待在临界点上
int getNumberWaiting()

// 用于查询阻塞等待的线程是否被中断
boolean isBroken()

// 将屏障重置为初始状态。如果当前有线程正在临界点等待的话,将抛出 BrokenBarrierException。
void reset()

说说 Semaphore 原理

信号量,用来在并发下管理数量有限的资源,是典型的共享模式下的 AQS 的实现。

线程可以通过 acquire() 方法来获取信号量的许可,当信号量中没有可用的许可的时候,线程阻塞,直到有可用的许可为止;线程可以通过 release() 方法释放它持有的信号量的许可


说说 Exchanger 原理

  • 交换者,用于线程间协作的工具类,当一个线程到达 exchange 调用点时,如果它的伙伴线程此前已经调用了此方法,那么它的伙伴会被调度唤醒并与之进行对象交换,然后各自返回。如果它的伙伴还没到达交换点,那么当前线程将会被挂起,直至伙伴线程到达完成交换正常返回,或者当前线程被中断抛出中断异常,又或者是等候超时抛出超时异常。

如何让子线程优先执行完再执行主线程


线程池的几种方式

  • 利用 Executors 静态工厂 创建不同的线程池满足不同场景的需求
  • newFixedThreadPool(int nThreads):指定工作线程数量的线程池
  • newCachedThreadPool():处理大量短时间工作任务的线程池
    • 试图缓存线程并重用,当无缓存线程可用时,就会创建新的工作线程
    • 如果线程闲置的时间超过阈值,则会被终止并移出缓存
    • 系统长时间闲置的时候,不会消耗什么资源
  • newSingleThreadExecutor():创建唯一的工作线程来执行任务,如果线程异常结束,会有另一个线程取代它
  • newSingleThreadScheduledExecutor() 和 newScheduledThreadPool(int corePoolSize):定时或者周期性的工作调度,两者的区别在于单一工作线程还是多个线程
  • newWorkStealingPool():内部会构建 ForkJoinPool,利用working-stealing算法,并行的处理任务,不保证处理顺序。
  • 线程池的大小如何选定
    • CPU密集型:线程数 = 按照核数或者核数 + 1设定
    • I/O密集型:线程数 = CPU核数 * (1 + 平均等待时间/平均工作时间)

CAS 乐观锁

首先,乐观锁就是每次都不加锁,而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止。

乐观锁实现的机制就是 CAS 操作,CAS 是英文单词 Compare And Swap 的缩写,翻译过来就是比较并替换。

CAS 机制当中使用了 3 个基本操作数,内存地址 V,旧的预期值 A,要修改的新值 B

更新一个变量的时候,只有当变量的预期值 A 和内存地址 V 当中的实际值相同时,才会将内存地址 V 对应的值修改为 B。


ABA 问题

首先,CAS 会导致 ABA 问题,CAS 算法实现一个重要的前提是需要取出内存中某时刻的数据并在当下时刻比较并替换,那么在这个时间差类会导致数据的变化。

比如线程 A 从内存位置取出 value1,这时候线程B执行,将 value1 -> value2 -> value1,这时 A 进行 CAS 操作发现内存仍然是 value1,然后线程 A 操作成功,但是这个过程中线程B可能操作了其他数据,造成问题。

修改方案:应该由“值”比对,优化为“版本号”比对。参考:AtomicStampedReference.class


乐观锁的业务场景及实现方式

版本号机制,在数据表中加上一个版本号 version 字段,当线程更新数据值时带上版本号和数据库中的 version 版本号比对,相同的话就允许更新,否则重试更新操作,直到更新成功。

CAS 算法。


Java 中的线程池 ThreadPoolExecutor

线程池,主要是通过维护一些线程不被销毁,复用这些线程来减少创建线程的消耗。一个线程生命周期可以分为 3 部分:

  • 创建线程
  • 线程执行
  • 销毁线程

采用线程池其实就是实现减少创建和销毁线程的时间损耗。

好处:
1、降低资源消耗:通过重用已经创建的线程来降低线程创建和销毁的消耗。
2、提高响应速度:任务到达时不需要等待线程创建就可以立即执行。
3、提高线程的可管理性:线程池可以统一管理、分配、调优和监控。


ThreadPoolExecutor 的属性

  • corePoolSize:核心线程数,即空闲时保留的线程数。
  • maximumPoolSize:最大线程数,代表该线程池能同时执行任务的最大线程数。
  • keepAliveTime:线程的存活时间,一般指超过 corePoolSize 数的线程最久能保留的时间。
  • threadFactory 线程的制造工厂,线程池通过他来创建线程,可以自定义一些线程的基本属性。
  • workQueue:任务提交的阻塞队列,不同的队列有不同的任务执行顺序,一般我们提交的任务的阻塞队列,实现 BlockingQueue 接口,常用的有这四种队列:
    • ArrayBlockingQueue:有界,先进先出
    • LinkedBlockingQueue:无界,先进先出
    • PriorityBlockingQueue:优先级队列,无界,根据传入的比较器排序;采用二叉堆结构存储数据
    • SynchronousQueue:一个不存放数据的缓存队列
    • PS:当线程池任务阻塞队列无界时,最大线程数和线程存活时间是不生效的。
  • RejectedExecutionHandler:线程池的拒绝任务的执行策略
    • AbortPolicy:直接抛出异常,默认策略;
    • CallerRunsPolicy:用调用者所在的线程来执行任务;
    • DiscardOldestPolicy:丢弃任务队列中靠最前的任务,将当前任务丢进任务队列;
    • DiscardPolicy:直接丢弃任务;
    • PS:注意点主要是有的拒绝策略会丢弃任务队列中的任务,会造成任务没有执行。

流程图:
线程池


常见的几种线程池

一般常用的线程池 Java 提供的 Executors 类已经为我们提供了,分别是:

  • Executors.newFixedThreadPool : 创建一个固定工作线程数的线程池
  • Executors.newSingleThreadExecutor :创建一个只有一个工作线程数的线程池
  • Executors.newCachedThreadPool : 创建一个可缓存的线程池,当有空闲线程时会执行任务,没有空闲线程时会创建一个新线程
  • Executors.newScheduledThreadPool :创建一个固定工作线程数,时间调度任务的线程池
  • 以上四种线程池可以说都是不同的 ThredPoolExecutor 的实现。
  • PS:阿里的 Java 文档并不建议直接使用 Executors 提供的线程池,建议我们自己 new 一个 ThreadPoolExecutor。
  • 2
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

发飙的蜗牛咻咻咻~

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值