java多线程学习总结

本文是对 Java 多线程的全面复习,涵盖了多线程概念,如进程与线程的区别、启动线程、线程中断以及安全地终止线程的方法。深入探讨了线程上下文切换的原理和减少切换的策略,同时讲解了 Java 并发底层的 volatile 原理、重排序规则和 Happens-before 规则。还详细讨论了 synchronized 的实现和优化,如偏向锁、轻量级锁和自旋锁。此外,文章介绍了线程安全的实现方法,如互斥同步、非阻塞同步和无同步方案。最后,对比了 Synchronized 与 ReentrantLock 的差异,并讲解了 JUC 并发包中的工具如 CountDownLatch 和 CyclicBarrier,以及线程池的工作原理。
摘要由CSDN通过智能技术生成

这篇文章主要是最近复习多线程部分的总结,主要参考了<<java并发编程艺术>>,<<深入理解java虚拟机>>,<<Java高并发程序设计>>三本书,算是对java多线程方面的学习笔记。笔记还是主要帮助复习记忆,只是起到参考作用,详细的内容还是看书为主。


 

目录

 

1.多线程概念

进程和线程的对比:

1.启动线程

2.理解线程中断

3.过期的suspend(),resume(),stop()

4.安全地终止线程

2.线程上下文切换

一、互斥锁的开销主要在内核态与用户态的切换:

二.cpu系统调用消耗时间

三.如何减少上下文切换

3.java并发底层原理探究(重要)

重排序:

数据依赖性:

as-if-serial

Happens-before规则

程序顺序规则

synchronized的实现原理及应用

可见性:

原子性:

有序性:

锁优化:

1.偏向锁

2.轻量级锁

3.自旋锁

4 锁消除

5 锁粗化

 

4.线程安全的实现方法

1.互斥同步

2.非阻塞同步

3.无同步方案

 

5.Synchronized 和 ReenTrantLock 的对比

ReentrantReadWriteLock(读写锁)

CopyOnWriteArrayList 

6.线程之间的通信机制

7JUC并发包

1.CountDownLatch(倒计时器)

2.CyclicBarrier

3.线程阻塞工具类:LockSupport

4.控制并发线程数的Semaphore

8.线程池

线程池的工作原理

9.多线程面试题

1.如何停止一个线程

2.何为线程安全的类?

3.如何确保线程安全?

4.主线程等待子线程运行完毕再运行的方法


 

1.多线程概念

 

进程和线程的对比:

 

根本区别:进程是操作系统资源分配的基本单位,而线程是cpu调度的基本单位

在开销方面:每个进程都有独立的代码和数据空间(程序上下文),程序之间的切换会有较大的开销;线程可以看做轻量级的进程,同一类线程共享代码和数据空间,每个线程都有自己独立的运行栈和程序计数器(PC),线程之间切换的开销小。

所处环境:在操作系统中能同时运行多个进程(程序);而在同一个进程(程序)中有多个线程同时执行(通过CPU调度,在每个时间片中只有一个线程执行)

内存分配方面:系统在运行的时候会为每个进程分配不同的内存空间;而对线程而言,除了CPU外,系统不会为线程分配内存(线程所使用的资源来自其所属进程的资源)

包含关系:没有线程的进程可以看做是单线程的,如果一个进程内有多个线程,则执行过程不是一条线的,而是多条线(线程)共同完成的;线程是进程的一部分,所以线程也被称为轻权进程或者轻量级进程。

 

1.启动线程

线程随着调用start()方法而启动,随着run()方法的执行完毕而进入终止状态。在启动线程前最好为线程设置名字,这样在使用jstack分析线程堆栈时,给开发人员一些提示。

守护线程:主要做一些支持性工作,当Java虚拟机中不存在非Daemon线程,java虚拟机将推出,守护进程也将立即终止

 

2.理解线程中断

中断好比其他线程对该线程打了个招呼,其他线程通过调用该线程的interrupt()方法进行中断操作。线程通过方法isInterrupted()来进行判断是否被中断,但线程如果是终止状态即使被中断过该方法还是返回false,Java API中抛出InterruptedException()的方法,会在抛出InterruptedException()之前先将中断标识位清除,此时调用isInterrupted()还是返回false。可以调用静态方法Thread.interrupted()对当前线程的中断标识位进行复位。Runnable状态下进行中断不会抛出异常,会将标志位置为true。

 

3.过期的suspend(),resume(),stop()

suspend()暂停线程后不会释放锁,容易导致死锁问题,stop()方法在终结线程的时候不会保证线程资源正常释放。

 

 

4.安全地终止线程

中断这种交互方式最适合用于取消和停止任务,或者还可以利用volatile变量作为标志位来终止任务。volatile相关将在后文讲述。

public class Shutdown {

