Java并发编程的艺术

1、并发编程的挑战

1、上下文切换

CPU通过给每个线程分配CPU时间片来实现多线程机制。时间片是CPU分配给各个线程的时间,这个时间非常短,一般是几十毫秒。 CPU通过时间片分配算法来循环执行任务,当前任务执行一个时间片后会切换到下一个任务,但是 ,在切换前会保存上一个任务的状态,以便下次切换回这个任务时,可以再加载这个任务的状态。所以任务从保存到再加载的过程就是一次上下文切换。

1.1、如何减少上下文切换

无锁并发编程
多任务处理数据时,可以用一些办法避免使用锁,如将数据ID按照HASH算法取模分段,不同的线程处理不同段的数据。
CAS算法
使用最少线程
任务很少时,避免创建不需要的线程

2、死锁

2.1 避免死锁的方法

避免一个线程同时获取多个锁
避免一个线程在锁内同时占用多个资源,尽量保证一个锁只占用一个资源
尝试使用定时锁来替代内部锁机制
对于数据库锁,加锁和解锁必须在一个数据库连接中,否则会出现解锁失败的现象

3、资源限制的挑战

资源限制是指在进行并发编程时,程序的执行速度受制于计算机硬件资源或软件资源。

3.1 如何解决资源限制的问题

考虑使用集群并行执行程序
使用资源池将资源复用,比如数据库连接池。

2、Java并发编程的底层实现原理

1、volatile的应用

在并发编程中,synchronized和volatile都扮演着重要的角色,volatile是轻量级的synchronized,他在并发编程中保证了共享变量的 “可见性”。可见性的意思是当一个线程修改一个共享变量时,另一个线程能读到这个修改的值。volatile不会引起线程上下文的切换和线程调度。

2、synchronized的原理与应用

Java中每个对象都可以作为锁,具体表现为以下3种形式:

  • 对于普通同步方法,锁是当前实例对象
  • 对于静态同步方法,锁是当前类Class对象
  • 对于同步方法快,锁是synchronized括号里配置的对象

synchronized原理(重要):

JVM基于进入和退出的Monitor对象来实现方法同步和代码块同步,但两者实现细节不一样。代码块同步是使用monitorenter和monitorexit指令实现的,而方法同步使用另外一种方式实现的。

monitorenter指令在编译后插入到同步代码块的开始位置,而monitorexit是插入到方法结束和异常处,JVM要保证每个monitorenter和monitorexit一一对应。当一个monitor被持有后,他将处于锁定状态,线程执行到monitorenter指令时,将会尝试获取对象所对应的monitor的所有权,即尝试获取对象的锁。

3、Java对象头

synchronized用的锁是存在Java对象头里的,对象头组成如下所示:

长度 内容 说明
32/64bit Mark Word 存储对象的hashcode或锁信息等
32/64bit Class Metadata Adress 存储到对象类型数据的指针
32/64bit Array length 数组的长度(如果当前对象是数组)

4、锁的升级与对比

Java SE 1.6为了减少获得锁和释放锁带来的性能消耗,引入了“偏向锁”和“轻量级锁”,锁一共有4中状态:级别从低到高依次是:无锁、偏向锁、轻量级锁和重量级锁。这几个状态会随着竞争情况逐渐升级。锁可以升级但不能降级。

4.1偏向锁

当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储偏向锁的ID,以后该线程在进入和退出同步块时不需要进行CAS操作来加锁和解锁,只需要简单地测试一下对象头里的Mark Word里是否存储着当前线程的偏向锁。如果测试成功,表示线程已经获得了锁,如果测试失败,则需要再测试一下Mark Word中偏向锁的标识是否被置为1(表示当前是偏向锁):如果没有设置,则使用CAS竞争,如果设置了,则尝试使用CAS将对象头的偏向锁指向当前线程。

偏向锁的撤销:偏向锁使用了一种等到竞争出现才释放锁的机制,所以当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。

Java并发编程的艺术_第1张图片

