Java IO NIO 并发 锁 详解

并发编程精要
本文深入讲解了并发编程的基础概念,包括IO模型、同步与异步IO、并发模型、线程安全、锁机制等内容,并介绍了Java中实现并发的具体方法和技术。

IO

IO的定义与类型

I/O,即 Input/Output(输入/输出) 的简称。指的是计算机与外部世界或者一个程序与计算机的其余部分的之间的接口。

IO分为几类,Console IOKeyboard IOFile IONetwork IO

Java IO流中分为字节流、字符流

字节流

基于字节的I/O操作接口输入和输出分别是InputStream和OputStream。

InputStream的类层次结构如下:在这里插入图片描述
OutputStream的类层次结构:
在这里插入图片描述

字符流

基于字符的读写流

Writer流
在这里插入图片描述
Reader流
在这里插入图片描述
字节流和字符流转换如下类图:
在这里插入图片描述

IO模型

概念上有 5 种模型:blocking I/O(BIO)nonblocking I/O(NIO)I/O multiplexing (select and poll),signal driven I/O (SIGIO)asynchronous I/O (the POSIX aio_functions)。不同的操作系统对上述模型支持不同,UNIX 支持 IO 多路复用。不同系统叫法不同,freebsd 里面叫 kqueue,Linux 叫 epoll。而 Windows2000 的时候就诞生了 IOCP 用以支持 asynchronous I/O。

在这里插入图片描述

同步IO和异步IO

从发出IO操作请求开始到IO操作结束的过程中没有任何阻塞,就称为异步,否则为同步

在这里插入图片描述

同步IO

同步IO大致分为阻塞IO和非阻塞IO两大类

在这里插入图片描述
IO总共有两个阶段:1-等待数据就绪、2-将数据从内核缓冲区复制到用户缓存区

  1. 阻塞和非阻塞通常用来形容多线程间的相互影响。比如一个线程占用了临界区资源,那么其它所有需要这个资源的线程就必须在这个临界区中进行等待,
    等待会导致线程挂起。这种情况就是阻塞。此时,如果占用资源的线程一直不愿意释放资源,那么其它所有阻塞在这个临界区上的线程都不能工作。

  2. 非阻塞允许多个线程同时进入临界区

临界区
临界区用来表示一种公共资源或者说是共享数据,可以被多个线程使用。但是每一次,只能有一个线程使用它,一旦临界区资源被占用,其他线程要想使用这个资源,就必须等待。
在这里插入图片描述

阻塞IO

blocking IO的特点就是在IO执行的两个阶段(等待数据和拷贝数据两个阶段)都被block了

非阻塞IO

NIO 是基于块 (Block) 的,它以块为基本单位处理数据。在 NIO 中,最为重要的两个组件是缓冲 Buffer 和通道 Channel。缓冲是一块连续的内存块,是 NIO 读写数据的中转地。通道标识缓冲数据的源头或者目的地,它用于向缓冲读取或者写入数据,是访问缓冲的接口。Channel 是一个双向通道,即可读,也可写。Stream 是单向的。应用程序不能直接对 Channel 进行读写操作,而必须通过 Buffer 来进行,即 Channel 是通过 Buffer 来读写数据的。

NIO有三大部分:BufferChannelSelector

Buffer
Buffer 是一个对象, 它包含一些要写入或者刚读出的数据。 在 NIO 中加入 Buffer 对象,体现了新库与原 I/O 的一个重要区别。在面向流的 I/O 中,您将数据直接写入或者将数据直接读到 Stream 对象中。
在 NIO 库中,所有数据都是用缓冲区处理的。在读取数据时,它是直接读到缓冲区中的。在写入数据时,它是写入到缓冲区中的。任何时候访问 NIO 中的数据,您都是将它放到缓冲区中。
缓冲区实质上是一个数组。通常它是一个字节数组,但是也可以使用其他种类的数组。但是一个缓冲区不 仅仅 是一个数组。缓冲区提供了对数据的结构化访问,而且还可以跟踪系统的读/写进程。

Buffer抽象类有几种子类的基本数据类型的IntBufferFloatBufferCharBufferDoubleBufferShorBufferLongBufferByteBuffer
在这里插入图片描述
Buffer重要属性

属性字段意思详细描述
capacity容量容量指的是缓冲区的大小。容量是在创建缓冲区时指定的,无法在创建后更改。在任何时候缓冲区的数据总数都不可能超过容量。capacity 是指 Buffer 的大小,在 Buffer 建立的时候已经确定。
limit读写限制读写限制表示的是在缓冲区中进行读写操作时的最大允许位置。比如对于一个容量为32的缓冲区来说,如果设置其limit值为16,那么只有前半个缓冲区在读写时是有用的。如果希望后半个缓冲区也能进行读写操作,就必须把limit设置为32.limit 当 Buffer 处于写模式,指还可以写入多少数据;处于读模式,指还有多少数据可以读。
position读写位置读写位置表示的是当前进行读写操作时的位置。position 当 Buffer 处于写模式,指下一个写数据的位置;处于读模式,当前将要读取的数据的位置。每读写一个数据,position+1,也就是 limit 和 position 在 Buffer 的读/写时的含义不一样。当调用 Buffer 的 flip 方法,由写模式变为读模式时,limit(读)=position(写),position(读) =0。
mark标记位置缓冲区支持标记和重置的特征,当调用mark方法时,会在当前的读写位置上设置一个标记。在调用reset方法之后,会使得读写位置回到上一次mark方法设置的位置上。进行标记时的位置不能超过当前的读写位置 。如果通过position方法重新设置了读写位置,而使之设置的标记的位置走出了新的读写位置的范围,那么该标记就会失效。

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
Buffer的方法
在这里插入图片描述
在这里插入图片描述
clear():将position置0,同时将limit设置为capacity的大小,并清除标志mark(置为-1)。clear方法并没有清除缓冲区的内容,只是重置了几个pointer位置。
在这里插入图片描述

flip():将limit设置到position的位置,然后将position置0,并清除标志mark(置为-1)
在这里插入图片描述
rewind():positon置0,清除标记位
在这里插入图片描述
mark():在position处做一下标记

reset():position回到上次标记的地方

创建Buffer

