java 高并发程序设计详解

JAVA herman 538浏览 0评论

有网友在面试过程中遇到了并发方面的知识,今天我就为大家简单的分析一下 java 关于并发编程和设计的知识,希望大家喜欢!

所谓并发编程是指在一台处理器上“同时”处理多个任务。并发是在同一实体上的多个事件。多个事件在同一时间间隔发生。并发编程的目标是充分的利用处理器的每一个核,以达到最高的处理性能。

根据上面的定义,我们总结下并发的概念:

  • 同步与异步 
  • 并发与并行
  • 临界区
  • 阻塞与非阻塞
  • 死锁、饥饿与活锁

并发的级别

  • 阻塞(Blocking):synchronized和重入锁 
  • 无饥饿(Starvation-Free):对于非公平锁,采取优先级插队;对于公平锁,采取FIFO排队 
  • 无障碍(Obstruction-Free):一致性标记(如版本号)。线程操作之前,读取并保存标记;在操作完成后,再次读取,检查这个标记是否被更改过,如果两者是一致的,则说明资源访问没有冲突;如果不一致,说明资源可能在操作过程中与其他写线程冲突,需要重试操作。 
  • 无锁:所有线程都能尝试对临界区进行访问,但是无锁的并发保证必然有一个线程能够在有限步骤内完成操作离开临界区(CAS) 
  • 无等待:在无锁的基础上,要求所有的线程必需在有限步骤内完成,如RCU——所有的读线程都是无等待的,但是在写数据的时候,先取得原始数据的副本,接着只修改副本数据,修改完成后,在合适的时机回写数据。

并发的规则

  • 不能重排序的指令:Happen-Before规则 
  • 程序顺序原则:一个线程内保证语义的串行性 
  • volatile规则:volatile变量的写,先发生于读,保证了volatile变量的可见性 
  • 锁规则:解锁(unlock)必然发生在随后的加锁(lock)之前 
  • 传递性:A->B,B->C,那么A必然先于C 
  • 线程的start()方法先于它的每一个动作 
  • 线程的所有操作先于线程的终结(Thread.join()) 
  • 线程的中断(interrupt())先于被中断线程的代码 
  • 对象的构造函数执行、结束先于finalize()方法

Java 并行程序基础

执行Thread.run()前一定要Tread.start(),不然会在当前线程中串行执行run()中的代码

Thread.stop()会直接终止线程,释放这个线程所持有的维持对象一致性的锁,这样会造成访问对象出现数据不一致的问题。

//中断线程,设置中断标志位
public void Thread.interrupt()
//判断线程是否被中断
public boolean Thread.isInterrupted()
//判断线程是否被中断,并清除当前的中断状态
public static boolean Thread.interrupted()

合理的中断方式如下:

Thread t1 = new Thread() {
    public void run() {
        while (true){
            if (Thread.currentThread().isInterrupted()){
                System.out.println("Interrupted!");
                break;
            }
            Thread.yield();
        }
    }
};

Thread.sleep()方法由于中断而抛出异常,此时会清除中断标记,如果不加处理,那么在下一次循环开始时,就无法捕捉这个中断,故在异常处理中,再次设置中断标志位。

object.wait()和object.notify()方法都需要首先获得目标对象的一个监视器,必需包含在对应的synchronized语句中,因为Java的线程是抢占式的,选择是随机的。

Thread.suspend()挂起线程会导致线程暂停的同事,并不会去释放任何锁资源,导致无法正常继续运行,知道对应的线程上进行了Thread.resume()操作,被挂起的线程才能继续;如果Thread.resume()操作先于Thread.suspend()执行,则被挂起的线程很难被执行,导致系统异常,且从jstack状态上看还是RUNNABLE,导致无法判断问题。

等待线程结束(join)和谦让(yield)

//无限等待,一致阻塞线程知道目标线程执行完毕
public final void join() throws InterruptException
//如果超过时间目标线程还在执行,当前线程也即会执行,不会等待上一个线程执行结束
public final synchronized void join(long millis) throws InterruptException
//使当前线程让出CPU,并与其他线程一起争夺CPU资源使用权
public static native void yield();

JDK并发包

公平锁:public static ReentrantLock fairLock = new ReentrantLock(true); 

ReentrantLock重要的方法

//获得锁,如果锁被占用,则等待
lock();
//获得锁,但优先响应中断
lockInterruptibly();
//尝试获得锁,如果成功,则返回true,失败返回false。该方法不等待,立即返回
tryLock();
//在给定时间内尝试获得锁
tryLock(long time, TimeUnit unit);
//释放锁
unlock();

重入锁主要包含三个要素:

  • 原子状态:使用CAS操作判断当前锁的状态是否被别的线程持有
  • 等待队列:所有没有请求到锁的线程,会进入等待队列进行等待,待有线程锁后,系统从等待队列中唤醒一个线程,继续工作。
  • 阻塞原语park()和unpark():挂起和恢复线程,没有得到锁的线程将会被挂起。

允许多线程同时访问:信号量(Semphore)

//信号量的准入数
public Semaphore(int permits)
//信号量的准入数和是否是公平许可
public Semaphore(int permits, boolean fair)