偏向锁的关闭:偏向锁时默认启用的,但是在应用程序启动几秒钟之后才激活,可以使用JVM参数来关闭延迟:-XX:BiasedLockingStartupDelay=0。如果确定应用程序里所有的锁通常处于竞争状态,可以通过JVM参数关闭偏向锁:-XX:UseBiasedLocking=false。

4.2轻量级锁

轻量级锁加锁:线程在执行同步块之前,JVM会先在当前线程的栈帧中创建用于存储锁记录的空间,并将对象头中的Mark Word复制到自己的锁记录中。然后线程尝试使用CAS将对象头中的Mark Word替换为指向锁记录的指针。如果成功,当前线程获得锁,如果失败,其他线程竞争锁,当前线程便尝试使用自旋来获取锁。

**轻量级做解锁:轻量级锁解锁时,会使用原子的CAS操作将Mark Word替换回到对象头,如果成功,则表示没有竞争发生。如果失败,表示当前锁存在竞争,锁就会膨胀成重量级锁。
Java并发编程的艺术_第2张图片

4.3三种锁的对比

优点 缺点 适用场景
偏向锁 加锁和解锁不需要额外的消耗,和执行非同步方法相比仅存在纳秒级的差别 如果线程间存在竞争,会带来额外的锁撤销的消耗 适用于只有一个线程访问的同步块场景
轻量级锁 竞争的线程不会阻塞,提高了线程的响应速度 如果线程始终得不到锁竞争的线程,使用自旋会消耗CPU 追求响应时间,同步块执行速度非常快
重量级锁 线程竞争不使用自旋,不会消耗CPU 线程阻塞,响应时间缓慢 追求吞吐量,同步块执行速度较长

5、原子操作的实现原理

处理器实现原理:略

Java实现原子操作:在Java中可以通过锁和循环CAS的方式来实现原子操作。

CAS实现原子操作的三大问题:

  • ABA问题
    因为CAS需要在操作值得时候检查值有没有变化,如果没有变化则更新,如果一个值原来是A,变成了B,又变成了A,那么使用CAS进行检查的时候回发现他的值没有变化,但是实际上却变化了。ABA问题的解决思路就是使用版本号,在变量前面加上版本号,每次变量更新的时候把版本号加1,那么A->B->A就会变成1A->2B->3A。JDK的Atomic包里同样提供了一个类AtomicStampedReference来解决ABA问题。这个类的compareAndSet方法的作用首先是检查当前应用是否等于预期引用,并且检查当前标志是否等于预期标志,如果全部相等,则以原子引用的方式将该引用和该标志的值设为给定的更新值。
  • 循环时间长开销大
  • 只能保证一个共享变量的原子操作
    对多个共享变量操作时,循环CAS就无法保证操作的原子性,这个时候可以用锁。同样JDK提供了AtomicReference类来保证引用对象之间的原子性,就可以把多个变量放在一个对象里来进行CAS操作。

3、Java内存模型

1、线程通信

在Java中,所有实例域、静态域和数组元素都存储在堆内存中,堆内存在线程之间共享。局部变量、方法定义参数和异常处理参数不会在线程之间共享。他们不会有内存可见性问题,也不会受内存模型的影响。

Java线程之间的通信由Java内存模型(JMM)控制,JMM决定一个线程对共享变量的写入何时对另一个线程可见。从抽象的角度看:JMM定义了线程和主内存之间的抽象关系,线程之间的共享变量存储在主内存中,每个线程都有一个私有的本地内存,本地内存中存储了该线程以读/写共享变量的副本。本地内存是一个抽象概念,并不真实存在。

线程A和线程B之间要通信的话,必须要经过下面两个步骤:
1)线程A把本地内存A中更新过的共享变量刷新到主内存中去。
2)线程B到主内存中去读线程A之前更新过的共享变量。

2、volatile的内存语义

2.1volatile的特性

volatile变量具有以下特性:

  • 保证可见性
    对一个volatile变量的读,总是能看到(任意线程对这个volatile变量最后的写入)

  • 不保证原子性
    对任意单个volatile变量的读写具有原子性,但类似于volatile++这种复合操作不具有原子性

  • 禁止指令重排序