ByteBuffer b = ByteBuffer.allocate(15); // 15个字节大小的缓冲区
byte[] buffeer = new byte[2048];
ByteBuffer b1 = ByteBuffer.wrap(buffeer);//包装一个已有的数组来创建

在这里插入图片描述
Channel
JAVA NIO中,Channel作为通往具有I/O操作属性的实体的抽象,这里的I/O操作通常指readding/writing,而具有I/O操作属性的实体比如I/O设备、文件、网络套接字等等。光有Channel可不行,我们必须为他增加readding/writing的特性,因此JAVA NIO基于Channel扩展WritableByteChannel和ReadableByteChannel接口。

通道是对原 I/O 包中的流的模拟。到任何目的地(或来自任何地方)的所有数据都必须通过一个 Channel 对象。一个 Buffer 实质上是一个容器对象。发送给一个通道的所有对象都必须首先放到缓冲区中;同样地,从通道中读取的任何数据都要读到缓冲区中。

Channel是一个对象,可以通过它读取和写入数据。拿 NIO 与原来的 I/O 做个比较,通道就像是流。
正如前面提到的,所有数据都通过 Buffer 对象来处理。您永远不会将字节直接写入通道中,相反,您是将数据写入包含一个或者多个字节的缓冲区。同样,您不会直接从通道中读取字节,而是将数据从通道读入缓冲区,再从缓冲区获取这个字节。
通道类型通道与流的不同之处在于通道是双向的。而流只是在一个方向上移动(一个流必须是 InputStream 或者 OutputStream 的子类), 而 通道 可以用于读、写或者同时用于读写。因为它们是双向的,所以通道可以比流更好地反映底层操作系统的真实情况。特别是在 UNIX 模型中,底层操作系统通道是双向的。

1.Opening a FileChannel
RandomAccessFile aFile     = new RandomAccessFile("data/nio-data.txt", "rw");
FileChannel      inChannel = aFile.getChannel();

2.Reading Data from a FileChannel into buffer
ByteBuffer buf = ByteBuffer.allocate(48);
int bytesRead = inChannel.read(buf);

3.Writing Data to a FileChannel
String newData = "New String to write to file..." + System.currentTimeMillis();
ByteBuffer buf = ByteBuffer.allocate(48);
buf.clear();
buf.put(newData.getBytes());
buf.flip();
while(buf.hasRemaining()) {
    channel.write(buf);
}

4.Closing a FileChannel
channel.close();    

Selector
Selector允许单线程处理多个 Channel。如果你的应用打开了多个连接(通道),但每个连接的流量都很低,使用Selector就会很方便。

Selector(选择器)是Java NIO中能够检测一到多个NIO通道,并能够知晓通道是否为诸如读写事件做好准备的组件。这样,一个单独的线程可以管理多个channel,从而管理多个网络连接。

在这里插入图片描述

异步IO

从发出IO操作请求开始到IO操作结束的过程中没有任何阻塞,就称为异步,否则为同步

异步I/O(asynchronous I/O)由POSIX规范定义。演变成当前POSIX规范的各种早起标准所定义的实时函数中存在的差异已经取得一致。一般地说,这些函数的工作机制是:告知内核启动某个操作,并让内核在整个操作(包括将数据从内核复制到我们自己的缓冲区)完成后通知我们。这种模型与前一节介绍的信号驱动模型的主要区别在于:信号驱动式I/O是由内核通知我们何时可以启动一个I/O操作,而异步I/O模型是由内核通知我们I/O操作何时完成。

在这里插入图片描述

并发

并发概念

首先要理解什么是并发,什么是并行?

并发:一个处理器“同时”处理多个任务。这里的同时要打双引号是因为并不是真正意义的同时,是通过时间片切换。
在这里插入图片描述
并行:多个处理器(或多核) “同时”处理多个任务
在这里插入图片描述

纯CPU密集型的应用
在单核上并发执行多个请求,不能提高吞吐量
由于任务来回场景切换的开销,吞吐量反而会下降
只有多核并行运算,才能有效提高吞吐量
IO密集型的应用
由于请求过程中,很多时间都是外部IO操作,CPU在wait状态,所以并发执行可以有效提高系统吞吐量

在这里插入图片描述

线程的使用

  1. 实现 java.lang.Runnable接口
    更加灵活,可以创建一个实现Runnalbe接口的对象,放在多个Thread中执行,这个多个线程共享一个资源
    可以创建多个实现Runnable接口的对象,放在多个Thread中执行,这样每个Thread各自拥有一份资源,互不影响

  2. 继承java.lang.Thread类
    每个Thread类只能独享继承Thread的这个类的资源

  3. 实现java.util.concurrent.Callable接口
    Callable是需要返回值的任务。用于要异步获取结果或取消执行任务的场景。
    需要借助FutureTask类

public FutureTask(Callable<V> callable)
public boolean cancel(boolean mayInterruptIfRunning)
public V get() throws InterruptedException, ExecutionException

线程的方法
sleep:睡眠。在sleep期间,不会释放持有的锁
线程结束睡眠后,首先转到就绪状态(Runnable),它不一定会立即运行,而是在可运行池中等待获得cpu。
线程在睡眠时如果被中断,就会收到一个InterrupedException异常。
如:

try{
  Thread.slee(100);
}catch(InterruptedException e){
      throw new RuntimeException(e);
}

yield:当线程在运行中执行了Thread类的yield()静态方法,如果此时具有相同优先级的其他线程处于就就绪状态。那么yield()方法将把当前运行的线程放到可运行池中并使另一个线程运行,如果没有相同优先级的可运行线程,则yield()方法什么都不做。会让出cpu的控制权,但不会释放锁。

当线程放弃某个稀有的资源(如数据库连接或网络端口)时,它可能调用 yield() 方法临时降低自己的优先级,以便某个其他线程能够运行。

在实际代码中不要使用Thread.yield(),它并不可靠

join:join方法的功能就是使异步执行的线程变成同步执行

interrupt:线程中断。
1、中断是一种协作机制,其他线程不能够迫使其他线程停止。
2、中断可以认为是“提醒”。
3、响应中断的阻塞方法可以更容易的取消耗时的操作。
响应线程中断的两种方式:
1、传递InterruptedException
2、恢复中断

