Java并发编程

Posted 叫我剑锋

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Java并发编程相关的知识,希望对你有一定的参考价值。

并发编程

1.多线程

       Java 是最先支持多线程的开发的语言之一,Java 从一开始就支持了多线程能力。由于现在的 CPU 已经多是多核处理器了,是可以同时执行多个线程的。

多线程优点

  1. 多线程技术使程序的响应速度更快 ,可以在进行其它工作的同时一直处于活动状态,程序性能得到提升。
  2. 性能提升的本质 就是榨取硬件的剩余价值(硬件利用率)。

多线程带来的问题是什么?        安全性(访问共享变量),性能(切换开销等)

并行与并发

       单核 cpu 下,线程实际是串行执行的。操作系统中有一个组件叫做任务调度器,将 cpu 的时间片,分给不同的线程使用,只是由于 cpu 在线程间(时间片很短)的切换非常快,人类感觉是同时运行的。

       总结为一句话就是:微观串行,宏观并行,一般会将这种线程轮流使用 cpu的做法称为并发,concurrent。

 多核 cpu 下,每个核(core)都可以调度运行线程,这时候线程可以是并行的。

        

        大家排队在一个咖啡机上接咖啡,交替执行,是并发;两台咖啡机上面接咖啡,是并行。               从严格意义上来说,并行的多任务是真的同时执行,而对于并发来说,这个过程只是交替的,一会执行 任务 A,一会执行任务 B,系统会不停地在两者之间切换。

       并发说的是在一个时间段内,多件事情在这个时间段内交替执行
       并行说的是多件事情在同一个时刻同事发生。

Java 内存模型(JMM)

JMM

       Java 内存模型(Java Memory Model,JMM)规范了 Java 虚拟机与计算机内存是如何协同工作的。Java 虚拟机是一个完整的计算机的一个模型,因此这个模型自然也包含一个内存模型——又称为 Java 内存模型。

JVM 主内存与工作内存

       Java 内存模型中规定了所有的变量都存储在主内存中,每条线程还有自己的工作内存,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存中的变量。

       这里的工作内存是 JMM 的一个抽象概念,也叫本地内存,其存储了该线程读/写共享变量的副本。

       就像每个处理器内核拥有私有的高速缓存,JMM 中每个线程拥有私有的本地内存。

并发编程核心问题--可见性,原子性,有序性

可见性

       一个线程对共享变量的修改,另外一个线程能够立刻看到,我们称为可见性。对于如今的多核处理器,每个 CPU 内核都有自己的缓存,而缓存仅仅对它所在的处理器内核可见,CPU 缓存与内存的数据不容易保证一致。
       为了避免处理器停顿下来等待向内存写入数据而产生的延迟,处理器使用写缓冲区来临时保存向内存写入的数据。写缓冲区合并对同一内存地址的多次写,并以批处理的方式刷新,也就是说写缓冲区不会即时将数据刷新到主内存中。
       缓存不能及时刷新导致了可见性问题。

有序性

有序性指的是程序按照代码的先后顺序执行。
为了优化性能,有时候会改变程序中语句的先后顺序。
cpu 的读等待同时指令执行是 cpu 乱序执行的根源。
读指令的同时可以同时执行不影响的其他指令。

原子性

线程切换带来的原子性问题

       原子的意思代表着——“不可分”;
       一个或多个操作在 CPU 执行的过程中不被中断的特性,我们称为原子性。
       原子性是拒绝多线程交叉操作的,不论是多核还是单核,具有原子性的量,同一时刻只能有一个线程来对它进行操作。
       CPU 能保证的原子操作是 CPU 指令级别的,而不是高级语言的操作符。线程切换导致了原子性问题。

       Java 并发程序都是基于多线程的,自然也会涉及到任务切换,任务切换的时机大多数是在时间片结束的时候。我们现在基本都使用高级语言编程,高级语言里一条语句往往需要多条 CPU 指令完成。

如 count++,至少需要三条 CPU指令

  1. 指令 1:首先,需要把变量 count 从内存加载到工作内存;
  2. 指令 2:之后,在工作内存执行 +1 操作;
  3. 指令 3:最后,将结果写入内存;

     以 count++ 为例。两个线程 A 和 B 同时执行 count++,即便 count 使用 volatile 修辞,我们预期的结果值是 2,但实际可能是 1。

并发问题总结

       缓存导致的可见性问题编译优化带来的有序性问题线程切换带来的原子性问题
       其实缓存、线程、编译优化的目的和我们写并发程序的目的是相同的,都是提高程序安全性和性能。但是技术在解决一个问题的同时,必然会带来另外一个问题,所以在采用一项技术的同时,一定要清楚它带来的问题是什么,以及如何规避。

volatile 关键字

一旦一个共享变量(类的成员变量、类的静态成员变量)被 volatile 修饰之后:

  1. 保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的。
  2. 禁止进行指令重排序。
  3. volatile 不能保证对变量操作的原子性。

volatile 底层实现原理

       使用 Memory Barrier(内存屏障)
       内存屏障是一条指令,该指令可以对编译器(软件)和处理器(硬件)的指令重排做出一定的限制,比如,一条内存屏障指令可以禁止编译器和处理器将其后面的指令移到内存屏障指令之前。 Volatile 变量编译为汇编指令会多出#Lock 前缀.

       有序性实现:主要通过对 volatile 修饰的变量的读写操作前后加上各种特定的内存屏障来禁止指令重排序来保障有序性的。

       可见性实现:主要是通过 Lock 前缀指令 + MESI 缓存一致性协议来实现的。对 volatiile 修饰的变量执行写操作时,JVM 会发送一个 Lock 前缀指令给CPU,CPU 在执行完写操作后,会立即将新值刷新到内存,同时因为 MESI 缓存一致性协议,其他各个 CPU 都会对总线嗅探,看自己本地缓存中的数据是否被别人修改,如果发现修改了,会把自己本地缓存的数据过期掉。然后这个CPU 里的线程在读取改变量时,就会从主内存里加载最新的值了,这样就保证了可见性。