2.2volatile写-读的内存语义(重要)

volatile写的内存语义如下:当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值刷新到主内存。

volatile读内存的语义如下:当读一个volatile变量时,JMM会把该线程对应的本地内存置为无效。线程接下来将从主内存读取共享变量并保存在线程对应的本地内存中。

2.3锁的内存语义

当线程释放时,JMM会把该线程对应的本地内存中的共享变量刷新到主内存中。当线程获取锁时,JMM会把该线程对应的本地内存置为无效,同时从主内存中读取共享变量保存在本地内存中。

对比锁释放-获取的内存语义和volatile写-读的内存语义可以看出:锁释放与volatile写有相同的内存语义,锁获取与volatile读有相同的内存语义。

4、Java并发编程基础

1、线程简介

1.1什么是线程

在一个进程里面可以创建多个线程,这些线程都拥有各自的计数器、堆栈、和局部变量等属性,并且能够访问共享变量的内存。处理器在这些线程上高速切换,让使用者感觉到这些线程在同时执行。

1.2线程的优先级

在Java进程中,通过一个整型变量priority来控制优先级,优先级的范围从1~10,在线程构建的时候可以通过setPriority(int x)方法来修改优先级,默认优先级是5。设置优先级时,针对频繁阻塞(I/O)的线程需要设置较高的优先级,而偏重计算的线程需要设置较低的优先级。

1.3线程的状态

状态名称 说明
NEW 初始状态,线程被构建但是还没有调用start()方法
RUNNABLE 运行状态,Java线程将操作系统的就绪和运行两种状态统称为“运行中”
BLOCKED 阻塞状态,表示线程阻塞于锁
WAITING 等待状态,表示线程进入等待状态,进入该状态表示当前线程需要等待其他线程做出一些特定动作(通知或中断)
TIME_WAITING 超时等待状态,该状态不同与WAITING,它是可以在指定的时间自行返回的
TERMINATED 终止状态,表示当前线程已经执行完毕

1.4Daemon(守护)线程

Daemon线程是一种支持性线程,它主要被用作程序中后台调度以及支持性工作。这意味着,当一个Java虚拟机中不存在非Daemon线程的时候,Java虚拟机将会退出。可以通过调用Thread.setDaemon(true)将线程设置为Daemon线程

2、启动和停止线程

启动线程:线程对象在初始化之后,调用start()方法就可以启动这个线程。
start()和run()方法的区别:xxx

停止线程:中断可以理解为线程的一个标识位属性,他表示一个运行中的线程是否被其他线程进行了中断操作。其他线程调用该线程的interrupt()方法对其进行中断操作。中断操作时一种简便的线程间交互方式,这种交互方式最适合用来取消或停止任务。除了中断以外,还可以利用Boolean变量来控制是否需要停止任务并终止该线程,示例代码如下:


public class ShutDownDemo {
    public static void main(String[] args) throws InterruptedException {
        Runner runner = new Runner();

        Thread countthread = new Thread(runner, "countthread");
        countthread.start();

        TimeUnit.SECONDS.sleep(1);

        countthread.interrupt();

        Runner runner1 = new Runner();
        countthread = new Thread(runner1, "countthread");

        countthread.start();

        TimeUnit.SECONDS.sleep(1);
        runner1.cancel();


    }
}

class Runner implements Runnable{

    private long i;

    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;
    }
}

这种通过标识位或者中断操作的方式能够使线程在终止时有机会去清理资源,而不是武断地将线程终止。

3、线程间通信

3.1volatile和synchronized关键字

volatile关键字可以用来修饰字段(成员变量),就是告知程序任何对该变量的访问均需从共享内存中获取,而对他的任何修改必须同步刷新到共享内存,他们保证所有线程对变量访问的可见性。

关键字synchronized可以修饰方法或者以同步块的形式来进行使用,它主要确保多个线程在同一个时刻,只能有一个线程处于方法或者同步块中,它保证了线程对变量访问的可见性和排他性