wait:当在一个对象上调用wait方法后,当前线程就会在这个对象上等待。比如,线程A中,调用了obj.wait(),那么线程A就会停止继续执行,而转为等待状态。等待何时结束呢?线程A会一直等到其他线程调用了obj.notify方法为止。此时,obj对象就俨然成了多个线程之间的有效通信手段。
wait和sleep的区别:wait会释放锁,sleep释放cpu,但不释放锁。

notify:唤醒。当obj.notify()被调用时,它就会从线程等待队列中,随机选择一个线程,并将其唤醒。

在这里插入图片描述

废弃的方法,尽量不用,如:暂停 suspend()重新开始 resume()销毁 destroy()停止 stop()

安全终止线程
使用中断的方式。前提是线程实现的逻辑里,本身响应中断。

1、中断是一种协作机制,其他线程不能够迫使其他线程停止。
2、中断可以认为是“提醒”。
3、响应中断的阻塞方法可以更容易的取消耗时的操作。
Thread.currentThread().interrupt()

使用一个boolean volatile变量来控制是否需要停止任务

public class Shutdown {
    public static void main(String[] args) throws Exception {
        Runner one = new Runner();
        Thread countThread = new Thread(one, "CountThread");
        countThread.start();
        // 睡眠1秒,main线程对CountThread进行中断,使CountThread能够感知中断而结束
        TimeUnit.SECONDS.sleep(1);
        //使用中断的方式
        countThread.interrupt();
        Runner two = new Runner();
        countThread = new Thread(two, "CountThread");
        countThread.start();
        // 睡眠1秒,main线程对Runner two进行取消,使CountThread能够感知on为false而结束
        TimeUnit.SECONDS.sleep(1);
        two.cancel();
    }

    private static class Runner implements Runnable {
        private long   i;
        //要使用volatile
        private volatile boolean on = true;
        @Override
        public void run() {
            while (on && !Thread.currentThread().isInterrupted()) {
                i++;
            }
            System.out.println("Count i = " + i);
        }
        public void cancel() {
            on = false;
        }
    }
}

线程的状态

在这里插入图片描述

  1. 新建状态(New) : 线程对象被创建后,就进入了新建状态。例如,Thread thread = new Thread()。
  2. 就绪状态RUNNABLE: 也被称为“可执行状态”。线程对象被创建后,其它线程调用了该对象的start()方法,从而来启动该线程。例如,thread.start()。处于就绪状态的线程,随时可能被CPU调度执行。
  3. 运行状态(Running) : 线程获取CPU权限进行执行。需要注意的是,线程只能从就绪状态进入到运行状态。
  4. 阻塞BLOCKED : 阻塞状态是线程因为某种原因放弃CPU使用权,暂时停止运行。直到线程进入就绪状态,才有机会转到运行状态。阻塞的情况分三种:
    (01) 等待阻塞 – 通过调用线程的wait()方法,让线程等待某工作的完成。
    (02) 同步阻塞 – 线程在获取synchronized同步锁失败(因为锁被其它线程所占用),它会进入同步阻塞状态。
    (03) 其他阻塞 – 通过调用线程的sleep()或join()或发出了I/O请求时,线程会进入到阻塞状态。当sleep()状态超时、join()等待线程终止或者超时、或者I/O处理完毕时,线程重新转入就绪状态。
  5. 终止TERMINATED : 线程执行完了或者因异常退出了run()方法,该线程结束生命周期。
  6. 等待超时TIMED_WAITING:超时等待状态,该状态不同于WAITING,它是可以在指定的时间自行返回的。

其他概念

死锁(DeadLock):是指两个或两个以上的进程在执行过程中,由于竞争资源或者由于彼此通信而造成的一种阻塞的现象,若无外力作用,它们都将无法推进下去。此时称系统处于死锁状态或系统产生了死锁,这些永远在互相等待的进程称为死锁进程。

饥饿(Starvation):饥饿是指某一个或者多个线程因为种种原因无法获得所需要的资源,导致一直无法执行。

活锁(LiveLock):指事物1可以使用资源,但它让其他事物先使用资源;事物2可以使用资源,但它也让其他事物先使用资源,于是两者一直谦让,都无法使用资源。

一个线程在取得了一个资源时,发现其他线程也想到这个资源,因为没有得到所有的资源,为了避免死锁把自己持有的资源都放弃掉。如果另外一个线程也做了同样的事情,他们需要相同的资源,比如A持有a资源,B持有b资源,放弃了资源以后,A又获得了b资源,B又获得了a资源,如此反复,则发生了活锁。

活锁会比死锁更难发现,因为活锁是一个动态的过程。

吞吐量(throughput):吞吐量是指系统在单位时间内处理请求的数量。对于无并发的应用系统而言,吞吐量与响应时间成严格的反比关系,实际上此时吞吐量就是响应时间的倒数。前面已经说过,对于单用户的系统,响应时间(或者系统响应时间和应用延迟时间)可以很好地度量系统的性能,但对于并发系统,通常需要用吞吐量作为性能指标。

CPU密集型 vs IO密集型

CPU密集型:又叫计算密集型。计算密集型任务的特点是要进行大量的计算,消耗CPU资源,比如计算圆周率、对视频进行高清解码等等,全靠CPU的运算能力。这种计算密集型任务虽然也可以用多任务完成,但是任务越多,花在任务切换的时间就越多,CPU执行任务的效率就越低,所以,要最高效地利用CPU,计算密集型任务同时进行的数量应当等于CPU的核心数。对于计算密集型任务,最好用C语言编写。

计算密集型,顾名思义就是应用需要非常多的CPU计算资源,在多核CPU时代,我们要让每一个CPU核心都参与计算,将CPU的性能充分利用起来,这样才算是没有浪费服务器配置,如果在非常好的服务器配置上还运行着单线程程序那将是多么重大的浪费。对于计算密集型的应用,完全是靠CPU的核数来工作,所以为了让它的优势完全发挥出来,避免过多的线程上下文切换,比较理想方案是:
线程数 = CPU核数+1
也可以设置成CPU核数2,这还是要看JDK的使用版本,以及CPU配置(服务器的CPU有超线程)。对于JDK1.8来说,里面增加了一个并行计算,计算密集型的较理想线程数 = CPU内核线程数2