    public static void main(String[] args) throws InterruptedException {
        Runner one = new Runner();
        Thread countThread = new Thread(one, "CountThread");
        countThread.start();
        TimeUnit.SECONDS.sleep(1);
        countThread.interrupt();

        Runner two = new Runner();
        countThread = new Thread(two, "CountThread");
        countThread.start();
        TimeUnit.SECONDS.sleep(1);
        two.cancel();

    }

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

 

输出结果:

Count i = 350373844

Count i = 317941913

 

 

2.线程上下文切换

 

上下文切换(context switch):多任务系统往往需要同时执行多道作业.作业数往往大于机器的CPU数, 然而一颗CPU同时只能执行一项任务, 如何让用户感觉这些任务正在同时进行呢? 操作系统的设计者巧妙地利用了时间片轮转的方式, CPU给每个任务都服务一定的时间, 然后把当前任务的状态保存下来, 在加载下一任务的状态后, 继续服务下一任务. 任务的状态保存及再加载, 这段过程就叫做上下文切换. 时间片轮转的方式使多个任务在同一颗CPU上执行变成了可能, 但同时也带来了保存现场和加载现场的直接消耗. 

 

上下文切换发生条件 描述

中断处理 中断分为硬件中断和软件中断,软件中断包括因为IO阻塞、未抢到资源或者用户代码等原因,线程被挂起。

多任务处理 每个程序都有相应的处理时间片,当前任务的时间片用完之后,系统CPU正常调度下一个任务。

用户态切换 这种情况下,上下文切换并非一定发生,只在特定操作系统才会发生上下文切换.

线程的操作,创建,析构,同步,需要进行系统调用,系统调用的代价比较高,需要在用户态和内核态中来回切换。

 

一、互斥锁的开销主要在内核态与用户态的切换:

  申请锁时,从用户态进入内核态,申请到后从内核态返回用户态(两次切换);没有申请到时阻塞睡眠在内核态。使用完资源后释放锁,从用户态进入内核态,唤醒阻塞等待锁的进程,返回用户态(又两次切换);被唤醒进程在内核态申请到锁,返回用户态(可能其他申请锁的进程又要阻塞)。所以,使用一次锁,包括申请,持有到释放,当前进程要进行四次用户态与内核态的切换。同时,其他竞争锁的进程在这个过程中也要进行一次切换。

 

二.cpu系统调用消耗时间

由于java使用的线程调度方式是抢占式线程调度,每个线程将由系统来分配执行时间,线程的切换不由线程本身决定。在当前任务执行完一个时间片后,就会切换到下一个任务,但是,切换前会保存上一个任务的状态,以便下次切换会这个任务,还可以加载这个任务的状态。所以任务从保存,到再加载的过程就是一次上下文切换,在线程每进行一次切换都消耗时间,影响执行速度。

 

三.如何减少上下文切换

无锁并发竞争:无锁并发编程。多线程竞争时,会引起上下文切换,所以多线程处理数据时,可以用一些办法来避免使用锁,将数据Id按hash算法取模来分段,不同的线程处理不同时端的数据

CAS算法:Java的atomic包使用CAS算法来更新数据。不需要加锁。Atomic变量的更新可以实现数据操作的原子性及可见性。这个是由volatile 原语及CPU的CAS指令来实现的。

使用最少线程:若任务少,但创建了很多线程来处理,这样会造成大量的线程处于等待状态。

协程:在单线程里实现多任务的调度,并在单线程里维持多个任务间切换。

 

3.java并发底层原理探究(重要)

 

volatile的原理和应用

volatile使用恰当的话,它比synchronized的使用和执行成本更低,因为不会引起上下文切换。

有volatile修饰的共享变量在进行写操作时会多出一条Lock指令。

 

第一条语义:具备可见性

 

volatile写-读的内存语义

1.当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量(所有共享变量)刷新到主内存。

2.当读一个volatile变量时,JMM会把该线程对应的本地内存置为无效。线程接下来从主内存中读取共享变量。

 

 

//线程1
boolean stop = false;
while(!stop){
    doSomething();
}

 

//线程2

stop = true;

 

对于这一段代码来说有可能线程2对stop的更改线程1一直看不到,分两种情况

