许吉友 - 运维

Java 并发总结

一,并发的必要性

首先说并发与并行,这两者有都可以让人感觉多个程序是在一起执行,其实实际上并发不能让多个程序一起执行,但并行可以。

并行可以理解为多个cpu同时运行不同的程序,当然,cpu与外围的IO设备一起干不同的工作,这也可以称之为并行。cpu给外围IO设备发出指令后就不再参与IO设备的工作,等到IO设备给cpu发中断之后cpu才开始处理IO设备的运行后的结果。

并发是几个程序都等待使用一个cpu,每个程序都能占用一段cpu时间,所以是串行的,不过因为cpu指令运行是非常快的,所以每个程序占用的时间是非常短的,以至于让人认为这些程序是在一起执行的。

并发有缺点,就是在程序(进程或线程)切换时,需要进行上下文切换,这就降低了cpu的吞吐率,因为进行切换是额外的工作,额外的工作做多了,必然会导致正常工作的时间减少。

在CPU密集型的工作上,并发不会带来好处,因为CPU会不停的进行计算,进行上下文切换会降低CPU的工作效率,做一些不必要的工作,但CPU密集型的工作一般用在科学计算上,我们一般不会接触到,我们接触更多的是IO密集型的工作!

IO密集型的工作会经常进行IO操作,像文件读取,网络通信等,IO操作比CPU慢成千上万倍,如果一个CPU核心上只跑一个进程(该进程只有一个线程)的话,这个进程在进行IO操作时,CPU是空闲的。为了充分利用CPU,应该在CPU等待IO操作时忙其他的工作。

现代处理器,都会搭载4个以上的CPU核心,要充分利用这些处理器,就要同时使用并行与并发技术来提高CPU的工作效率,充分利用现有的资源。

二,线程与进程

上面说到并发时会进行上下文切换,这里的切换可能是进程切换也可能是线程切换。操作系统会对进程进行调度,拿linux内核来说,主要有两种进程调度方式,完全公平调度和实时调度,不管是何种调度方式,操作系统都会分配给每个进程相应的时间片,而进程应该做的,就是充分利用有限的时间片,来做更多的事情。

如果一个IO密集型的进程只实现了一个线程,那么在进行IO操作时,如果等待IO完成,如前所说,会造成CPU空闲,如果这个进程对IO操作进行了阻塞,想让CPU忙起来,但是他自己只有一个线程,那CPU只能说:对不起,这是你自己放弃的,我已经给你机会了!然后CPU就将这个不珍惜机会的进程睡眠,然后根据调度算法将下一个进程切换进来运行,等一会IO操作完成了,IO设备会像CPU发中断,CPU运行操作系统中的中断程序,这时候操作系统会根据相应的情况采用抢占机制,让之前的那个进程唤醒,继续执行。

由此看出,单线程的进程效率不高,如果这个进程是这个主机上的主要业务进程,那么这个进程肯定不能很好的完成任务,并且因为其是单线程,所以这个进程只能在单个CPU上运行,这样的进程是非常臭的,开发人员应该避免写出这样的程序。

在进程进行上下文切换时,工作量是非常大的,需要保存寄存器状态,运行时栈,高速缓存中的内容,然后运行调度算法,选择接下来要运行的进程,再恢复接下来要运行的进程的环境。如果每个IO密集型进程都是单线程的,那么就会造成CPU空闲,或者造成CPU频繁的切换进程。

多线程可以解决上述问题,当一个线程执行IO操作,这个线程可以选择阻塞,也可以选择非阻塞。当其选择阻塞时,可以让这个进程中的其他线程运行,因为线程之间可以共享进程的内存环境,所以线程切换要比进程切换快;当其选择非阻塞时,这个线程可以在请求IO操作之后不等待结果,而是做其他事情,这样连线程切换都避免了,但非阻塞有个弊端,就是在IO操作完成之后必须有人给这个线程发信号,让这个线程获得IO操作的结果,线程不断轮询是个简单的方式,但是其效率低下(不过如果要是保证轮询时间小于线程切换的时间,那么轮询的效率比线程阻塞要高,不过这种情况很少见)。这个通知是不容易做到的,可能需要借助操作系统,这样就对开发人员的水平提出了要求,提高了编程工作的困难,不仅如此,还损失了代码的简单性,进而提高了维护成本。

作为开发人员,我们只能通过调整进程的优先级来使我们开发的程序获得更大的时间片,除此之外,很难做到让我们开发的程序获得更多的CPU的时间,但是我们有权自由地调整程序的线程,选择合适的并发模型,让程序充分利用时间片。