IO密集型:IO密集型,涉及到网络、磁盘IO的任务都是IO密集型任务,这类任务的特点是CPU消耗很少,任务的大部分时间都在等待IO操作完成(因为IO的速度远远低于CPU和内存的速度)。对于IO密集型任务,任务越多,CPU效率越高,但也有一个限度。常见的大部分任务都是IO密集型任务,比如Web应用。

对于IO密集型的应用,就很好理解了,我们现在做的开发大部分都是WEB应用,涉及到大量的网络传输,不仅如此,与数据库,与缓存间的交互也涉及到IO,一旦发生IO,线程就会处于等待状态,当IO结束,数据准备好后,线程才会继续执行。因此从这里可以发现,对于IO密集型的应用,我们可以多设置一些线程池中线程的数量,这样就能让在等待IO的这段时间内,线程可以去做其它事,提高并发处理效率。
那么这个线程池的数据量是不是可以随便设置呢?当然不是的,请一定要记得,线程上下文切换是有代价的。目前总结了一套公式,对于IO密集型应用:
线程数 = CPU核心数/(1-阻塞系数)
这个阻塞系数一般为0.8~0.9之间,也可以取0.8或者0.9。套用公式,对于双核CPU来说,它比较理想的线程数就是20,当然这都不是绝对的,需要根据实际情况以及实际业务来调整。
final int poolSize = (int)(cpuCore/(1-0.9))

并发深入

并发优缺点

优点

  1. 并行程序在多核心cpu有优势,可并行处理任务,减少单个任务的等待时间
    比如因为IO操作遇到了阻塞,CPU可以转去执行其他线程,这时并发的优点就显示出来了:更高效的利用CPU,提高程序的响应速度。
    Java的线程机制是抢占式的,会为每个线程分配时间片。

  2. 业务需求。
    提供更好的GUI交互体验(如搜狐视频可以边下边播)

  3. 性能需要,为了响应快速。提高服务吞吐量、降低响应时间

  4. 简化任务调度

  5. 线程较进程开销更小。
    线程一般最小十几K到几十K,而进程需要初始化环境至少需要几十M甚至上百M

  6. 充分利用服务器硬件资源

缺点

  1. VM管理内存要求高
    对内存管理要求非常高,应用代码稍不注意,就会产生OOM(out of memory),需要应用代码长期和内存泄露做斗争
    GC的策略会影响多线程并发能力和系统吞吐量,需要对GC策略和调优有很好的经验

  2. 设计更复杂

  3. 线程安全问题

  4. 调试不方便

  5. 锁竞争,内存开销

线程安全

当多个线程访问一个类时,如果不用考虑这些线程在运行时环境下的调度和交替执行,并且不需要额外的同步及在调用方代码不必作其他的协调,这个类的行为仍然是正确的,那么称这个类是线程安全的。

怎么保证线程安全呢?

不可变类
如果一个类初始化后,所有属性和类都是final不可变的,则它是线程安全的,不需要任何同步,活性高。
不可变对象永远是线程安全的,因为它的状态在构造完成后就无法再改变了。所以它是线程安全的。String为不可变对象的典型代表。

线程栈内使用

  1. 方法内局部变量使用
  2. 线程内参数传递
  3. ThreadLocal持有
    ThreadLocal顾名思义,它就是thread local variable(线程局部变量)。它的功能非常简单,就是为每一个使用该变量的线程都提供一个变量值的副本,使每一个线程都可以独立地改变自己的副本,而不会和其它线程的副本冲突。从线程的角度看,就好像每一个线程都完全拥有该变量。

注意: 使用ThreadLocal,一般都是声明在静态变量中,如果不断的创建ThreadLocal而且没有调用其remove方法,将会导致内存泄露。

private static final ThreadLocal<ThreadLocalRandom> localRandom =  // ThreadLocal对象都是static的,全局共享
    new ThreadLocal<ThreadLocalRandom>() {      // 初始值
        protected ThreadLocalRandom initialValue() {
            return new ThreadLocalRandom();
        }
};

localRandom.get();      // 拿当前线程对应的对象
localRandom.put(...);   // put

防止OOM,记得set以后,需要在特定情况下remove

并发实战

  1. 无论何种方式,启动一个线程,就要给它一个名字!这对排错诊断系统监控有帮助。否则诊断问题时,无法直观知道某个线程的用途。
Thread thread = new Thread("thread name") {
public void run() {
	// do xxx
	}
};
thread.start();
  1. 程序应该对线程中断作出恰当的响应。异常不要轻易忽略
  2. 编写多线程程序,要加相关注释(方法是否线程安全、适用在那种场景使用此类或方法)
  3. 不要误杀线程(运行定时器时要使全部线程都结束才可以退出)
CAS

全称为Compare-And-Swap,使用锁时,线程获取锁是一种悲观锁策略,即假设每一次执行临界区代码都会产生冲突,所以当前线程获取到锁的时候同时也会阻塞其他线程获取该锁。而CAS操作(又称为无锁操作)是一种乐观锁策略,它假设所有线程访问共享资源的时候不会出现冲突,既然不会出现冲突自然而然就不会阻塞其他线程的操作。因此,线程就不会出现阻塞停顿的状态。那么,如果出现冲突了怎么办?无锁操作是使用CAS(compare and swap)又叫做比较交换来鉴别线程是否出现冲突,出现冲突就重试当前操作直到没有冲突为止。

CAS比较交换的过程可以通俗的理解为CAS(V,O,N),包含三个值分别为:V 内存地址存放的实际值;O 预期的值(旧值);N 更新的新值。当V和O相同时,也就是说旧值和内存中实际的值相同表明该值没有被其他线程更改过,即该旧值O就是目前来说最新的值了,自然而然可以将新值N赋值给V。反之,V和O不相同,表明该值已经被其他线程改过了则该旧值O不是最新版本的值了,所以不能将新值N赋给V,返回V即可。当多个线程使用CAS操作一个变量是,只有一个线程会成功,并成功更新,其余会失败。失败的线程会重新尝试,当然也可以选择挂起线程

在这里插入图片描述
Atomic
使用原子操作类,非阻塞,获得最好的性能。开销小,原子性、可见性
AtomicDoubleAtomicIntegerAtomicLongAtomicBooleanAtomicIntegerArrayAtomicLongArray