  • 线程2 对变量的修改没有立即刷入到主存当中;
  • 即使 线程2 对变量的修改立即反映到主存中,线程1 也可能由于没有立即知道 线程2 对stop变量的更新而一直循环下去。

 

第二条语义,禁止指令重排序

因为要在本地代码插入许多内存屏障指令保证处理器不乱序执行。

 

 

重排序:

重排序指编译器和处理器为了优化程序的性能而对指令序列重新排序的一种手段。

 

 

数据依赖性:

如果两个操作访问同一个变量,且这两个操作中有一个为写操作。此时这两个操作就存在数据依赖性。

这里的数据依赖性仅针对单个处理器中的指令序列和单个线程中执行的操作,不同线程之间的数据依赖性不被编译器考虑。

 

as-if-serial

语义:不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。

为了遵循as-if-serial语义,编译器和处理器不会对存在数据依赖关系的操作重排序,因为这种重排序会改变执行结果。如果不存在数据依赖性则可能重排序。

 

 

Happens-before规则

happends-before是JMM最核心的概念,它能向程序员提供足够强的内存可见性保证(有些内存可见性保证并不一定真实存在)。

定义:

1.如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。

2.两个操作之间存在happens-before关系,并不意味着Java平台的具体实现必须要按照happens-before关系指定顺序来执行。如果重排序之后的执行结果,与按happens-before关系来执行的结果一致,那么这种重排序不非法。

 

第一个是JMM对程序员的承诺,第二个是JMM对编译器和处理器重排序的约束规则。程序员对于这两个操作是否会被重排序并不关心,程序员关心的是程序执行结果(语义)不能改变

 

happens-before关系本质上和as-if-serial语义是一回事。

as-if-setail语义保证单线程内程序的执行结果不被改变,happens-before关系保证正确同步的多线程程序的执行结果不被改变。

as-if-setail语义给编写单线程程序的程序员创造了一个幻境:单线程程序是按照程序顺序执行的。happens-before关系给编写正确同步的多线程程序程序员创造了一种幻境:正确同步的多线程程序是按照happens-before指定的顺序执行的。

它们这么做的目的,都是为了在不改变程序执行结果的前提下,尽可能提高程序执行的并行度。

1.程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后序操作

2.监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁

3.volatile变量规则:对一个volatile变量的写,happens-before于任意后序对这个volatile变量的读

4.传递性:如果A happens-before B,B happens-before C,则 A happens-before C

5.start()规则:如果线程A执行操作ThreadB.start()(启动B线程),那么A线程的ThreadB.start()操作happends-before于B线程中的任意操作

6.join()规则,如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作happends-before于线程A从ThreadB.join()操作成功返回。

对于5,根据1和4的组合使用可以保证,在线程A执行ThreadB.start()之前对共享变量的修改,在线程B开始后确保对B可见

对于6,根据1和4的组合使用可以保证,在线程A执行ThreadB.join()成功返回后,线程B对共享变量的任意操作将对线程A可见

 

程序顺序规则

一个线程中的每个操作,happens-before于该线程中的任意后序操作

double pi = 3.14 //A

double r = 1.0 //B

double area = pi*r* //C

1.A happens-before B

2.B happens-before C

2.A happens-before C

 

如果A happens-before B,并不一定要求A在B之前执行。JMM仅要求前一个操作的结果对后一个操作可见。重排序操作A和操作B后的执行结果与操作A和操作B按happens-before顺序执行的结果一致,JMM认为重排序不非法,运行这种重排序

 

 

 

synchronized的实现原理及应用

使用monitorenter和monitorexit指令实现,monitorenter指令是编译后插入到同步代码块的开始位置,而monitoerexit是插入到方法结束处和异常处,JVM要保证每个monitorenter都有monitorexit与之配对,任何对象都有一个monitor与之关联,当一个monitor被持有后,它将处于锁定状态。同步块在已进入的线程执行完之前,会阻塞后面其他线程的进入。如果要阻塞或者唤醒一个线程,都需要操作系统来帮忙完成,这就需要从用户态和内核态切换,会消耗很多处理器石时间。

java SE1.6之后,引入了偏向锁和轻量级锁。锁一共有四种状态,无锁,偏向锁,轻量级锁,重量级锁。这几个状态会随着竞争情况逐渐升级,但不能降级,这是为了提高获得锁和释放锁的效率。

synchronized释放锁的时机:

1、当前线程的同步方法、代码块执行结束的时候释放。代码块不执行完毕不会释放。

2、当前线程在同步方法、同步代码块中遇到break 、 return 的时候释放。

3、出现未处理的error或者exception导致异常结束的时候释放

4、程序执行了 同步对象 wait 方法 ,当前线程暂停,释放锁,进入等待池。

由happends-before规则知道synchronized有可见性,但是不能禁止指令重排序。

 

 

可见性:

是指当多个线程访问同一个共享变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。

 

根据happends-bevolatile修饰的变量可以保证可见性,

 

 

原子性:

原子性的定义:

即一个操作或者多个操作,要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。即使是多个线程在一起执行的时候,一个操作一旦开始,就不会被其他线程干扰。

如银行转账问题,账户A向账户B转1000,包含两个操作,A账户减1000,B账户加1000,如果A账户减1000后出现异常操作终止,则最后A白白少了1000元。

 

java内存模型保证了只有简单的读取、赋值(而且必须是将数字赋值给某个变量,变量之间的相互赋值不是原子操作)才是原子操作。java中可以使用锁和循环CAS的方式实现原子操作。

CAS的实现基本思路就是循环进行CAS操作,直到成功为止。

 

 

有序性:

关于有序性没有一个明确的定义,可以认为synchronized可以保证线程间的有序性,同步代码块的执行过程是串行执行的。