等待通知机制的相关方法是Java对象都具备的,因为这些方法被定义在所有对象的超类Object上,方法如下所示

方法名 描述
notify() 通知一个在对象上等待的线程,使其从wait()方法返回,而返回的前提是该线程获取到了对象的锁
notifyAll() 通知所有等待在该对象上的线程
wait(long,int) 调用该方法的线程进入WAITING状态,只有等待另外线程的通知或中断才会返回,需要注意,调用wait()方法后,会释放对象的锁

等待通知机制,是指一个线程A调用了对象O的wait()方法进入等待状态,而另一个线程B调用了对象O的notify()方法,线程A收到通知后从对象O的wait()方法返回,进而执行后续操作。

3.2Thread.join()

如果一个线程A执行了Thread.join()语句,其含义是:当前线程A等待thread线程终止之后才从thread.join()返回

4、Java中的锁

4.1Lock接口

锁是用来控制多个线程访问共享资源的方式,一般来说,一个锁能够防止多个线程同时访问共享资源(共享锁除外)。Java程序靠Lock接口和synchronized关键字实现锁功能。

Lock接口的使用方式如下:

Lock reentrantLock = new ReentrantLock();
            reentrantLock.lock();
            try {
                // do...
            } finally {
                reentrantLock.unlock();
            }

在final块中释放锁,目的是保证在获取到锁之后,最终能够被释放。
不要将获取锁的过程写在try块中,因为如果在获取锁时发生了异常,异常抛出的同时,也会导致锁无故释放。

Lock接口提供的锁主要特性如下(区别于synchronized关键字):
待补充。。。

4.2队列同步器

Lock接口的实现类基本都是通过聚合了一个同步器的子类来完成线程访问控制的。

队列同步器是用来构建锁或者其他同步组件的基础框架,他使用了一个int成员变量表示同步状态,通过内置的FIFO队列来完成资源获取线程的排队工作。

同步器是实现锁的关键,在锁的实现中聚合同步器,利用同步器实现锁的语义。可以这样理解二者之间的关系:锁时面向使用者的,他定义了使用者于锁交互的接口,隐藏了实现细节;同步器面向的是锁的实现,他简化了锁的实现方式,屏蔽了同步状态管理器、线程的排队、等待与唤醒等底层操作。

4.3重入锁

重入锁,顾名思义就是支持重进入的锁,他表示该线程能够支持一个线程对资源的重复加锁。除此之外,该锁还支持获取锁时公平和非公平性的选择。

synchronized关键字隐式的支持重进入,比如一个synchronized修饰的递归方法,在方法执行时,执行线程在获取了锁之后仍能连续多次地获得该锁

锁获取的公平性问题: 如果在绝对时间上,先对锁进行获取的请求一定先被满足,那么这个锁时公平的。反之是不公平的。公平锁的获取,也就是等待时间最长的线程最优先获取锁,也可以说获取锁的顺序的。

事实上,公平锁的效率往往没有非公平锁的效率高,公平锁能够减少“饥饿”发生的概率,等待时间越久的请求越是能够得到优先满足。

下面着重分析ReentrantLock是如何实现重进入和公平性获取锁的特性:

1、实现重进入
重进入是指任意线程在获取锁之后能够再次获取该锁而不会被锁阻塞,该特性的实现需要解决以下两个问题:

  • 线程再次获取锁
    锁需要去识别获取锁的线程是否为当前占据锁的线程,如果是,则再次成功获取
  • 锁的最终释放
    线程重复n次获取了锁,随后在第n次释放该锁后,其他线程能够获取到该锁。锁的最终释放要求锁对于获取进行计数自增,计数表示当前锁被重复获取的次数,而锁被释放时,计数自减,当计数等于0表示锁已经成功释放。

ReentrantLock是通过组合自定义同步器来实现锁的释放和获取。如果获取锁的线程再次请求获取锁,则将同步状态值进行增加并返回true,表示获取锁成功。同样,ReentrantLock在释放锁时会减少同步状态值。