CAS的问题:

  1. ABA问题
    因为CAS会检查旧值有没有变化,这里存在这样一个有意思的问题。比如一个旧值A变为了成B,然后再变成A,刚好在做CAS时检查发现旧值并没有变化依然为A,但是实际上的确发生了变化。解决方案可以沿袭数据库中常用的乐观锁方式,添加一个版本号可以解决。原来的变化路径A->B->A就变成了1A->2B->3C。

  2. 自旋时间过长
    使用CAS时非阻塞同步,也就是说不会将线程挂起,会自旋(无非就是一个死循环)进行下一次尝试,如果这里自旋时间过长对性能是很大的消耗。如果JVM能支持处理器提供的pause指令,那么在效率上会有一定的提升。

volatile
可见性,如果一个变量定义为volatile,在另外一个变量引用,修改了值,会刷新值。这个关键字并不会线程同步

java中最常用的是synchronized 关键字,代表线程安全,给方法或者对象加锁,属于重量级锁

重量级锁synchronized
在JDK1.5之前都是使用synchronized关键字保证同步的,Synchronized的作用相信大家都已经非常熟悉了;
它可以把任意一个非NULL的对象当作锁。
作用于方法时,锁住的是对象的实例(this);
当作用于静态方法时,锁住的是Class实例,又因为Class的相关数据存储在永久带PermGen(jdk1.8则是metaspace),永久带是全局共享的,因此静态方法锁相当于类的一个全局锁,会锁所有调用该方法的线程;
synchronized作用于一个对象实例时,锁住的是所有以该对象为锁的代码块。
在这里插入图片描述

synchronized(lock){
	//code acess shared state
}

每个java对象都可以用做一个实现同步的锁,这些锁称为内置锁(Intrinsic Lock)或 监视器锁(Monitor Lock).
线程在进入同步代码块之前会自动获得锁,并且在退出同步代码块的时候自动释放锁。
同步块相当于一个互斥锁,最多只有一个线程能持有这种锁。
同步锁是可重入的,一个线程可以获取已持有的锁。

java中的锁,分为乐观锁悲观锁

乐观锁
乐观锁是一种乐观思想,即认为读多写少,遇到并发写的可能性低,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,采取在写时先读出当前版本号,然后加锁操作(比较跟上一次的版本号,如果一样则更新),如果失败则要重复读-比较-写的操作。

悲观锁
悲观锁是就是悲观思想,即认为写多,遇到并发写的可能性高,每次去拿数据的时候都认为别人会修改,所以每次在读写数据的时候都会上锁,这样别人想读写这个数据就会block直到拿到锁。java中的悲观锁就是Synchronized,AQS框架下的锁则是先尝试cas乐观锁去获取锁,获取不到,才会转换为悲观锁,如RetreenLock。

自旋锁
如果持有锁的线程能在很短时间内释放锁资源,那么那些等待竞争锁的线程就不需要做内核态和用户态之间的切换进入阻塞挂起状态,它们只需要等一等(自旋),等持有锁的线程释放锁后即可立即获取锁,这样就避免用户线程和内核的切换的消耗。

自旋锁尽可能的减少线程的阻塞,这对于锁的竞争不激烈,且占用锁时间非常短的代码块来说性能能大幅度的提升,因为自旋的消耗会小于线程阻塞挂起操作的消耗!
但是如果锁的竞争激烈,或者持有锁的线程需要长时间占用锁执行同步块,这时候就不适合使用自旋锁了,因为自旋锁在获取锁前一直都是占用cpu做无用功。

偏向锁
偏向于第一个访问锁的线程,如果在运行过程中,同步锁只有一个线程访问,不存在多线程争用的情况,则线程是不需要触发同步的,减少加锁/解锁的一些CAS操作(比如等待队列的一些CAS操作),这种情况下,就会给线程加一个偏向锁。 如果在运行过程中,遇到了其他线程抢占锁,则持有偏向锁的线程会被挂起,JVM会消除它身上的偏向锁,将锁恢复到标准的轻量级锁。

JVM偏向锁参数:
-XX:+UseBiaseLocking:启动偏向锁
-XX:BiaseLockingStartupDelay=0:表示JVM在启动后,立即启用偏向锁。如果不设置该参数,JVM会在启动4s后,才启动偏向锁。
-XX:-UseBiaseLocking:禁用偏向锁

轻量级锁
轻量级锁是由偏向所升级来的,偏向锁运行在一个线程进入同步块的情况下,当第二个线程加入锁争用的时候,偏向锁就会升级为轻量级锁

重入锁ReentrantLock
ReentrantLock作用和synchiroized关键字相当,但是比synchiroized更加灵活。ReentrantLock本身也是支持重入锁,可以支持对一个资源重复加锁,同时也支持公平锁和非公平锁。所谓的公平锁是指按请求的先后顺序上,先对锁进行请求的就一定先获取锁,即公平锁。反过来就是如果不按时间先后顺序,这种叫做非公平锁。一般来说,非公平锁的效率往往会胜于公平锁,但是在某些特定场景中,可能需要注重时间先后顺序,那么公平锁自然是一个很好的选择。ReentrantLock可以对同一个线程加锁多次,但是加锁多少次,就必须解锁多少次。才能成功释放锁。

属于独占锁
提供了与synchronized相同的功能
比synchronized提供了更多的灵活性(不能中断那些正在等待获取锁的线程、并且在请求锁失败的情况下,必须无限期等待)
ReentrantLock可以创建公平和非公平的锁,内部锁不能够选择,默认是非公平锁
当需要可定时的、可轮询的、可识别中断、或者公平锁的时候,使用ReentrantLocak,否则使用内部锁

import java.util.concurrent.locks.ReentrantLock;

public class ReenterLock implement Runnable {
    public static ReentrantLock lock = new ReentrantLock();
    public static int i = 0;
    public void run() {
        for(int j = 0;j < 10000; j++) {
            lock.lock();
            //支持重入锁
            lock.lock();
            try{
                i++;
            } finally {
                lock.unlock();
                lock.unlock();
            }
        }
    }
    