  synchronized无法保证顺序性,也就是说,不能由于 synchronized 可以让线程串行执行同步代码,就说它们可以保证指令不会发生重排序,这根本不是一个粒度的问题。

volatile禁止指令重排序,所以具有有序性

 

锁优化:

 

1.偏向锁

消除数据在无竞争下的同步消耗,锁偏向于第一个获得它的线程,如果接下来锁没有被其他线程获取,则持有偏向锁的线程将永远不需要再进行同步

 

2.轻量级锁

在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗,因为使用轻量级锁时,不需要申请互斥量,轻量级锁的加锁和解锁都用到了CAS操作。它能提升程序同步性能的依据是:对于绝大部分锁,在整个同步周期内都是不存在竞争的,这是一个经验数据。如果没有竞争,轻量级锁使用CAS操作避免了使用互斥量的开销。

 

3.自旋锁

轻量级锁失败后,虚拟机为了避免线程真实地在操作系统层面挂起,还会进行一项称为自旋锁的优化手段。互斥同步对性能最大的影响就是阻塞的实现,因为挂起线程/恢复线程的操作都需要转入内核态中完成(用户态转换到内核态会耗费时间)。一般线程持有锁的时间都不是太长,所以仅仅为了这一点时间去挂起线程/恢复线程是得不偿失的。 所以,虚拟机的开发团队就这样去考虑:“我们能不能让后面来的请求获取锁的线程等待一会而不被挂起呢?看看持有锁的线程是否很快就会释放锁”。为了让一个线程等待,我们只需要让线程执行一个忙循环(自旋),这项技术就叫做自旋。

4 锁消除

锁消除理解起来很简单,它指的就是虚拟机即使编译器在运行时,如果检测到那些共享数据不可能存在竞争,那么就执行锁消除。锁消除可以节省毫无意义的请求锁的时间。锁消除设计的一项关键技术为逃逸分析,所谓逃逸分析就是观察某一个变量是否会逃出某一个作用域。

5 锁粗化

原则上,我们在编写代码的时候,总是推荐将同步块的作用范围限制得尽量小,在共享数据的实际作用域才进行同步,这样是为了使得需要同步的操作数量尽可能变小,如果存在锁竞争,那等待线程也能尽快拿到锁。

大部分情况下,上面的原则都是没有问题的,但是如果一系列的连续操作都对同一个对象反复加锁和解锁,那么会带来很多不必要的性能消耗。

 

 

4.线程安全的实现方法

 

1.互斥同步

同步是指多个线程在并发访问共享数据时,保证共享数据同一个时刻只能被一个(或者是一些,使用信号量的时候)线程使用,互斥是用来实现同步的一种手段。如果要阻塞或者唤醒一个线程,都需要操作系统来帮忙完成,这就需要从用户态和内核态切换,会消耗很多处理器石时间。

 

2.非阻塞同步

随着硬件集的发展,出现的另一个选择:基于冲突检测的乐观并发策略,先进行操作,如果没有其他线程争用资源操作就成功了。如果共享资源被争用,发生了冲突,那就采用其他的补偿措施(最常见的是不断重试,直到成功)。由于不需要把线程挂起,因此这种同步操作也称为非阻塞同步。操作和冲突检测这两个步骤整体需要具有原子性,由于硬件指令集的发展,CAS(比较并交换)这条看上去需要多次操作的行为只通过一条指令就可以完成,因此保证了原子性。

CAS的全称是Compare And Swap 即比较交换,其算法核心思想如下

执行函数:CAS(V,E,N)

其包含3个参数