要注意,这里不是线程越多越好,线程多了反而要进行频繁的线程调度,还更容易造成线程安全,活跃性等问题,所以我们在开发时,应该思考如何做到高并发。

三,并发带来的问题

因为线程共享进程内存,进程共享底层硬件资源(文件,块设备,字符设备,网络设备,虚拟设备),所以当多个线程或多个进程请求相同的资源时,会发生竞态条件,如果不对其进行同步,因其访问顺序混乱,会造成难以预料的结果。所以在发生竞态条件时,对其进行同步是不可避免的,同步就会造成串行化。

不合理的并发还会造成活跃性问题,如死锁,饥饿,活锁,响应慢。

过多的并发还会造成过多的线程切换,降低性能

尽量减少串行化,提高效率!

不管是在linux内核中,还是在linux用户空间中,亦或是在jvm虚拟机中,环境都给我们提供几种同步的机制,如对原子操作的支持,互斥锁,自旋锁,读写锁,信号量等,linux内核中还有RCU机制。还有进程间的通信机制,如通道,FIFO,消息队列,共享内存区,网路套接字。另外,java有阻塞队列,非阻塞队列和并发框架Executor等一些列的API,当然这些好用的API在linux用户态c语言编程中很少的,玩c语言只能手撕出这些数据结构。这也是java编程体系的好用之处。

但这些终归只是一些机制!

这些机制只是提供解决并发问题的原始材料,如果不能系统的加以利用,那造出来的还是一堆垃圾,因此开发人员应尽力利用这些已有的机制,创造出高并发的程序。

java并发编程:

准则:先保证正确,再提高效率

一,java有三种方式创建线程

  1. 继承Thread;
  2. 实现Runnable;
  3. 实现Callable,此方式实现的线程可以有返回值,可以抛出异常

二,java避免竞态条件,实现线程安全对象的三种方式

  1. 使对象不可变;
  2. 不在线程间共享对象;
  3. 对对象使用同步机制,使其具有原子性,同步机制会造成串行化,影响效率,减少参与同步的线程,如果全部线程都参与了这个对象的同步,那就跟单线程一样了,这时候就应该重新设计程序了;

这三种方式只需实现其中一种即可。

三,导致竞态条件的情况

  1. 先检查后更新,这种叫做延迟初始化。
  2. 复合操作,如读取-修改-写入

总之,在有修改域状态(域:成员变量或类变量)的地方,如果多个线程都有可能运行到这段代码,那就极可能发生竞态条件,当然如果你保证不会有两个以上的线程不会同时访问这块易变的代码,那就可以不使用同步,这样会提高效率,但是这种事情一定要写好文档,当然如果不确定,加上锁是最保险的方式,因为在使用非竞争锁(不会被多个线程争抢的锁)时,jvm会自动优化,将这种锁去除。

四,线程安全---不可变对象

不可变对象一定是线程安全的!要实现不可变对象要注意几点。

  1. 域是final的或者是私有的;
  2. 不要实现修改域的方法,只实现获取域的方法;
  3. 对象在初始化后应该填充所有域;
  4. 域如果是可变对象(如ArrayList)的话,则不应暴露任何发布该对象的方式;
  5. 确保在构造对象的过程中this不会溢出,只要保证在构造器中不要干奇葩的事就行了,构造器就是用来初始化对象域的!
  6. 域中可以有线程安全的对象

java API中有很多这样的类,如String,基本类型包装类,BigInteger,BigDecimal等。

如果不得已要改变,可以学String,返回一个新对象。

不过在改变大对象时,如一个含有成千上万个字符的String,然后你想改变其中一个字符,这样太浪费内存,也影响效率,一种替代方式是为这个对象造一个可变的替代版本,如String的替代版本StringBuilder。

如果不能实现不可变类,也应尽量减少域可能的状态,提前想好域可能的状态,然后使用枚举,尽量缩小访问范围,记得写好文档,做好记录。

五,线程安全---不共享对象

如果不共享对象,那么在多线程中使用这个对象就和单线程一样,单线程是绝对线程安全的,所以不共享对象也是绝对线程安全的,不过要小心,避免不共享对象溢出。因为线程栈具有天然的线程隔离性,所以在方法内生成与使用的对象是线程安全的,但要保证其不要溢出。溢出就是不安全的发布,发布是将对象给别人。

