并发和并行
并发指的是某个时间段内,多任务交替处理的能力,每个CPU不可能把一个任务执行完再执行下个任务,这样很多任务就会一直处于等待状态,所以CPU有个时间片的说法,每个任务执行完时间片的时间之后就会释放CPU的资源,其他任务就会来抢占CPU的资源。
并行指的是多核CPU可以同时进行多个任务的处理,它们是在同时执行的,而不是像并发那样,并发在某一个特定的时间点其实只有一个在运行。
并发和并行都是为了尽可能块地执行完所有的任务,比如两个医生都在坐诊给病人看病这就是并行,其中一个医生,一会回答病人问题,一会给病人开药,然后又继续回答病人问题,就是并发(虽然这个时间片可能有点长)。
并发的环境下程序的封闭性被打破,出现了以下特点:
- 并发程序之间的制约关系。直接制约为一个程序需要另一个程序的处理结果,简介制约为多个程序共享资源,比如处理器、缓冲区等。
- 并发程序是断断续续的,程序需要记忆现场指令和执行点。
- 并发数合理并且CPU处理能力足够的时候,并发会提高程序运行的效率。
线程安全
产生原因 — 竞态条件和临界区
当两个线程竞争同一资源时,如果对资源的访问顺序敏感,就称存在竞态条件。导致竞态条件发生的代码区称作临界区。上例中add()方法就是一个临界区,它会产生竞态条件。在临界区中使用适当的同步就可以避免竞态条件。
合理的定义
当多线程访问某个类时,不管运行时环境采用何种调度方式或者这些线程将如何交替执行,并且在主调代码中不需要任何额外的同步或协同,这个类都能表现出正确的行为,那么就称这个类是线程安全的。
合理设置线程数
线程是 CPU 分发和调度的基本单位,一般为了合理利用 CPU 资源都会使用多线程处理,多线程主要是为了提高任务的平均执行速度,但是会导致程序让别人理解起来更麻烦一点。比如楼下一车砖需要搬到 17 楼上去,十个人一起搬肯定比 1 个人搬得快,完成这个任务的总时间也会大大减少,但是如果考虑上下楼梯是两个人遇到可能会让路耽搁时间,所以一个人搬一次上去的时间可能比之前慢,所以如果很多人一起搬的话可能并不比10个人效率高,所以需要合理地设置人数,对比到程序中来说也是,合理设置线程数才能 CPU 资源充分被利用。
线程的五种基本状态
NEW — 新建状态
新建状态是线程已经创建,但是还没有启动的状态,创建线程的方式有如下三种:
- 继承 Thread 类
- 实现 Runnable接口
- 实现 Callable 接口
一般来说不使用第一种方式,继承 Thread 类,因为不符合里式替换原则(因为 Thread 中 run() 不是个抽象方法,子类会覆盖父类的行为),而使用 Runnable 接口可以让编程更加方便优雅,Java8 以后使用 lambda 表达式实现 run() 方法也非常方便。
Callable 接口代码如下:
1 |
|
从代码中可以看到 Callable 的方法是有返回值的,而继承 Thread 和 实现 Runnable 这两种方式都是没有直接的返回值的,使用 Callable 和 Future能够获取到返回值,并且 Callable 的 call() 方法可以抛出异常,而 Runnable 只能通过 setDefaultUncaughtExceptionHandler()
才能在主线程中捕获子线程的异常。
下面有一个测试 Callable 返回值和 抛出异常的 demo 代码
1 | public class Test1 { |
从下面的执行结果可以看出,Callable 抛出的异常在主线程中能够成功捕获到
1 | 捕获到执行异常 |
Runnable 接口的异常捕获方法如下:
1 | public class ThreadExceptionTest { |
从执行结果中可以看到,也成功捕获到了子线程抛出的异常。
1 | Thread:Thread[Thread-0,5,main] Exception message:java.lang.NullPointerException: Runnable抛出个空指针异常 |
RUNNABLE — 就绪状态
Thread 实例调用 start() 之后,就进入 RUNNABLE 状态,同时 start() 方法不能多次调用,不然会抛出 IllegalStateException
异常。
RUNNING — 运行状态
run() 方法正在执行的状态,CPU 正在运行这个任务,但是线程可能会由某些因素而退出 RUNNING 状态,比如时间,异常,锁,调度等。
BLOCKED 状态
处于运行状态中的线程由于某种原因,暂时放弃对 CPU 的使用权,停止执行,此时进入阻塞状态,等它再次进入到 RUNNABLE 状态,才有机会再次被 CPU 调用以进入到运行状态。
- 等待阻塞: 运行状态中的线程执行wait()方法,使本线程进入到等待阻塞状态
- 同步阻塞: 线程获取锁失败了,会进入阻塞状态
- 其他阻塞: 通过调用线程的
sleep()
,join()
或发出I/O
请求时,线程会进入到阻塞状态。当sleep()
状态超时、join()
等待线程终止或者超时、或者I/O
处理完毕时,线程重新转入就绪状态
DEAD 状态
终止状态,也就是 run()
方法执行结束,或异常退出的状态,DEAD 状态不能逆转。
线程安全问题
各个线程轮流使用CPU的计算资源,可能会出线某个线程的任务并没有执行完成就不得不中断运行的情况,容易出线线程不安全。比如服务端共享一个用户的数据,线程 A 查询了用户的数据出来,但是查询结果还没返回 CPU 就让出了资源给了 B 线程,B 线程覆盖了用户的数据,最后 A 重新得到 CPU 的调度执行,将数据返回给前端(这里的数据已经经过了 B 的污染),这里前端得到的数据就是错的。
因此多个线程并发竞争共享资源时,通常采用同步机制协调各个线程的执行,才能得到正确的结果。
为了保证高并发下的线程安全问题,可以从如下几个方面进行考虑:
数据单线程可见
单线程是安全的,可以通过限制数据在单线程内可见,就不会产生数据被其他线程更改的情况,常见的就是线程局部变量,他存储在虚拟机栈的局部变量表中,与其他线程没有关系,ThreadLocal
就是通过这种方式来实现线程安全的。
只读对象
对于一个只读对象来说他总是线程安全的,比如String、Integer,只读对象就是属性可以访问,允许复制,拒绝写入,一个对象想要拒绝任何写入比如满足类本身被 final
修饰,使用 private final 修饰属性,避免属性被修改,没有更新的方法。
线程安全类
某些线程安全类内部有实现线程安全机制,比如 StringBuffer
类就是一个线程安全类,它采用 synchronized
关键字来修饰相关方法。
同步与锁机制
想要对一个对象进行并发更新操作,但是又不满足上面的几种情况,就需要自己实现线程安全的更新操作了。
JUC包简介
线程安全的核心理念就是 要么加锁要么只读,合理利用 JUC
(java.util.concurrent)并发包可以解决很多问题,JUC
主要如下几类:
- 线程同步类: 这些类使我们在进行线程之间的协调工作的时候更加地容易,让我们逐步淘汰使用
Object
的wait()
和notify()
这种同步的方式,主要有CountdownLatch
,Semaphore
,CyclicBarrier
等。 - 并发集合类: 集合的并发操作要求的是能够支持并发操作,比如我们最常听到的
ConcurrentHashMap
,经过不断地优化,从最初的分段锁到现在的CAS
,来提升并发操作的性能,其他的还有ConcurrentSkipListMap
,CopyOnWriteArrayList
,BlockingQueue
。 - 线程管理类: 根据实际场景的需要,Java 提供了多种创建线程池的方法,比如我们可以使用
Executors
静态工厂或者使用ThreadPoolExecutor
等。同时也还有比如ScheduledExecutorService
来执行定时操作。 - 锁相关的类: 以
Lock
接口为核心,派生出一些在实际操作中互斥操作的锁相关类,看到最多的ReentrantLock
。