  • V表示要更新的变量
  • E表示预期值
  • N表示新值

如果V值等于E值,则将V的值设为N。若V值和E值不同,则说明已经有其他线程做了更新,则当前线程什么都不做。通俗的理解就是CAS操作需要我们提供一个期望值,当期望值与当前线程的变量值相同时,说明还没线程修改该值,当前线程可以进行修改,也就是执行CAS操作,但如果期望值与当前线程不符,则说明该值已被其他线程修改,此时不执行更新操作,但可以选择重新读取该变量再尝试再次修改该变量,也可以放弃操作。上述的执行过程是一个原子操作。

由于CAS操作属于乐观派,它总认为自己可以成功完成操作,当多个线程同时使用CAS操作一个变量时,只有一个会胜出,并成功更新,其余均会失败,但失败的线程并不会被挂起,仅是被告知失败,并且允许再次尝试,当然也允许失败的线程放弃操作,这点从图中也可以看出来。基于这样的原理,CAS操作即使没有锁,同样知道其他线程对共享资源操作影响,并执行相应的处理措施。同时从这点也可以看出,由于无锁操作中没有锁的存在,因此不可能出现死锁的情况,也就是说无锁操作天生免疫死锁。

CAS操作由Unsafe类的几个方法包装提供,虚拟机在内部对这些方法做了特殊处理,即时编译出的结果就是一条平台相关的处理器CAS指令。

 

AtomicInteger类中所有自增或自减的方法都间接调用Unsafe类中的getAndAddInt()方法实现了CAS操作,从而保证了线程安全


//当前值加1,返回新值,底层CAS操作
public final int incrementAndGet() {
     return unsafe.getAndAddInt(this, valueOffset, 1) + 1;
 }

//Unsafe类中的getAndAddInt方法 更新失败就重试
public final int getAndAddInt(Object o, long offset, int delta) {
        int v;
        do {
            v = getIntVolatile(o, offset);
        } while (!compareAndSwapInt(o, offset, v, v + delta));
        return v;
    }

/Unsafe类中的getAndSetObject方法,实际调用还是CAS操作
public final Object getAndSetObject(Object o, long offset, Object newValue) {
      Object v;
      do {
          v = getObjectVolatile(o, offset);
      } while (!compareAndSwapObject(o, offset, v, newValue));
      return v;
  }


//JDK 1.7的源码,由for的死循环实现,并且直接在AtomicInteger实现该方法,
//JDK1.8后,该方法实现已移动到Unsafe类中,直接调用getAndAddInt方法即可
public final int incrementAndGet() {
    for (;;) {
        int current = get();
        int next = current + 1;
        if (compareAndSet(current, next))
            return next;
    }
}

 

 

3.无同步方案

线程本地存储(ThreadLocal),限制共享数据的可见范围在同一个线程内,无需同步也能保证线程之间不出现数据争用。它完全不提供锁,以空间换时间,为每个线程提供变量的独立副本,不是数据共享的解决方案。在高并发的场合,它可以一定程度上减少锁竞争。

注意:存储的变量必须在每个线程中新建,并保证不同线程间的实例均不相同,否则其线程安全性无法保证。为每个线程人手分配一个对象的工作需要在应用层面保证,ThreadLocal只是起到了简单容器的作用。


public class TestThreadLocal implements Runnable{
    // SimpleDateFormat 不是线程安全的,所以每个线程都要有自己独立的副本
    private static final ThreadLocal<SimpleDateFormat> formatter = ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyyMMdd HHmm"));
    private static int k = 0;

    
    public static void main(String[] args) throws InterruptedException {
        TestThreadLocal obj = new TestThreadLocal();
        for(int i=0 ; i<10; i++){
            Thread t = new Thread(obj, ""+i);
            Thread.sleep(new Random().nextInt(1000));
            t.start();
        }
        System.out.println(k);
    }

    @Override
    public void run() {
        System.out.println("Thread Name= "+Thread.currentThread().getName()+" default Formatter = "+formatter.get().toPattern());
        try {
            Thread.sleep(new Random().nextInt(1000));
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        k++;
        //formatter pattern is changed here by thread, but it won't reflect to other threads
        formatter.set(new SimpleDateFormat());

        System.out.println("Thread Name= "+Thread.currentThread().getName()+" formatter = "+formatter.get().toPattern());
    }

}

 

 

k是共享的,formatter 是线程局部变量

 



public class Thread implements Runnable {

ThreadLocal.ThreadLocalMap threadLocals = null;
}

 public void set(T value) {
        Thread t = Thread.currentThread();
        ThreadLocalMap map = getMap(t); //1
        if (map != null)
            map.set(this, value);
        else
            createMap(t, value);
    }
    ThreadLocalMap getMap(Thread t) {
        return t.threadLocals;
    }

 

由第一句可以看看出,每个线程对应一个ThreadLocalMap,ThreadLocalMap是每个线程中的局部变量,根据传入的ThreadLocal就可以保存多个线程局部变量。

 

5.Synchronized 和 ReenTrantLock 的对比

1. 两者都是可重入锁

2. synchronized 依赖于 JVM 而 ReenTrantLock 依赖于 API

3.相比synchronized,ReenTrantLock增加了一些高级功能。1.无等待的tryLock,限时等待tryLock 并且可以响应中断2.lockInterruptibly()等待可响应中断 而synchronized等待不可中断3.可实现公平锁4.可实现选择性通知(锁可以绑定多个条件)

4.使用ReentrantLock一定要在程序的最后释放锁,一般写在finally里面,否则程序出现异常,锁就永远无法释放了。而JVM总会在最后自动释放synchronized的锁,比如抛出异常,或者return。

ReenTrantLock 提供了丰富的锁控制功能,如五等待的tryLock(),loclInterruptibly。在锁竞争激烈的情况下,这些灵活的控制功能有助于应用层合理的任务分配避免锁竞争。

 

 

ReentrantReadWriteLock(读写锁)

ReadWriteLock是JDK5开始提供的读写分离锁。读写分离开有效的帮助减少锁的竞争,以提升系统性能。用锁分离的机制避免多个读操作线程之间的等待。

 

读写锁的访问约束:

  • 读-读不互斥:读读之间不阻塞
  • 读-写互斥:读堵塞写,写也阻塞读
  • 写-写互斥:写写阻塞

如果在一个系统中读的操作次数远远大于写操作,那么读写锁就可以发挥明显的作用,提升系统性能。

 

 

CopyOnWriteArrayList 

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

这和我们之前在多线程章节讲过 ReentrantReadWriteLock 读写锁的思想非常类似,也就是读读共享、写写互斥、读写互斥、写读互斥。

JDK中提供了 CopyOnWriteArrayList 类比相比于在读写锁的思想又更进一步。为了将读取的性能发挥到极致,CopyOnWriteArrayList 读取是完全不用加锁的,并且更厉害的是:写入也不会阻塞读取操作。只有写入和写入之间需要进行同步等待。这样一来,读操作的性能就会大幅度提升。写入需要加锁,并对内部数组进行完整复制,添加新数组,然后用新数组替换老数组,整个过程不会影响读取,由于内部数组是volatile的,所以修改立即可见。

 

读写分离

写操作在一个复制的数组上进行,读操作还是在原始数组中进行,读写分离,互不影响。

写操作需要加锁,防止并发写入时导致写入数据丢失。读不用加锁。

写操作结束之后需要把原始数组指向新的复制数组

 

CopyOnWriteArrayList 在写操作的同时允许读操作,大大提高了读操作的性能,因此很适合读多写少的应用场景。

但是 CopyOnWriteArrayList 有其缺陷:

  • 内存占用:在写操作时需要复制一个新的数组,使得内存占用为原来的两倍左右;
  • 数据不一致:读操作不能读取实时性的数据,因为部分写操作的数据还未同步到读数组中。

所以 CopyOnWriteArrayList 不适合内存敏感以及对实时性要求很高的场景。

 

 

6.线程之间的通信机制

 

1.利用可见性

volatile的可见性保证了A线程对标志量的修改,其他线程一定能立即感知到,所以可以实现线程间通信。由happens-before规则知道synchronized也具有可见性,所以也能进行线程间通信。

 

2.等待通知机制

细节:

1.使用wait(),notify(),notifyAll()时需要先对调用对象加锁

2.使用wai()方法后,线程释放锁,线程状态由RUNNING变为WAITING,并将当前线程防止到对象的等待队列。

3.notify()或notifyAll()方法调用后,等待线程不会从wait()处直接返回,需要调用notify()的线程释放锁后才有机会返回。

4.notify()方法将等待队列中的一个等待线程从等待队列移动到同步队列中,被移动的线程状态从WAITING变为BLOCKING。

 

等待通知的经典范式:

 

等待通知的经典范式:
等待方:
synchronized(对象)
{
while(条件不满足)
{
   对象.wait();
}
处理逻辑
}
通知方:
synchronized(对象)
{
改变条件
对象.notifyAll();
}

 

Thread.join()的使用,当前线程A调用threadB.join(),表示等待线程B终止后才从join()处返回。

 

JDK中的源码:


public final synchronized void join() throws InterruptedException{

//条件不满足,继续等待
while(isAlive()))
{
//wail(0)表示永远等待下去
wait(0);
}
//条件符合,方法返回
}

 

和等待/通知经典范式一致,即加锁,循环,处理逻辑三个步骤

 

Condition是在java 1.5中出现的,它用来替代传统的Object的wait()/notify()实现线程间的协作,它的使用依赖于 Lock,Condition、Lock 和 Thread 三者之间的关系如下图所示。相比使用Object的wait()/notify(),使用Condition的await()/signal()这种方式能够更加安全和高效地实现线程间协作。Condition是个接口,基本的方法就是await()和signal()方法。Condition依赖于Lock接口,生成一个Condition的基本代码是lock.newCondition() 。 必须要注意的是,Condition 的 await()/signal() 使用都必须在lock保护之内,也就是说,必须在lock.lock()和lock.unlock之间才可以使用。事实上,Conditon的await()/signal() 与 Object的wait()/notify() 有着天然的对应关系:

Conditon中的await()对应Object的wait();

Condition中的signal()对应Object的notify();

Condition中的signalAll()对应Object的notifyAll()

使用Condition实现生产者和消费者,可以在资源满足时精确唤醒另一方。

BlockingQueue的两个实现ArrayBlockingQueue和LinkedBlockingQueue的阻塞方法take()和put()底层就是通过Condition的这些方法实现,

 

 

7JUC并发包

 

1.CountDownLatch(倒计时器)

用途:允许一个或多个线程等待其他线程完成操作。譬如火箭发射,启动十个线程,每个线程运行结束,倒计时器减一,减到零时主线程才可以继续执行。

用法:可以用来实现join()的功能,并且比join()的功能更多,它的构造函数接收一个int类型参数作为计数器,每次调用countDown()方法时,计数器减一直到减为0,线程从await()处,返回。一个线程调用countDown()。

 

 

2.CyclicBarrier

按照字面意思就是可以循环(Cyclic)使用的屏障(Barrier),它所做的事情就是,让一组线程到达一个屏障(同步点)时被阻塞,直到最后一个线程到达屏障时,屏障才会开门,所有被屏障拦截的线程才会继续运行。

用法:默认的构造参数是CylicBarrier(int parties),其参数表示屏障拦截的线程数量,每个线程调用await方法告诉CyclicBarier我已经到达了屏障,然后当前线程被阻塞,直到最后一个线程到达屏障。另一种构造函数CylicBarrier(int parties, Runnable barrierAction)用于当所有线程到达了屏障时执行的动作。await()方法会抛出两种异常,InterruptedException,就是被中断异常,另一个是BrokenBarrierException,可能是系统无法等待所有线程到期了。

 

 

CountDownLatch与CyclicBarrier对比

 


public class TestCyclicBarrier {