可以指定多个线程访问一个资源,使用信号量必需用release()方法释放信号量,避免信号量泄露(申请了但是没有释放)

倒计时器CountDownLatch与循环栅栏CyclicBarrier的对比 
public CyclicBarrier(int parties, Runnable barrierAction) //barrierAction当最后一次计数完成后,系统会执行的动作;parties为技术总数,即参与的线程总数 
CountDownLatch与CyclicBarrier都可以实现线程间的计数等待,但是CyclicBarrier可以接受一个参数作为barrierAction 

LockSupport线程阻塞工具,park()避免了Thread.resume()容易出现的问题,因为park()使用了类似信号量的机制,为每一个线程准备了一个许可,如果许可可用,那么park()就会理科返回,并且消费这个许可(使许可变为不可用),如果许可不可用,即会阻塞;而unpark()是让一个许可变为可用许可(park()最多只有一个可用许可,且不可累加)

线程池:如果线程数量超过corePoolSize,则进入阻塞队列,执行饱和策略,如果阻塞队列超过负载,则执行拒绝策略。

线程分治:Fork/Join框架

//task为自定义的任务,必需继承ForkJoinTask的两个重要子类RecurisiveTask(返回<V>类型)或RecurisiveAction(无返回)其中一个
public <T> ForkJoinTask<T> submit(ForkJoinTask<T> task);

通过ForkJoinPool线程池分配线程资源,fork()开启线程,join()等待处理结果。 
使用时需注意:如果任务一直得不到返回,可能系统内的线程数量越积越多导致性能严重下降,或者函数调用层次变得很深,导致栈溢出。 
ForkJoinPool线程池使用一个无锁的栈管理空闲的线程,如果一个工作线程暂时取不到可用的任务,则可能被挂起,挂起的线程将会被压入由线程池维护的栈中,等待有任务可用时从栈中唤醒这些线程。

Java虚拟机对锁的优化

  • 锁偏向:如果一个线程获得了锁,那么锁就进入偏向模式;当这个线程再次请求锁时,无须再做任何同步操作。
  • 轻量级锁:如果偏向锁失败,,进行轻量级锁操作——将对象的头部作为指针,指向持有锁的线程堆栈的内部,判断一个线程是否持有对象锁。如果线程获得轻量级锁成功,则可以顺利进入临界区,如果轻量级加锁失败,则表示其他线程抢先争夺到了锁,那么当前线程的锁请求就会膨胀为重量级锁。
  • 自旋锁:如果轻量级锁膨胀,JVM会让当前线程若干次循环请求锁,如果自旋锁阶段还不能获得锁,线程才会在操作系统层面挂起。
  • 锁消除:JVM在JIT编译时,去除不可能存在共享资源竞争的锁,节省请求锁的时间。

无锁对象

  • 无锁的对象引用:AtomicReference可以保证在修改普通对象引用时的线程安全性
  • 带有时间戳的对象引用:AtomicStampedReference设置对象时,对象值和时间戳都必须满足期望值,写入才会成功
  • 原子操作的对象引用:AtomicReferenceFiledUpdater对普通对象进行CAS修改

并行模式与算法

  • 单例模式:推荐懒汉模式,只会在instance被第一次使用时创建对象 
  • 不变模式:不可变模式的对象多线程友好,对象创建后内部状态和数据不再变化,对象可以被共享被对多线程频繁访问。 
  • 生产者-消费者模式:通过内存缓冲区解耦。 
  • Future模式:异步调用,调用者立即返回,在真正需要数据的场合再去尝试获得需要的数据。 
  • 并行流水线:开启多线程,将每一个拆分出来的任务单一职责化,计算出结果。 
  • NIO模式:NIO网络操作中提供了非阻塞的方法,但是NIO的IO行为还是同步的。业务线程在IO操作准备好时,由这个线程自行进行IO操作,IO本身还是同步的。 
  • AIO模式:不是IO操作准备好时再通知线程,而是在IO操作已经完成后再给线程发出通知。因此AIO是完全不会阻塞的。

Java8与并发

增强的Future:CompletableFuture

public class CompletableFuture<T> implements Future<T>, CompletionStage<T>

ForkJoinPool.commonPool()方法:获得一个公共的ForkJoin线程池,这个公共的线程池中的所有的线程都是Daemon线程,意味着如果主线程(JVM线程)退出,这些线程无论是否执行完毕,都会退出系统。

读写锁改进:StampedLock 
ReentrantReadWriteLock虽然分离了读和写的功能,使得读与读之间可以完全并发,但是读和写之间依然是冲突的,读锁会完全阻塞写锁,使用的策略已然是悲观锁策略。StampedLock提供了乐观锁策略,通过时间戳整数stamp作为锁获取的凭证,这样乐观锁完全不会阻塞读线程。

StampedLock内部实现时使用了CAS操作的死循环反复尝试的策略,导致阻塞在park()上的线程被中断后,会再次进入循环;而当退出条件不满足时,会发生疯狂占用CPU情况。
StampedLock的内部实现类似于CLH锁(一种自旋锁),保证没有饥饿发生,且保证FIFO的服务顺序。
更快的原子类LongAddr(使用了类似于ConcurrentHashMap的热点数据分离和CAS思想)