4.4读写锁

之前提到的ReentrantLock是排它锁,即在同一时刻只允许一个线程访问。而读写锁在同一时刻可以允许多个读线程访问,但是在写线程访问时,所有的读线程和其他写线程均被阻塞。读写锁维护了一对锁,一个读锁,一个写锁,通过分离读锁和写锁,是的并发性相比其他一般的排他锁有了很大提升。

一般情况下,读写锁的性能会比其他排他锁好,因为大多数场景读是多余写的。Java中提供读写锁的是ReentrantReadWriteLock,它提供的特性如下:

特性 说明
公平性选择 支持非公平(默认)和公平的锁获取方式,吞吐量还是非公平优于公平
重进入 该锁支持重进入
锁降级 遵循获取写锁、获取读锁再释放写锁的次序,写锁能够降级成读锁

ReentrantReadWriteLock展示内部工作状态的方法:

方法名称 描述
int getReadLockCount() 返回当前读锁被获取的次数。该次数不等于获取读锁的线程数,例如,仅一个线程,它连续获取了n次读锁,那么占据读锁的线程数是1,但该方法返回n
int getReadHoldCount() 返回当前线程获取读锁的次数
boolean isWriteLocked() 判断写锁是否被获取
int getWriteHoldCount() 返回当前写锁被获取的次数

5、Java并发容器和框架

1、CurrentHashMap的实现原理与使用

1.1为什么要使用CurrentHashMap

  • 线程不安全的HashMap
    在多线程环境下,使用HashMap进行put操作会引起死循环,因为多线程会导致HashMap的Empty链表形成环形数据结构,一旦形成环形数据结构,Entry的next节点永远不为空,就会产生死循环获取Entry,所以在并发请款下不能使用HashMap。
  • 效率低下的HashTable
    HashTable容器使用synchd来保证线程安全,但在线程竞争激烈的情况下,HashTable效率非常低下。因为当一个线程访问HashTable的同步方法,其他线程也访问HashTable的同步方法时,会进入阻塞或轮询状态。如线程1使用put进行元素添加,线程2不但不能使用put方法添加元素,也不能使用get方法来获取元素,所以竞争越激烈效率越低。
  • CurrentHashMap的锁分段结束可有效提升并发访问效率
    HashTable容器在竞争激烈的并发环境下表现出效率低下的原因是所有HashTable的线程都必须竞争同一把锁。假如容器里有多把锁,每一把锁用于锁容器其中一部分数据,那么当多线程访问容器里不同数据段数据时,线程间就不会存在锁竞争,从而提高并发效率,这就是CurrentHashMap锁使用的锁分段技术。首先将数据分成一段一段地存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据的时候,其他的段的数据也能被其他线程访问。

1.2CurrentHashMap的结构

CurrentHashMap是由Segment数组结构和HashEntry数组结构组成。Segment是一种可重入锁,在CurrentHashMap里扮演锁的角色;HashEntry则用于存储键值对数据。一个CurrentHashMap里包含一个Segment数组。Segment的结构和HashMap类似,是一种数组和链表结构。一个Segment里包含一个HashEntry数组,每个HashEntry是一个链表结构的元素,每个Segment守护着一个HashEntry数组里的元素,当对HashEntry数组的数据进行修改时,必须首先获得与他对应的Segment锁。
Java并发编程的艺术_第3张图片

1.3CurrentHashMap的操作

get操作:Segment的get操作实现非常简单和高效。先经过一次再散列,然后使用这个散列值通过散列运算定位到Segment,再通过散列算法定位到元素。

public V get(Object key){
	int hash = hash(key.hashCode());
	return segmentFor(hash).get(key,hash);
}

get操作的高效在于整个get操作不需要加锁,除非读到空值才会加锁重读。那么他是如何做到不加锁的呢?原因是他的get方法里将要使用的共享变量都定义成volatile类型,如用于统计当前Segment大小的count字段和用于存储值得HashEntry的value,此举保证了变量在线程之间的可见性,能够被多线程同时读取,并且保证不会读取到过期值。