    public static class worker implements Runnable{

        private int id;
        private CyclicBarrier cyclicBarrier;

        public worker(int id,CyclicBarrier cyclicBarrier) {
            this.id = id;
            this.cyclicBarrier = cyclicBarrier;
        }

        @Override
        public void run() {
            System.out.println("worder"+id+" start first work");

            System.out.println("worder"+id+" finish first work");
            try {
                Thread.sleep(3000);
            } catch (InterruptedException e) {

            }
            try {
                cyclicBarrier.await();

                System.out.println("worder"+id+" start second work");

                System.out.println("worder"+id+" finish second work");
                try {
                    Thread.sleep(3000);
                } catch (InterruptedException e) {

                }
            } catch (InterruptedException e) {
                e.printStackTrace();
            } catch (BrokenBarrierException e) {
                e.printStackTrace();
            }

        }
    }


    static CyclicBarrier cyclicBarrier = new CyclicBarrier(5, new Runnable() {
        @Override
        public void run() {

            System.out.println("工人工作结束,工人数量:"+cyclicBarrier.getParties());

        }
    });

    public static void main(String[] args) throws InterruptedException {

        Thread []threads = new Thread[5];
        for(int i=0;i<5;i++)
        {
            threads[i] =new Thread(new worker(i,cyclicBarrier));
            threads[i].start();
        }
        for(int i=0;i<5;i++)
        {
            threads[i].join();
        }
        System.out.println("第一波工人工作结束===========");
        for(int i=0;i<5;i++)
        {
            threads[i] =new Thread(new worker(i,cyclicBarrier));
            threads[i].start();
        }
        for(int i=0;i<5;i++)
        {
            threads[i].join();
        }
    }
}

 

3.线程阻塞工具类:LockSupport

与Object()相比,它不需要先获取到某个对象的锁,也不会抛出InterruptedException异常。

用法:静态方法park()可以阻塞当前线程,可以使用park(object)指定阻塞对象便于使用jstack观察,类似还有parkNanos()、parkUntil()等,实现了一个限时的等待。LockSuppor()类采取了信号量机制,每一次unpark()将许可变为可用,park()消费这次许可,如果许可不可用就会阻塞,它只有一个许可。即使unpark()操作发生在park()之前,它的下一次park()操作将立即返回()。

作用:可以完全替代suspend()和resume()

中断:support()后会进入WAITING状态,这个是个例外,被中断时不会抛出异常,而是默默返回,但可以从 Thread.interrupted()获得被设置的中断标记。

 


public class LockSupportDemo {

     static Object u = new Object();
    static  ChangeObjectThread  t1 = new ChangeObjectThread("t1");
    static  ChangeObjectThread  t2 = new ChangeObjectThread("t2");

    public static class ChangeObjectThread extends Thread{
        public ChangeObjectThread(String name) {
            super(name);
        }

        @Override
        public void run() {
            synchronized (u){
                System.out.println("in "+getName());
                LockSupport.park(this);
             //   if(Thread.currentThread().isInterrupted())
                if(Thread.interrupted())
                {
                    System.out.println(getName()+" 被中断了");
                }
                System.out.println(getName()+"执行结束");
            }
        }
    }

    public static void main(String[] args) throws InterruptedException {
        t1.start();
        Thread.sleep(100);
        t2.start();
        t1.interrupt();
        LockSupport.unpark(t2);
        t1.join();
        t2.join();

    }
}

 

4.控制并发线程数的Semaphore

Semaphore(信号量)用来控制同时访问特定资源的线程数量。

构造信号量对象时,必须指定信号量的准入数,即同时可以申请多少个许可,当每个线程只申请一个许可时,就相当于制定了同时有多少个线程可以访问某一个资源。

 


public class SemaphoreDemo implements Runnable {

    final Semaphore semp = new Semaphore(5);
    @Override
    public void run() {
        try {
            semp.acquire();
            Thread.sleep(2000);
            System.out.println(Thread.currentThread().getId()+":done!");
            semp.release();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

    }

    public static void main(String[] args) {
        ExecutorService exec = Executors.newFixedThreadPool(20);
        final SemaphoreDemo  semaphoreDemo= new SemaphoreDemo();
        for (int i = 0; i < 20; i++) {
            exec.submit(semaphoreDemo);
        }
    }
}

 

以5个线程一组为单位,依次输出。

 

 

 

 

8.线程池

  • 降低资源消耗。 通过重复利用已创建的线程降低线程创建和销毁造成的消耗。
  • 提高响应速度。 当任务到达时,任务可以不需要的等到线程创建就能立即执行。
  • 提高线程的可管理性。 线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一的分配,调优和监控。

 

Runnable接口和Callable接口