如果想在线程中使用不共享对象,那么就要在刚开始时就写好文档,并认真检查其是不是溢出了。

六,线程安全---同步---加锁机制

  1. 使用java内置锁,也叫监视器,即关键字:synchronized,这是唯一的一种在语言级别支持的加锁机制,可以锁住一个对象,包括Class对象(即类),这个锁是可重入的,可重入这个词的意思是获得这个对象的锁的线程可以继续获得这个对象的锁,因为synchronized只能用在代码块上和方法上,最高级别也只是方法级别,所以其不能用在类上。可重入使线程可以在类的多个方法,甚至跨类的多个方法中使用同一个对象的锁。

    不过最好不要将一个锁锁定过大的范围,这种大范围锁叫粗粒度锁,粗粒度锁加大了串行化,提高了死锁概率。当然也不要让锁粒度过细,如果细到只改变一个域状态,那最好使用原子变量,不应让锁获取操作的工作量大于实际工作的工作量。开发人员要平衡锁粒度,使其在并发性,简单性,效率之间达到平衡

  2. 原子变量,原子变量通常用于计数,统计数据等,原子变量是粒度最细的锁,但其没有锁的一些弊端,因为原子变量不需要挂起和重新调度线程,它直接使用了底层硬件的支持,如CPU支持“测试并设置”,“获取并递增”,“交换”等指令,这通过底层硬件只需一个指令。java.untl.concorrent包***有12个原子类,他们分为四组:标量类,更新器类,数组类,复合变量类,标量类最常用如AtomicInteger,最神奇的是数组类,可以让在数组上的操作也变成原子的。

    synchronized是悲观锁,原子变量是乐观锁,synchronized只允许一个线程进入,需要挂起和重新调度线程。原理变量的原理就是如果多个线程同时修改了,那么就重试,然后随机时间后重试,直到成功。原子变量的这种机制类似与网络冲突域中的CSMA/CD协议。原子变量在过度拥塞的环境中比锁的效率低,因为其会一直重试,不过在一般环境中效率还是比锁高的。

  3. Lock接口与ReentrantLock实现类,这种API级别的自旋锁比synchronized更灵活,但必须注意的是Lock必须要解锁!最好在finally块中解锁!切记!synchronized有自己的范围,不需解锁。synchronized在锁被占用时,其他线程必须阻塞,而Lock则不必,Lock可以使用tryLock()或定时锁,或者轮询,虽然效率不高,但可以自定义,给了我们更大的自由,这种锁可以有效的避免死锁的产生。另外,Lock可以设置为可中断的,使线程在阻塞时也可以响应中断,Lock还可以实现公平锁,不过公平锁用到了同步队列,会影响效率的,开发人员自己把握要不要使用公平锁。

  4. ReadWriterLock读写锁,适用于读多写少的场景,读写锁是可重入的,写锁可以降级为读锁,但读锁不可升级为写锁,写锁与写锁互斥,写锁与读锁互斥,读锁与读锁不互斥

  5. Semaphore与CountDownLatch与CyclicBarrier与Phaser,这几个都可以实现信号量的功能,互斥量是信号量的特例,这几个类功能是相近的,就是在一些细节上不同,Semaphore信号量就是可以规定让几个线程可以同时运行,比如每个线程都需要消耗一个资源,但资源都有限的,所以这里可以规定可以有几个线程来消耗资源。CountDownLatch与CyclicBarrier与Phaser可以让几个线程同时运行,定点一块结束。

  6. 特殊:volatile变量,个人感觉这个东西比较鸡肋,他可以保证可见性,不保证原子性,不保证原子性好懂,保证可见性太抽象,现在有点不理解,说说我的理解(记忆)吧,volatile可以保证从内存中取得的变量改完后,立即写回内存,而不保存在高速缓存中,因为保存在CPU高速缓存中会使其他CPU不可见。另外由于大多数jvm不支持64位操作,所以double和long都分成了32位操作,这样分步操作就可能会产生竞态条件,所以使用volatile修饰可以使64位操作的可见性。应该就这俩功能吧。

七,线程池

java在java.util.concurrent包中提供了一个线程池框架,可以重用线程。其API主要包含下面的几个接口与类

Executor接口,只有一个execute方法,该接口只是一个规范,说明。

ExecutorService接口,增加了几个方法,ThreadPoolExecutor实现了这个接口,ThreadPoolExecutor也是常用的线程池类。

ThreadPoolExecutor需要非常多的配置参数,可以使用Executors这个工厂类产生默认的ThreadPoolExecutor对象。