如何保证原子性

       “同一时刻只有一个线程执行”我们称之为互斥。如果我们能够保证对共享变量的修改是互斥的那么就都能保证原子性了。

       锁是一种通用的技术方案,Java 语言提供的 synchronized 关键字,就是锁的一种实现。

       synchronized 是独占锁/排他锁(就是有你没我的意思),但是注意!synchronized 并不能改变 CPU 时间片切换的特点,只是当其他线程要访问这个资源时,发现锁还未释放,所以只能在外面等待。

       synchronized 一定能保证原子性,因为被 synchronized 修饰某段代码后,无论是单核 CPU 还是多核 CPU,只有一个线程能够执行该代码,所以一定能保证原子操作。

       synchronized 也能够保证可见性和有序性。

原子变量

        现在我们已经知道互斥锁可以保证原子性,也知道了如何使用synchronized 来保证原子性。但 synchronized 并不是 JAVA 中唯一能保证原子性的方案。

       如果你粗略的看一下 JUC(java.util.concurrent 包),那么你可以很显眼的发现它俩:

一个是 locks 包,一个是 atomic 包,它们可以解决原子性问题。

  • 加锁是一种阻塞式方式实现
  • 原子变量是非阻塞式方式实现

原子类

原子类原理(AtomicInteger 为例)

       原子类的原子性是通过 volatile + CAS 实现原子操作的。
       AtomicInteger 类中的 value 是有 volatile 关键字修饰的,这就保证了 value的内存可见性,这为后续的 CAS 实现提供了基础。
       低并发情况下:使用 AtomicInteger。

CAS

CAS(Compare-And-Swap) :比较并交换,该算法是硬件对于并发操作的支持。

       CAS 是乐观锁的一种实现方式,他采用的是自旋锁的思想,是一种轻量级的锁机制。
       即每次判断我的预期值和内存中的值是不是相同,如果不相同则说明该内存值已经被其他线程更新过了,因此需要拿到该最新值作为预期值,重新判断。而该线程不断的循环判断是否该内存值已经被其他线程更新过了,这就是自旋的思想。

       底层是通过 Unsafe 类中的 compareAndSwapInt 等方法实现.
CAS 包含了三个操作数:

  1. 内存值 V
  2. 预估值 A (比较时,从内存中再次读到的值)
  3. 更新值 B (更新后的值)

       当且仅当预期值 A==V,将内存值 V=B,否则什么都不做。
       这种做法的效率高于加锁,当判断不成功不能更新值时,不会阻塞,继续获得 cpu执行权,继续判断执行。

CAS 的缺点

       CAS 使用自旋锁的方式,由于该锁会不断循环判断,因此不会类似 synchronize线程阻塞导致线程切换。但是不断的自旋,会导致 CPU 的消耗,在并发量大的时候容易导致 CPU 跑满。

ABA 问题

       ABA 问题,即某个线程将内存值由 A 改为了 B,再由 B 改为了 A。当另外一个线程使用预期值去判断时,预期值与内存值相同,当前线程的 CAS 操作无法分辨当前 V 值是否发生过变化。
       解决 ABA 问题的主要方式,通过使用类添加版本号,来避免 ABA 问题。如原先的内存值为(A,1),线程将(A,1)修改为了(B,2),再由(B,2)修改为(A,3)。此时另一个线程使用预期值(A,1)与内存值(A,3)进行比较,只需要比较版本号 1 和 3,即可发现该内存中的数据被更新过了。

Java 中的锁分类

        Java 中很多锁的名词,这些并不是全指锁,有的指锁的特性,有的指锁的设计,有的指锁的状态,下面总结的内容是对每个锁的名词进行一定的解释。

乐观锁/悲观锁

       乐观锁与悲观锁不是指具体的什么类型的锁,而是指看待并发同步的角度。
       乐观锁则认为对于同一个数据的并发操作,是不会发生修改的。在更新数据的时候,会采用尝试更新,不断重新的方式更新数据。乐观的认为,不加锁的并发操作是没有事情的。

       悲观锁认为对于同一个数据的并发操作,一定是会发生修改的,哪怕没有修改,也会认为修改。因此对于同一个数据的并发操作,悲观锁采取加锁的形式。悲观的认为,不加锁的并发操作一定会出问题。

       从上面的描述我们可以看出,悲观锁适合写操作非常多的场景,乐观锁适合读操作非常多的场景,不加锁会带来大量的性能提升。
       悲观锁在 Java 中的使用,就是利用各种锁。
       乐观锁在 Java 中的使用,是无锁编程,常常采用的是 CAS 算法,典型的例子就是原子类,通过 CAS 自旋实现原子操作的更新。

可重入锁

       可重入锁又名递归锁,是指在同一个线程在外层方法获取锁的时候,在进入内层方法会自动获取锁。
        对于 Java ReentrantLock 而言, 他的名字就可以看出是一个可重入锁,其名字是 Reentrant Lock 重新进入锁。
       对于 Synchronized 而言,也是一个可重入锁。可重入锁的一个好处是可一定程 度避免死锁。