 Runnable 接口不会返回结果

Callable 接口可以返回结果。

 

execute()方法和submit()方法的区别是什么呢?

1)execute() 方法用于提交不需要返回值的任务,所以无法判断任务是否被线程池执行成功与否;

2)submit() 方法用于提交需要返回值的任务。线程池会返回一个Future类型的对象,通过这个Future对象可以判断任务是否执行成功,并且可以通过future的get()方法来获取返回值,get()方法会阻塞当前线程直到任务完成,而使用 get(long timeout,TimeUnit unit)方法则会阻塞当前线程一段时间后立即返回,这时候有可能任务没有执行完。

 

线程池的工作原理

ThreadPoolExecutor执行execute方法分下面4种情况。

1)如果当前运行的线程少于corePoolSize,则创建新线程来执行任务(注意,执行这一步骤

需要获取全局锁)。

2)如果运行的线程等于或多于corePoolSize,则将任务加入BlockingQueue。

3)如果无法将任务加入BlockingQueue(队列已满),则创建新的线程来处理任务(注意,执

行这一步骤需要获取全局锁)。

4)如果创建新线程将使当前运行的线程超出maximumPoolSize,任务将被拒绝,并调用

RejectedExecutionHandler.rejectedExecution()方法。

ThreadPoolExecutor采取上述步骤的总体设计思路,是为了在执行execute()方法时,尽可能

地避免获取全局锁(那将会是一个严重的可伸缩瓶颈)。在ThreadPoolExecutor完成预热之后

(当前运行的线程数大于等于corePoolSize),几乎所有的execute()方法调用都是执行步骤2,而

步骤2不需要获取全局锁。

线程Worker执行完任务后会反复从队列中获取任务来执行

 

 

 

9.多线程面试题

 

1.如何停止一个线程

 

1.使用volatile变量终止正常运行的线程

  public volatile boolean exit = false; 
    public void run() 
    { 
        while (!exit); 
    } 

 

 

2.组合使用interrupt方法与interruptted/isinterrupted方法终止正在运行的线程

while(!isInterrupted()){……}

注意:在Thread类中有两个方法可以判断线程是否通过interrupt方法被终止。一个是静态的方法interrupted(),一个是非静态的方 法isInterrupted(),这两个方法的区别是interrupted用来判断当前线是否被中断,而isInterrupted可以用来判断其他 线程是否被中断。因此,while (!isInterrupted())也可以换成while (!Thread.interrupted())。 

3.使用interrupt方法终止 正在阻塞中的 线程。比如sleep状态的线程会抛出InterruptedException

 

2.何为线程安全的类?

在线程安全性的定义中,最核心的概念就是 正确性。当多个线程访问某个类时,不管运行时环境采用何种调度方式或者这些线程将如何交替执行,并且在主调代码中不需要任何额外的同步或协同,这个类都能表现出正确的行为,那么这个类就是线程安全的。

 

为什么线程通信的方法wait(), notify()和notifyAll()被定义在Object类里?

Object lock = new Object();
synchronized (lock) {
    lock.wait();
    ...
}

 

Wait-notify机制是在获取对象锁的前提下不同线程间的通信机制。在Java中,任意对象都可以当作锁来使用,由于锁对象的任意性,所以这些通信方法需要被定义在Object类里。

 

3.如何确保线程安全?

在Java中可以有很多方法来保证线程安全,诸如:

  • 通过加锁(Lock/Synchronized)保证对临界资源的同步互斥访问;
  • 使用volatile关键字,轻量级同步机制,但不保证原子性;
  • 使用不变类(String) 和 线程安全类(原子类,并发容器,同步容器等)。

 

 

 

4.主线程等待子线程运行完毕再运行的方法

(1). Join

 

Thread提供了让一个线程等待另一个线程完成的方法 — join()方法。当在某个程序执行流程中调用其它线程的join()方法时,调用线程将被阻塞,直到被join()方法加入的join线程执行完毕为止,在继续运行。join()方法的实现原理是不停检查join线程是否存活,如果join线程存活则让当前线程永远等待。直到join线程完成后,线程的this.notifyAll()方法会被调用。

 

(2). CountDownLatch

 

Countdown Latch允许一个或多个线程等待其他线程完成操作。CountDownLatch的构造函数接收一个int类型的参数作为计数器,如果你想等待N个点完成,这里就传入N。当我们调用countDown方法时,N就会减1,await方法会阻塞当前线程,直到N变成0。这里说的N个点,可以使用N个线程,也可以是1个线程里的N个执行步骤。

 

 

如何确保N个线程可以访问N个资源同时又不导致死锁?

使用多线程的时候,一种非常简单的避免死锁的方式就是:指定获取锁的顺序,并强制线程按照指定的顺序获取锁。因此,如果所有的线程都是以同样的顺序加锁和释放锁,就不会出现死锁了。

 

 

参考:

https://blog.csdn.net/kuangsonghan/article/details/80674777

https://blog.csdn.net/antony9118/article/details/51475034 

java并发编程艺术

深入理解java虚拟机

Java高并发程序设计

  • 2
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值