    public static void main(String[] args) throws InterruptedException {
        ReenterLock tl = new ReenterLock();
        Thread t1 = new Thread(tl);
        Thread t2 = new Thread(tl);
        t1.start();
        t2.start();
        
        t1.join();
        t2.join();
        //此时两个线程join以后,因为对线程加锁了,所以结果是20000
        System.out.println(i);
    }
    
}

//查询当前线程锁的次数
int getHoldCount();

//返回目前拥有此锁的线程,如果这个锁不被任何线程拥有,则返回null
protected Thread getOwner();

//返回一个Collection,他包含可能正在等待获取此锁的线程,其内部维持一个队列
protected Collection<Thread> getQueueThreads();

//返回正在等待获取此锁的线程计数
int getQueueLength();

//返回一个collection,包括了正在等待与这个锁相关的那些线程
protected Collection<Thread> getWaitingThreads(Condition condition);

//返回等待与此锁相关的给定条件的线程估计数。       
int getWaitQueueLength(Condition condition);

// 查询给定线程是否正在等待获取此锁。     
boolean hasQueuedThread(Thread thread); 

//查询是否有些线程正在等待获取此锁。     
boolean hasQueuedThreads();

//查询是否有些线程正在等待与此锁有关的给定条件。     
boolean hasWaiters(Condition condition); 

//如果此锁的公平设置为true,则返回true,表示是否是公平锁
boolean isFair();

//查询当前线程是否保持此锁
boolean isHeldByCurrentThead();

//查询此锁是否由任意线程持有,就是这个锁是否是锁住的状态
boolean isLocked();

ReentrantReadWriteLock
ReentrantReadWriteLock是共享锁、 Reentrantlock是互斥锁
可以实现公平、非公平的共享锁
对于有大量读请求,少量写请求的场景,性能提升比较大

ReadWriteLock 维护了一对相关的锁,一个用于只读操作,另一个用于写入操作。只要没有 writer,读取锁可以由多个 reader 线程同时保持。写入锁是独占的。

与互斥锁相比,读-写锁允许对共享数据进行更高级别的并发访问。

readLock()返回用于读取操作的锁。
writeLock() 返回用于写入操作的锁。

Condition
同synchronized中的wait,notify,notifyAll类似
使一个锁上可以有多个等待队列
使用condition.awit,condition.signal使线程被唤醒和阻塞

并发组件AQS
AbstractQueuedSynchroizer 称为队列同步器,他是用来构建锁或者其他同步组件,内部是通过一个int类型的成员变量state,当state等于0的时候,表示没有线程占有资源的锁,当state=1的时候,说明有线程正在使用共享变量,其他线程必须等待,通过FIFO来完成锁的排队工作,同时利用内部类ConditionObject构建等待队列,当Condition调用signal()的时候,线程将从等待队列转移到同步队列中进行锁竞争。注意这里涉及两种队列,一种是同步队列,当线程请求锁而等待后加入同步队列等待,而另一种是等待队列,通过Condition调用await()方法释放锁,将加入等待队列。

  • CANCELLED:值为1,在同步队列中等待的线程等待超时或被中断,需要从同步队列中取消该Node的结点,其结点的waitStatus为CANCELLED,即结束状态,进入该状态后的结点将不会再变化。
  • SIGNAL:值为-1,被标识为该等待唤醒状态的后继结点,当其前继结点的线程释放了同步锁或被取消,将会通知该后继结点的线程执行。说白了,就是处于唤醒状态,只要前继结点释放锁,就会通知标识为SIGNAL状态的后继结点的线程执行。
  • CONDITION:值为-2,与Condition相关,该标识的结点处于等待队列中,结点的线程等待在Condition上,当其他线程调用了Condition的signal()方法后,CONDITION状态的结点将从等待队列转移到同步队列中,等待获取同步锁。
  • PROPAGATE:值为-3,与共享模式相关,在共享模式中,该状态标识结点的线程处于可运行状态。
  • 0状态:值为0,代表初始化状态。

synchronized VS Lock

  • Lock与synchronized功效是一样的
  • Lock提供更好的灵活度和可伸缩性
  • 在Java1.5中Lock的性能明显强于synchronized,1.6中差距不明显
  • 使用Lock一定要记住在finally释放锁
优点缺点适用场景
偏向锁加锁和解锁不需要额外的消耗,和执行非同步方法相比仅存在纳秒级的差距如果线程间存在锁竞争,会带来额外的锁撤销的消耗适用于只有一个线程访问同步块场景
轻量级锁竞争的线程不会阻塞,提高了程序的响应速度如果始终得不到索竞争的线程,使用自旋会消耗CPU追求响应速度,同步块执行速度非常快
重量级锁线程竞争不使用自旋,不会消耗CPU线程阻塞,响应时间缓慢追求吞吐量,同步块执行速度较慢
并发集合和工具

使用java.util.concurrent里的并发设施,它们本身是Lock Free的,从而获得最佳性能。
ConcurrentLinkedQueueConcurrentHashMapBlockingQueue ……

Vector HashTable性能低下。Collections.synchronizedList更加危险,在负载重的情况下依然会抛出NP或并发异常。要少用。

使用java.util.concurrent里的并发控制工具,代替notifyAll,wait, join等。
CountDownLatch
CyclicBarrier
Semaphore
FutureTask
ExecutorService

ThreadPoolExecutor(线程池)

public ThreadPoolExecutor(int corePoolSize,
                              int maximumPoolSize,
                              long keepAliveTime,
                              TimeUnit unit,
                              BlockingQueue<Runnable> workQueue,
                              ThreadFactory threadFactory,
                              RejectedExecutionHandler handler)

corePoolSize:核心池的大小。
如果core线程数+临时线程数 >maxSize,则不能再创建新的临时线程了,转头执行RejectExecutionHanlder。默认的AbortPolicy抛RejectedExecutionException异常,其他选择包括静默放弃当前任务(Discard),放弃工作队列里最老的任务(DisacardOldest),或由主线程来直接执行(CallerRuns),或你自己发挥想象力写的一个。

maximumPoolSize:线程池允许创建的最大线程数。如果队列满了,并且已创建的线程数小于最大线程数,则线程池会再创建新的线程执行任务。

