赞
踩
在以前计算机是单核CPU的时候,不会存在并发,因为每次只能执行一个任务。随着科技进步,CPU性能的提高,由之前的单核CPU进化成多核CPU。如果依旧同时只存在一个任务运行,则CPU的性能会大大浪费,因此产生的并发,要求多个任务一起运行。
最开始并发的实现是基于进程实现的并发。但是因为创建进程的资源消耗比较大,所以选择了比进程体量更小的线程。
线程是指一个单一的控制流。一个进程中可以有多个线程并发的执行,每一个线程执行一个不同的任务。在Java中,Java中的线程和操作系统的线程1:1相同的,即可以理解Java中的线程就是操作系统中的线程。
多线程是多任务的一种表现形式,但是多线程与多进程相比,使用了更小的资源开销。进程内的多个线程会共享进程中的内存空间。
一个进程包括了由操作系统分配的空间,以及一个或者多个线程。进程是资源分配的最小单位,线程是进行资源调度的最小单位。一个线程不能独立存在,只能存在与线程的内部。
线程的生命周期主要有五个部分:创建、阻塞、运行、就绪和死亡,他们之间的转换关系如图所示。
线程的运行是不能由程序员决定的,是由操作系统进行任务调度的时候,执行线程。
当我们使用多线程技术解决系统的并发能力的时候,会重复的去创建线程、销毁线程。而创建线程和销毁线程的开销会比较大。除此之外,可能页会更加的关注这多个任务之间的关系,以及对任务进行管理和维护等,线程池都可以很好的帮我进行管理和维护。
创建线程的开销:内存(堆栈内存使用的是JVM外的内存)、线程的切换。具体可以参考文章。
使用线程池技术可以动态的维护线程的创建和销毁,以及维护任务之间的执行的顺序任务管理等。
Java中提供了一系列创建线程池的方式,可以选择使用预先设定的线程池,也可以自己根据业务特点自己创建线程池。
Java在Executors
中提供了五种默认的线程池,可以在不同的场景使用,分别是:
固定线程数的线程池,无论线程池中的任务数,线程的数量是固定的。是一个无界队列的线程池,如果任务巨多,会出现OOM的情况。
创建一个单线程的线程池,线程池中有且仅有一个线程,如果在执行过程中出现异常,线程死亡,会重新创建一个新的线程顶替。与newFixedThreadPool
不同的是,该线程池返回的线程不可重新配置。他使用的队列也是无界队列。
创建的线程数最大为Integer.MAX_VALUE
,如果之前创建的线程不可用,则会创建一个新的线程,如果之前创建的线程可用,则会使用已经存在的线程。该线程的队列无存储空间,因为每到一个任务,便会执行一个任务。
创建一个可以定时或者延迟执行任务的线程池。创建的最大的线程数量为Integer.MAX_VALUE
,使用的队列是无界延时队列。
创建一个可以保证并行度的线程池,线程池在执行过程中,可能会动态的增加或者减少线程的数量。
除了上述的五种默认的线程池外,可以根据业务场景,创建适合场景的线程池。创建线程池的全部参数如下:
public ThreadPoolExecutor(int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue,
ThreadFactory threadFactory,
RejectedExecutionHandler handler)
[workQueue]()
中,如果workQueue也满了,就会判断当前的线程数是否小于最大的线程数,如果小于最大的线程数,则会创建线程,执行任务。allowCoreThreadTimeOut
为true,核心线程在达到回收条件的时候,就会被回收。创建线程池的时候,可以根据上面的参数和使用的时机,设置不同的参数,以达到预期的效果。
线程池的整个模型是一个生产者-消费者的模型:业务线程负责生产数据,将生产的内容放入到线程池中,线程池中的线程负责消费数据,是一个典型的生产者消费者模型。
注意点:如果使用线程池的execute执行任务的时候,如果任务失败,抛出异常,异常信息会被线程池吞掉,导致代码中没有异常信息,所以在使用线程池的时候,一定要注意异常的处理。
当使用并发编程时,可以提高CPU的利用效率,提高程序的执行速度。但是如果同时出现多个线程一起修改同一个共享变量的时候,就会出现数据的错误。例如,如果两个线程同时对字段进行自加,可能就会出现最终的结果和预期的结果不相符。
缓存带来的一致性问题
在单核CPU中,由于一个CPU只会有一个核心,所以CPU的缓存也只会存在于一个区域中。在多核CPU中,每一个核心都会有对应的缓存,每一个缓存之间的数据可能出现不同步。当CPU从缓存中读取数据的时候,读取到的缓存的值可能是不一样的。当CPU在写入写入数据的时候,会先写入到缓存,写入缓存后,更新到主存的过程中,就会出现数据的覆盖,导致数据丢失。
线程切换带来的原子性问题
在多线程中,一个线程不能一直运行,需要在运行一段时间后,在操作系统的调度下,需要进行线程的切换。当切换线程的时候,该线程对数据的处理可能还未完成,但是因为线程的切换导致线程不得不停止,下一次线程再次运行的时候会继续从切换前的状态继续运行。如果在切换有,有其他的线程更新了数据,就会导致该线程现在读取的数据是脏数据,导致数据错误。
编译优化带来的有序性问题
CPU在执行任务的时候,并不会完全按照程序员编写的代码严格的执行。CPU会在不影响执行结果的情况下,会将指令之间的顺序进行修改,以提高访问的效率。例如在创建对象的时候,正常流程下是先去申请一块内存,然后初始化对象的值,创建一个引用指向初始化的对象,然后返回值。CPU在进行优化后,可能会先去申请一块内存,然后创建一个引用,执行该内存,然后再去初始化。如果在初始化完成之前,有其他的对象调用了该对象的内存,就会出现异常错误。参考笔记
为了解决以上的问题,Java引入了一系列的工具用于解决并发中可能出现的问题。
Java内存模型
Java中的内存模型,简称JMM,指的是一套Java中用于限制指令重排和禁用缓存的规范,主要的实现是由个JVM厂商完成。
一个简单的问题
// 以下代码来源于【参考1】
class VolatileExample {
int x = 0;
volatile boolean v = false;
public void writer() {
x = 42;
v = true;
}
public void reader() {
if (v == true) {
// 这里x会是多少呢?
}
}
}
在JDK1.5之前,返回的值可能是0,也可能是42。JDK 1.5之后返回42。因为在JDK 1.5之后,引入了Happens-Befores规则。
含义:前面一个的操作结果,对于后面一个的操作是可见的。
程序的顺序性规则
例如上面的例子中,x = 42
的修改,对与 v = true
是可见的。
volatile变量规则
volatile
的写操作对后续volatile
变量的读操作是可见的。例如 v = true
对于 if (v == true)
是可见的。
传递性
如果A Happens-Before B,B Happens-Before C,则A Happens-Before C。
管程中锁的规则
管程中对一个锁的解锁 Happens-Before 对锁的加锁。例如
synchronized (this) { //此处自动加锁
// x是共享变量,初始值=10
if (this.x < 12) {
this.x = 12;
}
} //此处自动解锁
线程A对x的修改,对线程B是可见的。
线程start规则
主线程A启动子线程B后,子线程B能够看到主线程A启动子线程B前的操作。
线程join规则
主线程 A等待子线程B完成(主线程A调用子线程B的join()方法阻塞实现),当子线程B完成后,主线程A能够看到子线程对共享变量的操作。
线程中断规则
对线程interrupt()的方法调用先行发生于被中断线程的代码检测到中断事件的发生
对象终结规则
一个对象的初始化(构造函数的完成)先行发生于他的finilize()方法的开始
管程:英文是Monitor,是指一个管理共享变量以及对共享变量操作过程,让共享变量支持并发的模型。所以管程是一种模型,描述了对共享变量的操作方式。管程和信号量相比,是等价的,可以使用管程实现信号量,也可以使用信号量实现管程。
管程解决并发的思想都是把共享变量的操作进行包装。
synchronized关键字实现锁的原理是借助了JVM为每一个对象提供的对象头信息。当使用synchronized锁定一个对象的时候,会把当前获取锁成功的线程ID写入到对象头信息中。如果一个线程在获取锁的时候,发现对象头中已经存在有其他线程的信息,则该线程获取锁失败,如果该对象的对象头中,不存在锁,则会把当前线程的锁信息写入到对象头;
synchronized的锁升级过程
锁升级中的状态有:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态。
偏向锁:当线程1访问代码块并获取锁对象时,会在java对象头和栈帧中记录偏向的锁的threadID,因为偏向锁不会主动释放锁,因此以后线程1再次获取锁的时候,需要比较当前线程的threadID和Java对象头中的threadID是否一致,如果一致(还是线程1获取锁对象),则无需使用CAS来加锁、解锁;如果不一致(其他线程,如线程2要竞争锁对象,而偏向锁不会主动释放因此还是存储的线程1的threadID),那么需要查看Java对象头中记录的线程1是否存活,如果没有存活,那么锁对象被重置为无锁状态,其它线程(线程2)可以竞争将其设置为偏向锁;如果存活,那么立刻查找该线程(线程1)的栈帧信息,如果还是需要继续持有这个锁对象,那么暂停当前线程1,撤销偏向锁,升级为轻量级锁,如果线程1 不再使用该锁对象,那么将锁对象状态设为无锁状态,重新偏向新的线程。
轻量级锁:线程1获取轻量级锁时会先把锁对象的对象头MarkWord复制一份到线程1的栈帧中创建的用于存储锁记录的空间(称为DisplacedMarkWord),然后使用CAS把对象头中的内容替换为线程1存储的锁记录(DisplacedMarkWord)的地址;如果在线程1复制对象头的同时(在线程1CAS之前),线程2也准备获取锁,复制了对象头到线程2的锁记录空间中,但是在线程2CAS的时候,发现线程1已经把对象头换了,线程2的CAS失败,那么线程2就尝试使用自旋锁来等待线程1释放锁。但是如果自旋的时间太长也不行,因为自旋是要消耗CPU的,因此自旋的次数是有限制的,比如10次或者100次,如果自旋次数到了线程1还没有释放锁,或者线程1还在执行,线程2还在自旋等待,这时又有一个线程3过来竞争这个锁对象,那么这个时候轻量级锁就会膨胀为重量级锁。
重量级锁把除了拥有锁的线程都阻塞,防止CPU空转。
锁粗化:将多个连续的加锁、解锁操作连接在一起,扩展成一个范围更大的锁,避免频繁的加锁解锁操作。
锁消除:Java虚拟机在JIT编译时(可以简单理解为当某段代码即将第一次被执行时进行编译,又称即时编译),通过对运行上下文的扫描,经过逃逸分析,去除不可能存在共享资源竞争的锁
Lock和Condition是Java中提供的基于SDK实现的管程的方式,期中Lock用于解决互斥问题,Condition用于解决同步问题,对应并发编程问题中的互斥和同步。
如何解决可见性问题
Lock解决可见性问题的方式和synchronized的方式不一致,synchronized解决可见性问题的方式是基于synchronized的Happens-Before原则,Lock是基于volatile关键字的Happens-Before原则。
新建管程的原因
在造成死锁的四个条件中,synchronized无法做到 破坏不可抢占条件,因为sychronized在申请资源的时候,如果申请的资源不可用,线程会进入阻塞状态,进入阻塞状态后便不能释放已经持有的资源。如果在线程申请不到资源的时候,主动放弃已经申请的资源,便可以打破这个条件。
所以只要新提供的方法可以做到以下三点,便可打破不可抢占的条件:
所以Lock提供了三个方法:
// 支持中断的API
void lockInterruptibly()
throws InterruptedException;
// 支持超时的API
boolean tryLock(long time, TimeUnit unit)
throws InterruptedException;
// 支持非阻塞获取锁的API
boolean tryLock();
以上三种方式便是对应破坏不可抢占的条件的三种方式。
造成死锁的四个条件:互斥、不可抢占、循环等待、占有且等待。
synchronized解决循环等待的方式:当发现资源不可用的时候,可以调用wait方法,释放自己已经获得的资源;
synchronized解决占有且等待的方式:在获取资源的时候,可以一次性获取全部需要的资源,如果其中一个无法获取,则放弃整个资源的获取。
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。