public class Demof
    synchronized void setA( throws Exception
        system.out.print(“方法A”);
        setB();
    
    synchronized void setB() throws Exception
        System.out.print(“方法B”);
    

       上面的代码就是一个可重入锁的一个特点,如果不是可重入锁的话,setB 不会被当前线程执行,造成死锁。

读写锁

读写锁特点: 读读不互斥,读写互斥,写写互斥
加读锁是防止在另外的线程在此时写入数据,防止读取脏数据

分段锁

       分段锁并非一种实际的锁,而是一种思想,用于将数据分段,并在每个分段上都会单独加锁,把锁进一步细粒度化,以提高并发效率。

自旋锁

       所谓自旋其实指的就是自己重试,当线程抢锁失败后,重试几次,要是抢到锁了就继续,要是抢不到就阻塞线程。说白了还是为了尽量不要阻塞线程。
       由此可见,自旋锁是是比较消耗 CPU 的,因为要不断的循环重试,不会释放 CPU资源。另外,加锁时间普遍较短的场景非常适合自旋锁,可以极大提高锁的效率。

共享锁/独占锁

共享锁是指该锁可被多个线程所持有,并发访问共享资源。
独占锁也叫互斥锁,是指该锁一次只能被一个线程所持有。

       对于 Java ReentrantLock,Synchronized 而言,都是独享锁。但是对于 Lock的另一个实现类 ReadWriteLock,其读锁是共享锁,其写锁是独享锁。 读锁的共享锁可保证并发读是非常高效的,读写,写读 ,写写的过程是互斥的。

公平锁/非公平锁

公平锁(Fair Lock)是指按照请求锁的顺序分配,拥有稳定获得锁的机会.
非公平锁(Nonfair Lock)是指不按照请求锁的顺序分配,不一定拥有获得锁的机会.

       对于 synchronized 而言,是一种非公平锁。ReentrantLock 默认是非公平锁,但是底层可以通过 AQS 的来实现线程调度,所以可以使其变成公平锁。

偏向锁/轻量级锁/重量级锁

锁的状态

  • 无锁状态
  • 偏向锁状态
  • 轻量级锁状态
  • 重量级锁状态

       锁的状态是通过对象监视器在对象头中的字段来表明的。四种状态会随着竞争的情况逐渐升级。
       这四种状态都不是 Java 语言中的锁,而是 JVM 为了提高锁的获取与释放效率而做的优化(使用 synchronized 时)。

偏向锁

       偏向锁是指一段同步代码一直被一个线程所访问,那么该线程会自动获取锁。降低获取锁的代价。

轻量级

        轻量级锁是指当锁是偏向锁的时候,此时又有一个线程访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,提高性能。

重量级锁

       重量级锁是指当锁为轻量级锁的时候,另一个线程虽然是自旋,但自旋不会一直持续下去,当自旋一定次数的时候,还没有获取到锁,就会进入阻塞,该锁膨胀为重量级锁。在高并发情况下,出现大量线程自旋获得锁,对 cpu 销毁较大, 升级为重量级锁后,获取不到锁的线程将阻塞,等待操作系统的调度.

  • 轻量级锁 自旋
  • 重量级锁 需要操作系统调度

对象结构

       在 Hotspot 虚拟机中,对象在内存中的布局分为三块区域:对象头、实例数据和对齐填充;Java 对象头是实现 synchronized 的锁对象的基础,一般而言,synchronized 使用的锁对象是存储在 Java 对象头里。它是轻量级锁和偏向锁的关键。

        对象头中有一块区域称为 Mark Word,用于存储对象自身的运行时数据,如哈希码(HashCode)、GC 分代年龄、锁状态标志、线程持有的锁、偏向线程 ID 等等。

       32 位操作系统 Mark Word 为 32bit 为,64 位操作系统 Mark Word 为 64bit. 下面就是对象头的一些信息:

 

Java 代码打印对象头信息

添加依赖

<dependency>
    <groupId>org.openjdk.jol</groupId>
    <artifactId>jol-core</artifactId>
    <version>0.10</version>
</dependency>
打印出相关的对象头信息
 System.out.println(ClassLayout.parseInstance(myClass).toPrintable());
尝试加锁改变对象头信息
synchronized (myClass)
    System.out.println(ClassLayout.parseInstance(myClass).toPrintable());

synchronized 锁实现

       Java 提供的一种原子性性内置锁,Java 每个对象都可以把它当做是监视器锁,线程代码执行在进入 synchronized 代码块时候会自动获取内部锁,这个时候其他线程访问时候会被阻塞,直到进入 synchronized 中的代码执行完毕或者抛出异常或者调用了 wait 方法,都会释放锁资源。在进入 synchronized 会从主内存把变量读取到自己工作内存,在退出的时候会把工作内存的值写入到主内存,保证了原子性。

       synchronized 基于进入和退出监视器对象来实现方法同步和代码块同步。同步方法使用 ACC_SYNCHRONIZED 标记是否为同步方法。当方法调用时,调用指令将会检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,该标记表明线程进入该方法时,需要 monitorenter,退出该方法时需要 monitorexit。

       使用 javap -verbose SynchronizedDemo 反编译后得到:

        代码块的同步是利用 monitorenter 和 monitorexit 这两个字节码指令。在虚拟机执行到 monitorenter 指令时,首先要尝试获取对象的锁。

       当前线程拥有了这个对象的锁,把锁的计数器+1;当执行 monitorexit 指令时将模计数器-1;当计数器为 0 时,锁就被释放了。

 Java 中 synchronized 通过在对象头设置标记,达到了获取锁和释放锁的目的。

AQS

        AQS 的 全 称 为 ( AbstractQueuedSynchronizer ) , 这 个 类 在java.util.concurrent.locks 包下面。
       AQS 是一个用来构建锁和同步器的框架,使用 AQS 能简单且高效地构造出同步 器 , 是 JUC 中核心的组件 , 比如我们提到的ReentrantLock,CountDownLatch 等等都是基于 AQS 来实现。
       只要搞懂了 AQS,那么 JUC 中绝大部分的 api 都能掌握。

AQS 实现原理

       在内部有一个 state 变量表示锁是否使用, 初始化 0,在多线程条件下,线程要执行临界区的代码,必须首先获取 state,某个线程获取成功之后, state加 1。
       其他线程再获取的话由于共享资源已被占用,所以会到 FIFO 队列去等待,等占有 state 的线程执行完临界区的代码释放资源( state 减 1)后,会唤醒FIFO 中的下一个等待线程(head 中的下一个结点)去获取 state。
       state 由于是多线程共享变量,所以必须定义成 volatile,以保证 state 的可见性, 同时虽然 volatile 能保证可见性,但不能保证原子性,所以 AQS 提供了对 state 的原子操作方法,保证了线程安全。
       另外 AQS 中实现的 FIFO 队列其实是双向链表实现的,head 结点代表当前占用的线程,其他节点由于暂时获取不到锁所以依次排队等待锁释放。
队列由 Node 对象组成,Node 是 AQS 中的内部类。

 

AbstractQueuedSynchronizer 成员
private transient volatile Node head;
private transient volatile Node tail;
/*使用变量 state 表示锁状态,0-锁未被使用,大于 0 锁已被使用
共享变量 state,使用 volatile 修饰保证线程可见性
*/
private volatile int state;
状态信息通过 getState , setState , compareAndSetState 进行操作.
protected final int getState()  //获得锁状态
    return state;

protected final void setState(int newState) //设置锁状态
    state = newState;

//使用 CAS 机制设置状态
protected final boolean compareAndSetState(int expect, int update) 
    return unsafe.compareAndSwapInt(this, stateOffset, expect, update);
获取锁的方式有两种
  1. 尝试获取锁(修改标记位置),立即返回  -->  tryAcquire
  2. 获取锁(修改标记位置),愿意进入队列等待,直到获取  --->  acquire

 AQS 操作重点方法

acquire: 表示一定能获取锁

public final void acquire(int arg) 
    if (!tryAcquire(arg) && acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
    selfInterrupt();

       tryAcquire: 尝试获取锁,如果 tryAcquire 获取锁成功,那么 !tryAcquire(arg) 为 false,说明已经获取锁,根本不用参与排队,也就是不用再执行后续判断条件。根据判断条件的短路规则,直接返回。

       addWaiter: 尝试获取锁失败后,将当前线程封装到一个 Node 对象中,添加到队尾,并返回 Node 节点.

       acquireQueued: 将线程添加到队列后,以自旋的方式去获取锁release 释放锁

       tryRelease: 释放锁,将 state 值进行修改为 0

       unparkSuccessor: 唤醒节点的后继者(如果存在)

AQS 的锁模式分为:独占和共享

       独占锁:每次只能有一个线程持有锁,比如 ReentrantLock 就是以独占方式实现的互斥锁。

       共享锁:允许多个线程同时获取锁,并发访问共享资源 , 比如 ReentrantReadWriteLock。

ReentrantLock 锁实现

        ReentrantLock 是 java.util.concurrent.locks 包 下 的 类 , 实 现 Lock 接口,Lock 的意义在于提供区别于 synchronized 的另一种具有更多广泛操作的同步方式,它能支持更多灵活的结构.

public class ReentrantLock implements Lock, java.io.Serializable 

       ReentrantLock 基于 AQS,在并发编程中它可以实现公平锁和非公平锁来对共享资源进行同步,同时和 synchronized 一样,ReentrantLock 支持可重入, 除此之外,ReentrantLock 在调度上更灵活,支持更多丰富的功能。

       ReentrantLock 总共有三个内部类,并且三个内部类是紧密相关的.

        ReentrantLock 类内部总共存在 Sync、NonfairSync、FairSync 三个类,NonfairSync 与 FairSync 类继承自Sync 类 , Sync 类继承自 AbstractQueuedSynchronizer 抽象类。

Sync 类继承 AbstractQueuedSynchronizer
abstract static class Sync extends AbstractQueuedSynchronizer

构造方法
//默认
public ReentrantLock() 
    sync = new NonfairSync();


//传入true or false
public ReentrantLock(boolean fair) 
    sync = fair ? new FairSync() : new NonfairSync();
       NonfairSync 类继承了 Sync 类,表示采用非公平策略获取锁,其实现了 Sync 类中抽象的 lock 方法.

 

static final class NonfairSync extends Sync 
//加锁
final void lock() 
    //若通过 CAS 设置变量 state 成功,就是获取锁成功,则将当前线程设置为独占线程。
    //若通过 CAS 设置变量 state 失败,就是获取锁失败,则进入 acquire 方法进行后续处理。
    if (compareAndSetState(0, 1))
        setExclusiveOwnerThread(Thread.currentThread());
    else
        acquire(1);
    
    //尝试获取锁,无论是否获得都立即返回
    protected final boolean tryAcquire(int acquires) 
        return nonfairTryAcquire(acquires);
    

       FairSync 类也继承了 Sync 类,表示采用公平策略获取锁,其实现了 Sync 类中的抽象 lock 方法.

static final class FairSync extends Sync 
    final void lock() 
        // 以独占模式获取对象,忽略中断
        acquire(1);//底层实现交由 AbstractQueuedSynchronizer
    

JUC 常用类

Java 5.0 在 java.utilconcurrent 包中提供了多种并发容器类来改进同步容器的性能。

ConcurrentHashMap

       ConcurrentHashMap 同步容器类是 Java 5 增加的一个线程安全的哈希表。对与多线程的操作,介于 HashMap 与 Hashtable 之间。内部采用“锁分段”机制(jdk8 弃用了分段锁,使用 cas+synchronized)替代 Hashtable 的独占锁。进而提高性能。

放弃分段锁的原因:

  1. 加入多个分段锁浪费内存空间。
  2. 生产环境中, map 在放入时竞争同一个锁的概率非常小,分段锁反而会造成更新等操作的长时间等待。

       jdk8 放弃了分段锁而是用了 Node 锁,减低锁的粒度,提高性能,并使用 CAS操作来确保 Node 的一些操作的原子性,取代了锁。

       put 时首先通过 hash 找到对应链表过后,查看是否是第一个 Node,如果是,直接用 cas 原则插入,无需加锁。然后, 如果不是链表第一个 Node, 则直接用链表第一个 Node 加锁,这里加 的锁是 synchronized。

        ConcurrentHashMap 不支持存储 null 键和 null 值.

        为了消除歧义ConcurrentHashMap 不能 put null 是因为 无法分辨是 key 没找到的 null 还是有 key 值为 null,这在多线程里面是模糊不清的,所以压根就不让 put null。

        ConcurrentHashmap 和 Hashtable 都是支持并发的,这样会有一个问题,当你通过 get(k)获取对应的 value 时,如果获取到的是 null 时,你无法判断,它是 put(k,v)的时候 value 为 null,还是这个 key 从来没有做过映射。

CopyOnWriteArrayList

        ArraayList 是线程不安全的,在高并发情况下可能会出现问题, Vector 是线程安全的。

        但是在很多应用场景中,读操作可能会远远大于写操作。由于读操作根本不会修改原有的数据,因此如果每次读取都进行加锁操作,其实是一种资源浪费。我们应该允许多个线程同时访问 List 的内部数据,毕竟读操作是线程安全的。

        JDK 中提供了 CopyOnWriteArrayList 类,将读取的性能发挥到极致,取是完全不用加锁的,并且更厉害的是:写入也不会阻塞读取操作,只有写入和写入之间需要进行同步等待,读操作的性能得到大幅度提升。

        CopyOnWriteArrayList 类的所有可变操作(add,set 等等)都是通过创建底层数组的新副本来实现的。当 List 需要被修改的时候,并不直接修改原有数组对象,而是对原有数据进行一次拷贝,将修改的内容写入副本中。写完之后,再将修改完的副本替换成原来的数据,这样就可以保证写操作不会影响读操作了。

CopyOnWriteArraySet

CopyOnWriteArraySet 的实现基于 CopyOnWriteArrayList,不能存储重复数据。

辅助类 CountDownLatch

        CountDownLatch 允许一个线程等待其他线程各自执行完毕后再执行。底层实现实现是通 AQS 来完成的.创建 CountDownLatch 对象时指定一个初始值是线程的数量。每当一个线程执行完毕后,AQS 内部的 state 就-1,当 state 的值为0 时,表示所有线程都执行完毕,然后在闭锁上等待的线程就可以恢复工作了。

线程池

概述

        以前我们需要使用线程的时候就去创建一个线程,这样实现起来非常简便,但是就会有一个问题:如果并发的线程数量很多,并且每个线程都是执行一个时间很短的任务就结束了,这样频繁创建线程就会大大降低系统的效率,因为频繁创建线程和销毁线程需要时间。

        在 Java 中可以通过线程池来解决此问题。线程池里的每一个线程代码结束后,并不会死亡,而是再次回到线程池中成为空闲状态,等待下一个对象来使用。在 JDK5 之前,我们必须手动实现自己的线程池,从 JDK5 开始,Java 内置支持线程池。

        在 JDK5 版本中增加了内置线程池实现 ThreadPoolExecutor,同时提供了Executors 来创建不同类型的线程池。Executors 中提供了以下常见的线程池创建方法:                                                    newSingleThreadExecutor:一个单线程的线程池。如果因异常结束,会再创建一个新的,保证按照提交顺序执行。
        newFixedThreadPool:创建固定大小的线程池。根据提交的任务逐个增加线程,直到最大值保持不变。如果因异常结束,会新创建一个线程补充。
        newCachedThreadPool:创建一个可缓存的线程池。会根据任务自动新增或回收线程。

        虽然在 JDK 中提供 Executors 类来支持以上类型的线程池创建,但通常情况下不建议开发人员直接使用(见《阿里巴巴 java 开发规范》并发处理)。

线程池优点:

  • 重复利用线程,降低线程创建和销毁带来的资源消耗
  • 统一管理线程,线程的创建和销毁都由线程池进行管理
  • 提高响应速度,线程创建已经完成,任务来到可直接处理,省去了创建时间

ThreadPoolExecutor 类

        Java.uitl.concurrent.ThreadPoolExecutor 类是线程池中最核心的一个类,因此如果要透彻地了解 Java 中的线程池,必须先了解这个类。ThreadPoolExecutor 继承了 AbstractExecutorS

        ThreadPoolExecutor 继承了 AbstractExecutorService 类,并提供了四个构造器,事实上,通过观察每个构造器的源码具体实现,发现前面三个构造器都是调用的第四个构造器进行的初始化工作。

构造器中各个参数的含义

        corePoolSize:核心池的大小,这个参数跟后面讲述的线程池的实现原理有非常大的关系。在创建了线程池后,默认情况下,在创建了线程池后,线程池中的线程数为 0,当有任务来之后,就会创建一个线程去执行任务,当线程池中的线程数目达到 corePoolSize 后,就会把到达的任务放到缓存队列当中;除非调用了prestartAllCoreThreads()或者 prestartCoreThread()方法,从这 2 个方法的名字就可以看出,是预创建线程的意思,即在没有任务到来之前就创建corePoolSize 个线程或者一个线程。

        maximumPoolSize:线程池最大线程数,这个参数也是一个非常重要的参数,它表示在线程池中最多能创建多少个线程;

        keepAliveTime:表示线程没有任务执行时最多保持多久时间会终止。默认情况下,只有当线程池中的线程数大于 corePoolSize 时,keepAliveTime 才会起作用,直到线程池中的线程数不大于 corePoolSize,即当线程池中的线程数大于 corePoolSize 时,如果一个线程空闲的时间达到 keepAliveTime,则会终止,直到线程池中的线程数不超过 corePoolSize。

        unit:参数 keepAliveTime 的时间单位,有 7 种取值,在 TimeUnit 类中有 7

种静态属性:

        workQueue:一个阻塞队列,用来存储等待执行的任务,这个参数的选择也很重要,会对线程池的运行过程产生重大影响

        threadFactory:线程工厂,主要用来创建线程;

        handler:表示当拒绝处理任务时的策略

 

线程池的执行

        创建完成 ThreadPoolExecutor 之后,当向线程池提交任务时,通常使用 execute 方法。 execute 方法的执行流程图如下:

  1. 如果线程池中存活的核心线程数小于线程数 corePoolSize 时,线程池会创建一个核心线程去处理提交的任务。
  2. 如果线程池核心线程数已满,即线程数已经等于 corePoolSize,一个新提交的任务,会被放进任务队列 workQueue 排队等待执行。
  3. 当线程池里面存活的线程数已经等于 corePoolSize 了,并且任务队列workQueue 也满,判断线程数是否达到 maximumPoolSize,即最大线程数是否已满,如果没到达,创建一个非核心线程执行提交的任务。
  4. 如果当前的线程数达到了 maximumPoolSize,还有新的任务过来的话,直接采用拒绝策略处理。

 

线程池中的队列

线程池有以下工作队列:

        ArrayBlockingQueue:是一个用数组实现的有界阻塞队列,创建时必须设置长度,,按 FIFO 排序量。
        LinkedBlockingQueue:基于链表结构的阻塞队列,按 FIFO 排序任务,容量可以选择进行设置,不设置是一个最大长度为 Integer.MAX_VALUE;

线程池的拒绝策略

       构造方法的中最后的参数 RejectedExecutionHandler 用于指定线程池的拒绝策略。当请求任务不断的过来,而系统此时又处理不过来的时候,我们就需要采取对应的策略是拒绝服务。

默认有四种类型:
       AbortPolicy 策略:该策略会直接抛出异常,阻止系统正常工作。
       CallerRunsPolicy 策略:只要线程池未关闭,该策略在调用者线程中运行当前的任务(如果任务被拒绝了,则由提交任务的线程(例如:main)
直接执行此任务)。
       DiscardOleddestPolicy 策略:该策略将丢弃最老的一个请求,也就是即将被执行的任务,并尝试再次提交当前任务。DiscardPolicy 策略:该策略丢弃无法处理的任务,不予任何处理。

execute 与 submit 的区别
       执行任务除了可以使用 execute 方法还可以使用 submit 方法。它们的主要区别是:execute 适用于不需要关注返回值的场景,submit 方法适用于需要关注返回值的场景。

关闭线程池

       关闭线程池可以调用 shutdownNow 和 shutdown 两个方法来实现。
       shutdownNow:对正在执行的任务全部发出 interrupt(),停止执行,对还未开始执行的任务全部取消,并且返回还没开始的任务列表。
       shutdown:当我们调用 shutdown 后,线程池将不再接受新的任务,但也不会去强制终止已经提交或者正在执行中的任务。

对象引用

概述

       我们希望能描述这样一类对象:当内存空间还足够时,则能保留在内存中;如果内存空间在进行垃圾收集后还是很紧张,则可以抛弃这些对象。
       既偏门又非常高频的面试题:强引用、软引用、弱引用、虚引用有什么区别?具体使用场景是什么?
       在 JDK1.2 版之后,Java 对引用的概念进行了扩充,将引用分为:

  • 强引用
  • 软引用(SoftReference)
  • 弱引用(WeakReference)
  • 虚引用(PhantomReference)
       这4种引用强度依次逐渐减弱。除强引用外,其他3种引用均可以在java.lang.ref包中找到它们的身影。如下图,显示了这 3 种引用类型对应的类.

       Reference 子类中只有终结器引用是包内可见的,其他 3 种引用类型均为 public,可以在应用程序中直接使用.

        强引用:最传统的“引用”的定义,是指在程序代码之中普遍存在的引用赋值,即类似“Object obj=new Object()”这种引用关系。无论任何情况下,只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象。宁可报 OOM,也不会 GC 强引用。

       软引用(SoftReference):在系统将要发生内存溢出之前,将会把这些对象列入回收范围之中进行第二次回收。如果这次回收后还没有足够的内存,才会抛出内存溢出异常。

       弱引用(WeakReference):被弱引用关联的对象只能生存到下一次垃圾收集之前。当垃圾收集器工作时,无论内存空间是否足够,都会回收掉被弱引用关联的对象。

       虚引用(PhantomReference):一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来获得一个对象的实例。为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知。

强引用

       在 Java 程序中,最常见的引用类型是强引用,也就是我们最常见的普通对象引用,也是默认的引用类型。
       Object obj = new Object( );
       当在 Java 语言中使用 new 操作符创建一个新的对象,并将其赋值给一个变量的时候,这个变量就成为指向该对象的一个强引用。
       只要强引用的对象是可触及的,垃圾收集器就永远不会回收掉被引用的对象。
       只要强引用的对象是可达的,jvm 宁可报 OOM,也不会回收强引用。
       对于一个普通的对象,如果没有其他的引用关系,只要超过了引用的作用域或者显式地将相应(强)引用赋值为 null,就是可以当做垃圾被收集了,当然具体回收时机还是要看垃圾收集策略。
       Object obj = new Object( );
       Obj=null;
       软引用、弱引用和虚引用的对象是软可触及、弱可触及和虚可触及的,在一定条件下,都是可以被回收的。所以,强引用是造成 Java 内存泄漏的主要原因之一。

软引用(Soft Reference):内存不足即回收

       软引用是用来描述一些还有用,但非必需的对象。只被软引用关联着的对象,在系统将要发生内存溢出异常前,会把这些对象列进回收范围之中进行第二次回收,如果这次回收还没有足够的内存,才会抛出内存溢出异常。注意,这里的第一次回收是不可达的对象。
       软引用通常用来实现内存敏感的缓存。比如:高速缓存就有用到软引用。如果还有空闲内存,就可以暂时保留缓存,当内存不足时清理掉,这样就保证了使用缓存的同时,不会耗尽内存。
       垃圾回收器在某个时刻决定回收软可达的对象的时候,会清理软引用,并可选地把引用存放到一个引用队列(Reference Queue)。 类似弱引用,只不过 Java 虚拟机会尽量让软引用的存活时间长一些,迫不得已才清理。

Object obj = new Object();// 声明强引用
SoftReference<Object> sf = new SoftReference<>(obj);
obj = null; //销毁强引用

弱引用(Weak Reference)发现即回收

       弱引用也是用来描述那些非必需对象,只被弱引用关联的对象只能生存到下一次垃圾收集发生为止。在系统 GC 时,只要发现弱引用,不管系统堆空间使用是否充足,都会回收掉只被弱引用关联的对象。

//声明强引用
Object obj = new Object();
WeakReference<Object> sf = new WeakReference<>(obj);
//销毁强引用
obj = null;
       法检查是否回收软引用对象,而对于弱引用对象,GC 总是进行回收。弱引用对象更容易、更快被 GC 回收。

虚引用(Phantom Reference):对象回收跟踪

       也称为“幽灵引用”或者“幻影引用”,是所有引用类型中最弱的一个一个对象是否有虚引用的存在,完全不会决定对象的生命周期。
       如果一个对象仅持有虚引用,那么它和没有引用几乎是一样的,随时都可能被垃圾回收器回收。虚引用必须和引用队列一起使用。虚引用在创建时必须提供一个引用队列作为参数。

// 声明强引用
Object obj = new Object();
// 声明引用队列
ReferenceQueue phantomQueue = new ReferenceQueue();
// 声明虚引用(还需要传入引用队列)
PhantomReference<Object> sf = new PhantomReference<>(obj, phantomQueue);
obj = null;

ThreadLocal

ThreadLocal 是什么

       从名字我们就可以看到 ThreadLocal 叫做线程变量,意思是 ThreadLocal 中填充的变量属于当前线程,该变量对其他线程而言是隔离的。ThreadLocal 为变量在每个线程中都创建了一个副本,那么每个线程可以访问自己内部的副本变量。

static ThreadLocal<Integer> threadLocal = new ThreadLocal<Integer>()
    @Override
    protected Integer initialValue() 
    return 1;
    
;

ThreadLocal 原理分析

       首 先 ThreadLocal 是 一 个 泛 型 类 , 保 证 可 以 接 受 任 何 类 型 的 对 象 。 ThreadLocal 内 部 维 护 了 一 个 Map , ThreadLocal 实 现 了 一 个 叫做 ThreadLocalMap 的静态内部类。而我们使用的 get()、set() 方法其实都是由这个 ThreadLocalMap 类对应的 get()、set() 方法实现的。

set 方法

Java 并发编程:核心理论

Java并发编程系列:

  并发编程是Java程序员最重要的技能之一,也是最难掌握的一种技能。它要求编程者对计算机最底层的运作原理有深刻的理解,同时要求编程者逻辑清晰、思维缜密,这样才能写出高效、安全、可靠的多线程并发程序。本系列会从线程间协调的方式(wait、notify、notifyAll)、Synchronized及Volatile的本质入手,详细解释JDK为我们提供的每种并发工具和底层实现机制。在此基础上,我们会进一步分析java.util.concurrent包的工具类,包括其使用方式、实现源码及其背后的原理。本文是该系列的第一篇文章,是这系列中最核心的理论部分,之后的文章都会以此为基础来分析和解释。

一、共享性

  数据共享性是线程安全的主要原因之一。如果所有的数据只是在线程内有效,那就不存在线程安全性问题,这也是我们在编程的时候经常不需要考虑线程安全的主要原因之一。但是,在多线程编程中,数据共享是不可避免的。最典型的场景是数据库中的数据,为了保证数据的一致性,我们通常需要共享同一个数据库中数据,即使是在主从的情况下,访问的也同一份数据,主从只是为了访问的效率和数据安全,而对同一份数据做的副本。我们现在,通过一个简单的示例来演示多线程下共享数据导致的问题:

代码段一:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
package com.paddx.test.concurrent;
 
public class ShareData {
    public static int count = 0;
 
    public static void main(String[] args) {
        final ShareData data = new ShareData();
        for (int i = 0; i < 10; i++) {
            new Thread(new Runnable() {
                @Override
                public void run() {
                    try {
                        //进入的时候暂停1毫秒,增加并发问题出现的几率
                        Thread.sleep(1);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    for (int j = 0; j < 100; j++) {
                        data.addCount();
                    }
                    System.out.print(count + " ");
                }
            }).start();
 
        }
        try {
            //主程序暂停3秒,以保证上面的程序执行完成
            Thread.sleep(3000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("count=" + count);
    }
 
    public void addCount() {
        count++;
    }
}

  上述代码的目的是对count进行加一操作,执行1000次,不过这里是通过10个线程来实现的,每个线程执行100次,正常情况下,应该输出1000。不过,如果你运行上面的程序,你会发现结果却不是这样。下面是某次的执行结果(每次运行的结果不一定相同,有时候也可能获取到正确的结果):

 

可以看出,对共享变量操作,在多线程环境下很容易出现各种意想不到的的结果。

二、互斥性

  资源互斥是指同时只允许一个访问者对其进行访问,具有唯一性和排它性。我们通常允许多个线程同时对数据进行读操作,但同一时间内只允许一个线程对数据进行写操作。所以我们通常将锁分为共享锁和排它锁,也叫做读锁和写锁。如果资源不具有互斥性,即使是共享资源,我们也不需要担心线程安全。例如,对于不可变的数据共享,所有线程都只能对其进行读操作,所以不用考虑线程安全问题。但是对共享数据的写操作,一般就需要保证互斥性,上述例子中就是因为没有保证互斥性才导致数据的修改产生问题。Java 中提供多种机制来保证互斥性,最简单的方式是使用Synchronized。现在我们在上面程序中加上Synchronized再执行:

代码段二:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
package com.paddx.test.concurrent;
 
public class ShareData {
    public static int count = 0;
 
    public static void main(String[] args) {
        final ShareData data = new ShareData();
        for (int i = 0; i < 10; i++) {
            new Thread(new Runnable() {
                @Override
                public void run() {
                    try {
                        //进入的时候暂停1毫秒,增加并发问题出现的几率
                        Thread.sleep(1);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    for (int j = 0; j < 100; j++) {
                        data.addCount();
                    }
                    System.out.print(count + " ");
                }
            }).start();
 
        }
        try {
            //主程序暂停3秒,以保证上面的程序执行完成
            Thread.sleep(3000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("count=" + count);
    }
 
    /**
     * 增加 synchronized 关键字
     */
    public synchronized void addCount() {
        count++;
    }
}

  现在再执行上述代码,会发现无论执行多少次,返回的最终结果都是1000。

三、原子性

  原子性就是指对数据的操作是一个独立的、不可分割的整体。换句话说,就是一次操作,是一个连续不可中断的过程,数据不会执行的一半的时候被其他线程所修改。保证原子性的最简单方式是操作系统指令,就是说如果一次操作对应一条操作系统指令,这样肯定可以能保证原子性。但是很多操作不能通过一条指令就完成。例如,对long类型的运算,很多系统就需要分成多条指令分别对高位和低位进行操作才能完成。还比如,我们经常使用的整数 i++ 的操作,其实需要分成三个步骤:(1)读取整数 i 的值;(2)对 i 进行加一操作;(3)将结果写回内存。这个过程在多线程下就可能出现如下现象:

这也是代码段一执行的结果为什么不正确的原因。对于这种组合操作,要保证原子性,最常见的方式是加锁,如Java中的Synchronized或Lock都可以实现,代码段二就是通过Synchronized实现的。除了锁以外,还有一种方式就是CAS(Compare And Swap),即修改数据之前先比较与之前读取到的值是否一致,如果一致,则进行修改,如果不一致则重新执行,这也是乐观锁的实现原理。不过CAS在某些场景下不一定有效,比如另一线程先修改了某个值,然后再改回原来值,这种情况下,CAS是无法判断的。

四、可见性

   要理解可见性,需要先对JVM的内存模型有一定的了解,JVM的内存模型与操作系统类似,如图所示:

  

从这个图中我们可以看出,每个线程都有一个自己的工作内存(相当于CPU高级缓冲区,这么做的目的还是在于进一步缩小存储系统与CPU之间速度的差异,提高性能),对于共享变量,线程每次读取的是工作内存中共享变量的副本,写入的时候也直接修改工作内存中副本的值,然后在某个时间点上再将工作内存与主内存中的值进行同步。这样导致的问题是,如果线程1对某个变量进行了修改,线程2却有可能看不到线程1对共享变量所做的修改。通过下面这段程序我们可以演示一下不可见的问题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
package com.paddx.test.concurrent;
 
public class VisibilityTest {
    private static boolean ready;
    private static int number;
 
    private static class ReaderThread extends Thread {
        public void run() {
            try {
                Thread.sleep(10);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            if (!ready) {
                System.out.println(ready);
            }
            System.out.println(number);
        }
    }
 
    private static class WriterThread extends Thread {
        public void run() {
            try {
                Thread.sleep(10);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            number = 100;
            ready = true;
        }
    }
 
    public static void main(String[] args) {
        new WriterThread().start();
        new ReaderThread().start();
    }
}

从直观上理解,这段程序应该只会输出100,ready的值是不会打印出来的。实际上,如果多次执行上面代码的话,可能会出现多种不同的结果,下面是我运行出来的某两次的结果:

当然,这个结果也只能说是有可能是可见性造成的,当写线程(WriterThread)设置ready=true后,读线程(ReaderThread)看不到修改后的结果,所以会打印false,对于第二个结果,也就是执行if (!ready)时还没有读取到写线程的结果,但执行System.out.println(ready)时读取到了写线程执行的结果。不过,这个结果也有可能是线程的交替执行所造成的。Java 中可通过Synchronized或Volatile来保证可见性,具体细节会在后续的文章中分析。

五、有序性

  为了提高性能,编译器和处理器可能会对指令做重排序。重排序可以分为三种:

  (1)编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。

  (2)指令级并行的重排序。现代处理器采用了指令级并行技术(Instruction-Level Parallelism, ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
  (3)内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。

  我们可以直接参考一下JSR 133 中对重排序问题的描述:

  

        (1)                    (2)

先看上图中的(1)源码部分,从源码来看,要么指令 1 先执行要么指令 3先执行。如果指令 1 先执行,r2不应该能看到指令 4 中写入的值。如果指令 3 先执行,r1不应该能看到指令 2 写的值。但是运行结果却可能出现r2==2,r1==1的情况,这就是“重排序”导致的结果。上图(2)即是一种可能出现的合法的编译结果,编译后,指令1和指令2的顺序可能就互换了。因此,才会出现r2==2,r1==1的结果。Java 中也可通过Synchronized或Volatile来保证顺序性。

六 总结

  本文对Java 并发编程中的理论基础进行了讲解,有些东西在后续的分析中还会做更详细的讨论,如可见性、顺序性等。后续的文章都会以本章内容作为理论基础来讨论。如果大家能够很好的理解上述内容,相信无论是去理解其他并发编程的文章还是在平时的并发编程的工作中,都能够对大家有很好的帮助。

以上是关于Java并发编程的主要内容,如果未能解决你的问题,请参考以下文章

JAVA多线程之并发编程三大核心问题

学java用哪本书好?

Java并发编程

Day829.Java线程的生命周期 -Java 并发编程实战

Day829.Java线程的生命周期 -Java 并发编程实战

经验分享新手学Java编程语言怎么入门?