keepAliveTime:指的是空闲线程活动保持时间。如果池中当前有多于corePoolSize 的线程,则这些多出的线程在空闲时间超过 keepAliveTime 时将会终止

TimeUnit unit:是一个枚举,表示 keepAliveTime 的单位。线程活动保持时间的单位。可选的单位有天(DAYS)、小时(HOURS)、分钟(MINUTES)等。

threadFactory(线程工厂):创建线程的时候,使用到的线程工厂。可以通过线程工厂给每个创建出来的线程设置更有意义的名字。

workQueue(任务队列):用于保存等待执行的任务的阻塞队列。
ArrayBlockingQueue:基于数组结构的有界阻塞队列,FIFO。
LinkedBlockingQueue:基于链表结构的无界阻塞队列,FIFO。吞量通常高于ArrayBlockingQueue。
SynchronousQueue:不存储元素的阻塞队列。每个插入操作必须等到另一个线程调用移除操作,否则插入操作一直处于阻塞状态,吞吐量通常要高于LinkedBlockingQueue。Executors.newCachedThreadPool使用了这个队列。
PriorityBlockingQueue:具有优先级的无界阻塞队列

RejectedExecutionHandler handler(拒绝策略):当线程达到最大限制,并且工作队列里面也已近存放满了任务的时候,决定如何处理提交到线程池的任务策略。
ThreadPoolExecutor.AbortPolicy:拒绝任务并抛出异常
ThreadPoolExecutor.DiscardPolicy:拒绝任务但不做任何动作
ThreadPoolExecutor.CallerRunsPolicy:不会丢弃任务,也不会抛出异常。在调用者的线程中直接执行该任务。直接调用线程的run方法。
ThreadPoolExecutor.DiscardOldestPolicy:丢弃队列里最近一个任务(最近最早进入队列的任务),并执行当前任务。

工作流程:

  1. 每次提交任务时,如果线程数还没达到coreSize就创建新线程并绑定该任务。
    所以第coreSize次提交任务后线程总数必达到coreSize,不会重用之前的空闲线程。
    在生产环境,为了避免首次调用超时,可以调用executor.prestartCoreThread()预创建所有core线程,避免来一个创一个带来首次调用慢的问题。

  2. 线程数达到coreSize后,新增的任务就放到工作队列里,而线程池里的线程则努力的使用take()阻塞地从工作队列里拉活来干。

  3. 如果队列是个有界队列,又如果线程池里的线程不能及时将任务取走,工作队列可能会满掉,插入任务就会失败,此时线程池就会紧急的再创建新的临时线程来补救。

  4. 临时线程使用poll(keepAliveTime,timeUnit)来从工作队列拉活,如果时候到了仍然两手空空没拉到活,表明它太闲了,就会被解雇掉。

  5. 如果core线程数+临时线程数 >maxSize,则不能再创建新的临时线程了,转头执行RejectExecutionHanlder。默认的AbortPolicy抛RejectedExecutionException异常,其他选择包括静默放弃当前任务(Discard),放弃工作队列里最老的任务(DisacardOldest),或由主线程来直接执行(CallerRuns),或你自己发挥想象力写的一个。
    在这里插入图片描述
    Feture
    Future接口和实现Future接口的FutureTask类, 代表异步计算的结果。用于要异步获取结果或取消执行任务的场景。

Callable类似于Runnable,但其可以获取执行的结果。Future代表了一种异步执行的结果,可以对执行做取消,检查,获取结果等操作。

在这里插入图片描述
Executors
java.util.concurrent.Executors是Executor的工厂类,提供了许多静态工厂方法。通过Executors可以创建你所需要的Executor。

class Executors {
   ExecutorService newFixedThreadPool(int nThreads){}
   ExecutorService newCachedThreadPool() {}
      // … many more…
}

newSingleThreadExecutor:单任务线程池
newFixedThreadPool:固定大小的线程池。FixedPool默认用了一条无界的工作队列 LinkedBlockingQueue, oreSize的线程做不完的任务不断堆积到无限长的Queue中。所以只有coreSize一个参数,其他maxSize,keepAliveTime,RejectHandler的配置都不会实际生效。
newCachedThreadPool:可变尺寸的线程池。使用时候需要定义大小
newScheduledThreadPool:延迟&定时执行的线程池

在这里插入图片描述
CountDownLatch
倒数到0时,释放所有等待的线程,否则阻塞。
countDown() 倒数
await() 阻塞等待
所有线程启动后await等待,直到countDown到0时大家一起跑,模拟并发
每条线程跑完时countDown,所有线程跑完后await释放,代替join

CountDownLatch允许一个或多个线程等待其他线程完成操作。CountDownLatch 适用于一组线程和另一个主线程之间的工作协作。一个主线程等待一组工作线程的任务完毕才继续它的执行是使用 CountDownLatch 的主要场景。

在这里插入图片描述
CyclicBarrier
与CountDownLatch 不同,CyclicBarrier 是当await 的数量达到了设定的数量后,才继续往下执行。
public CyclicBarrier(int parties)
public int await() throws InterruptedException, BrokenBarrierException

CyclicBarrier 用于一组或几组线程,比如一组线程需要在一个时间点上达成一致,例如同时开始一个工作。另外,CyclicBarrier 的循环特性和构造函数所接受的 Runnable 参数也是 CountDownLatch 所不具备的。

Semaphore
Semaphore可以维护当前访问自身的线程个数,并提供了同步机制。使用Semaphore可以控制同时访问资源的线程个数,例如,实现一个文件允许的并发访问数。

用于控制某种资源 同时被 访问的个数
适用场景 :数据库连接池

ConcurrentHashMap
ConcurrentHashMap是HashMap在并发环境下的版本,大家可能要问,既然已经可以通过Collections.synchronizedMap获得线程安全的映射型容器,为什么还需要ConcurrentHashMap呢?因为通过Collections工具类获得的线程安全的HashMap会在读写数据时对整个容器对象上锁,这样其他使用该容器的线程无论如何也无法再获得该对象的锁,也就意味着要一直等待前一个获得锁的线程离开同步代码块之后才有机会执行。实际上,HashMap是通过哈希函数来确定存放键值对的桶(桶是为了解决哈希冲突而引入的),修改HashMap时并不需要将整个容器锁住,只需要锁住即将修改的“桶”就可以了。