put操作:put方法首先定位到Segment,然后在Segment里进行插入操作。插入操作经过两个步骤:第一步判断是否需要对Segment里的HashEntry数组进行扩容,第二步定位添加元素的位置,然后将其放在HashEntry数组里。

  • 是否需要扩容
    Segment扩容的操作比HashMap更恰当,因为HashMap是在插入元素后判断元素是否已经达到容量的,如果到达了就扩容,但是很有可能扩容之后没有新元素加入,这时HashMap就进行了一次无效的扩容。

  • 如何扩容
    在扩容的时候,首先会创建一个容量是原来容量两倍的数组,然后将原数组里的元素进行再散列后插入到新的数组里。为了高效,ConcurrentHashMap不会对整个容器扩容,而只对某个segment进行扩容

size操作:ConcurrentHashMap的做法是先尝试两次通过不锁住的Segment的方法来统计各个Segment大小,如果统计过程中,容器的count发生了变化,则再采用加锁(把所有Segment的put、remove、和clean方法全部锁住)的方式来统计所有Segment的大小。那么ConcurrentHashMap是如何判断在统计的时候容器发生了变化呢?使用了modCount变量,在put、remove、和clean方法里操作元素钱都会讲modCount进行加1,那么在统计size前后比较modCount是否发生变化,从而得知容器大小是否发生了变化。

2、Java中的阻塞队列

2.1什么是阻塞队列

阻塞队列是一个支持两个附件操作的队列。这两个附加的操作支持阻塞的插入和异常方法。

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

在阻塞队列不可用时,这两个附加操作提供了4种处理方式:

  • 抛出异常:当队列满时,如果再往队列里插入元素,会抛出IllegalStateException异常。当队列空时,从队列里获取全部元素会抛出NoSuchElementException异常。
  • 返回特殊值:当往队列插入元素时,会返回是否插入成功,成功返回true。如果是移除方法,则是从队列里取出一个元素,如果没有则返回null。
  • 一直阻塞:当队列满时,如果生产者线程往队列里pull元素,队列会一直阻塞生产者线程,直到队列可用或者响应中断退出。当队列空时,如果消费者线程从队列里take元素,队列会阻塞住消费者线程,直到队列不为空。
  • 超时退出:当阻塞队列满时,如果生产者线程往队列里插入元素,队列会阻塞生产者线程一段时间,如果超出了指定的时间,生产者线程就会退出。

6、Java中的并发工具类

1、等待多线程完成的CountDownLatch

CountDownLatch允许一个或多个线程等待其他线程完成操作。
假如有这样一个需求:我们需要解析一个Excel里多个sheet数据,此时可以考虑使用多线程。每个线程解析一个sheet里的数据,等到所有的sheet都解析完之后,程序需要提示解析完成。在这个需求中,要实现主线程等待所有线程完成sheet的解析操作,最简答的方法是使用join()方法,join()用于让当前执行线程等待join线程执行结束。其实现原理是不停地检查join线程是否存活,如果join线程存活则让当前线程永远等待,其中wait(0)表示永远等待下去。

//待补充详细

在JDK1.5之后的并发包中提供的CountDownLatch也可以实现join的功能,并且比join的功能更多。

CountDownLatch的构造函数接收一个int类型的参数作为计数器,如果你想等待N个点完成,这里就传入N。当我们调用CountDownLatch的countDown方法时,N就会减一,CountDownLatch的await方法会阻塞当前线程,直到N变成零,由于countDown方法可以用在任何地方,所以这里说的N个点,可以是N个线程,也可以是1个线程里的N个执行步骤,用在多个线程时,只需要把这个CountDownLatch的引用传递到线程里即可。

如果有某个解析sheet的线程处理的会比较慢,我们不可能一直让主线程一直等待,所以可以使用另一个带执行时间的await方法-await(long time,TimeUnit unit),这个方法等待特定时间后就不会再阻塞当前线程。

你可能感兴趣的:(面试题集锦,java,多线程,并发编程)