并发优化的HashMap,默认16把写锁(可以设置更多),有效分散了阻塞的概率,而且没有读锁。
数据结构为Segment[],Segment里面才是哈希桶数组,每个Segment一把锁。Key先算出它在哪个Segment里,再算出它在哪个哈希桶里。

支持ConcurrentMap接口,如putIfAbsent(key,value)与相反的replace(key,value)与以及实现CAS的replace(key, oldValue, newValue)。

没有读锁是因为put/remove动作是个原子动作(比如put是一个对数组元素/Entry 指针的赋值操作),读操作不会看到一个更新动作的中间状态。

加锁操作是针对的 hash 值对应的某个 Segment,而不是整个 ConcurrentHashMap。因为 put 操作只是在这个 Segment 中完成,所以并不需要对整个 ConcurrentHashMap 加锁。所以,此时,其他的线程也可以对另外的 Segment 进行 put 操作,因为虽然该 Segment 被锁住了,但其他的 Segment 并没有加锁。同时,读线程并不会因为本线程的加锁而阻塞。

正是因为其内部的结构以及机制,所以 ConcurrentHashMap 在并发访问的性能上要比Hashtable和同步包装之后的HashMap的性能提高很多。在理想状态下,ConcurrentHashMap 可以支持 16 个线程执行并发写操作(如果并发级别设置为 16),及任意数量线程的读操作。

特点

  • 线程安全
  • 支持并发、无阻塞
  • 锁分离
  • volatile 、读不加锁
  • 较HashMap,高并发情景下(50-100线程并发)添加和删除性能提高一倍,查找性能提高10倍

ConcurrentLinkedQueue
如果不需要阻塞队列,优先选择ConcurrentLinkedQueue

CopyOnWriteArrayList
并发优化的ArrayList。用CopyOnWrite策略,在修改时先复制一个快照来修改,改完再让内部指针指向新数组。

因为对快照的修改对读操作来说不可见,所以只有写锁没有读锁,加上复制的昂贵成本,典型的适合读多写少的场景。如果更新频率较高,或数组较大时,还是Collections.synchronizedList(list),对所有操作用同一把锁来保证线程安全更好。

增加了addIfAbsent(e)方法,会遍历数组来检查元素是否已存在,性能可想像的不会太好。

CopyOnWriteArrayList的核心思想是利用高并发往往是读多写少的特性,对读操作不加锁,对写操作,先复制一份新的集合,在新的集合上面修改,然后将新集合赋值给旧的引用,并通过volatile 保证其可见性,当然写操作的锁是必不可少的了。
当读操作远远大于写操作的时候,考虑用这个并发集合。例如:维护监听器的集合。注意:其频繁写的效率可能低的惊人。适合于读取频繁,但很少有写操作的集合,例如 JavaBean事件的Listeners。

Blocking Queue
阻塞队列是一个支持两个附加操作的队列。这两个附加的操作支持阻塞的插入和移除方法。FIFO

支持阻塞的插入方法:意思是当队列满时,队列会阻塞插入元素的线程,直到队列不满。
支持阻塞的移除方法:意思是在队列为空时,获取元素的线程会等待队列变为非空。

阻塞队列常用于生产者和消费者的场景,生产者是向队列里添加元素的线程,消费者是从队列里取元素的线程。阻塞队列就是生产者用来存放元素、消费者用来获取元素的容器。

ArrayBlockingQueue:由数组结构组成的有界阻塞队列。如果需要阻塞队列,队列大小固定优先选择ArrayBlockingQueue
LinkedBlockingQueue:一个由链表结构组成的可选界阻塞队列。是否有界是可选的。大小设置为Integer.MAX_VALUE时相当于无界。如果需要阻塞队列,队列大小不固定优先选择LinkedBlockingQueue
PriorityBlockingQueue:支持优先级排序的无界阻塞队列。如果需要对队列进行排序,选择PriorityBlockingQueue
SynchronousQueue:不存储元素的阻塞队列。size为0。每个插入操作必须等待另一个线程的对应移除操作 ,反之亦然。如果需要一个快速交换的队列,选择SynchronousQueue
DelayQueue:DelayQueue是一个使用优先队列(PriorityQueue)实现的无界阻塞队列。使用于以下场景:关闭空闲连接、清空缓存中的Item、任务超时处理等。如果需要对队列中的元素进行延时操作,则选择DelayQueue。

使用阻塞队列的一些建议

  • 阻塞队列,要使用put和take,而不要使用offer和poll,如果要使用offer和poll,也要使用带等待时间参数的offer和poll。
  • add、put、offer为插入,remove、poll、take为移除

在这里插入图片描述

多线程优化总结
  1. 不要改变默认线程优先级
  2. 尽量减少共享对象的状态
  3. 访问共享数据前问问自己是否需要进行同步
  4. 尽量避免一次操作拿多把锁(容易产生死锁)
  5. 尽量避免在同步块内调用其他对象的方法
  6. 尽可能减少锁持有时间
    如代码本来是
public synchronized void syncMethod(){  
	othercode1();  
	mutextMethod();  
	othercode2(); 
}

可以优化成只需要在线程安全有要求的地方加锁

public void syncMethod(){  
	othercode1();  
	synchronized(this) {
		mutextMethod();  
	}
	othercode2(); 
}
  1. 充分利用java并并发库提供的并发集合(ConcurrentHashMap、 ConcurrentSkipListSet……)以及原子变量(AtomicLong、 AtomicReference……)

  2. 减少锁的颗粒度或者范围。
    将大对象(这个对象可能会被很多线程访问),拆成小对象,大大增加并行度,降低锁竞争。降低了锁的竞争,偏向锁,轻量级锁成功率才会提高。使用典型代表:ConcurrentHashMap

  3. 减少上下文切换

  4. 不要跨线程共享变量(无状态对象永远是线程安全的)

  5. 使状态变量为不可变的(final常量)

  6. 控制状态变量可见性(volatile,Atomic*)

  7. 非要共享状态变量,在访问状态变量的时候使用同步

  8. 使用ThreadLocal保存状态变量

  9. 尽可能减少共享数据

  10. 尽可能减少共享数据的并发访问次数

  11. 任务处理异步事件化

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值