这一篇说一说多线程的一些问题

1 线程,程序、进程的基本概念;以及他们之间关系是什么

线程与进程相似,但线程是一个比进程更小的执行单位

  • 一个进程在其执行的过程中可以产生多个线程
  • 与进程不同的是同类的多个线程共享同一块内存空间和一组系统资源,所以系统在产生一个线程,或是在各个线程之间作切换工作时,负担要比进程小得多,也正因为如此,线程也被称为轻量级进程

程序含有指令和数据的文件,被存储在磁盘或其他的数据存储设备中,也就是说程序是静态的代码

进程程序的一次执行过程,是系统运行程序的基本单位,因此进程是动态的。

  • 系统运行一个程序即是一个进程从创建,运行到消亡的过程。
  • 简单来说,一个进程就是一个执行中的程序,它在计算机中一个指令接着一个指令地执行着
  • 同时,每个进程还占有某些系统资源如 CPU 时间,内存空间,文件,文件,输入输出设备的使用权等等。换句话说,当程序在执行时,将会被操作系统载入内存中线程是进程划分成的更小的运行单位

线程和进程最大的不同在于基本上各进程是独立的,而各线程则不一定,因为同一进程中的线程极有可能会相互影响
从另一角度来说,进程属于操作系统的范畴,主要是同一段时间内,可以同时执行一个以上的程序,而线程则是在同一程序内几乎同时执行一个以上的程序段

2 并行和并发

并行就是同一时刻,两个线程都在执行。这就要求有两个CPU去分别执行两个线程

  • 并行就比如食堂有多个打菜的阿姨,同学们可以去多个窗口排队

并发就是同一时刻,只有一个执行,但是一个时间段内,两个线程都执行了

  • 并发的实现依赖于CPU切换线程,因为切换的时间特别短,所以基本对于用户是无感知的
  • 并发类似与食堂只有一个打菜的阿姨,同学们只能去一个窗口排队

3 线程的几种状态

线程在一定条件下,状态会发生变化。线程一共有以下几种状态:
在这里插入图片描述
图例说明:

  1. 新建状态(New):新创建了一个线程对象。
  2. 就绪状态(Runnable):线程对象创建后,其他线程调用了该对象的start()方法。该状态的线程位于“可运行线程池”中,变得可运行,只等待获取CPU的使用权。即在就绪状态的进程除CPU之外,其它的运行所需资源都已全部获得。
  3. 运行状态(Running):就绪状态的线程获取了CPU,执行程序代码。
  4. 阻塞状态(Blocked):阻塞状态是线程因为某种原因放弃CPU使用权,暂时停止运行。直到线程进入就绪状态,才有机会转到运行状态。
    阻塞的情况分三种
    • 等待阻塞:运行的线程执行wait()方法,该线程会释放占用的所有资源,JVM会把该线程放入“等待池”中。进入这个状态后,是不能自动唤醒的,必须依靠其他线程调用notify()或notifyAll()方法才能被唤醒
    • 同步阻塞运行的线程在获取对象的同步锁时,若该同步锁被别的线程占用,则JVM会把该线程放入“锁池”中。
    • 其他阻塞:运行的线程执行sleep()或join()方法,或者发出了I/O请求时,JVM会把该线程置为阻塞状态。当sleep()状态超时、join()等待线程终止或者超时、或者I/O处理完毕时,线程重新转入就绪状态
  5. 死亡状态(Dead)线程执行完了或者因异常退出了run()方法,该线程结束生命周期。

4 线程中常用的调度方法

在这里插入图片描述

4.1 线程等待

Object类中的线程等待方法

  1. wait():当一个线程A调用一个共享变量的 wait()方法时, 线程A会被阻塞挂起, 发生下面几种情况会被唤醒

    • (1) 线程A调用了共享对象 notify()或者 notifyAll()方法
    • (2)其他线程调用了线程A的 interrupt() 方法,线程A抛出InterruptedException异常返回。
  2. wait(long timeout) :如果线程A调用共享对象的wait(long timeout)方法后,没有在指定的 timeout ms时间内被其它线程唤醒,那么这个方法还是会因为超时而返回(唤醒)。

  3. wait(long timeout, int nanos),其内部调用的是 wait(long timout)函数。

Thread类提供的:

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

4.2 线程唤醒

notify() : 一个线程A调用共享对象的 notify() 方法后,会唤醒一个在这个共享变量上调用 wait 系列方法后被挂起的线程一个共享变量上可能会有多个线程在等待,具体唤醒哪个等待的线程是随机的

notifyAll():notifyAll()方法会唤醒所有在该共享变量上由于调用 wait 系列方法而被挂起的线程

4.3 线程休眠

sleep(long millis) :Thread类中的静态方法,当一个执行中的线程A调用了Thread 的sleep方法后,线程A会暂时让出指定时间的执行权,但是线程A所拥有的监视器资源,比如锁还是持有不让出的。指定的睡眠时间到了后该函数会正常返回,接着参与 CPU 的调度,获取到 CPU 资源后就可以继续运行。

4.4 让出优先权

yield():Thread类中的静态方法,当一个线程调用 yield 方法时,实际就是在暗示线程调度器当前线程请求让出自己的CPU但是线程调度器可以无条件忽略这个暗示

4.5 线程中断

设置线程的中断标志并不能直接终止该线程的执行,而是被中断的线程根据中断状态自行处理

  1. void interrupt()中断线程,例如,当线程A运行时,线程B可以调用钱程interrupt() 方法来设置线程的中断标志为true 并立即返回。设置标志仅仅是设置标志, 线程A实际并没有被中断, 会继续往下执行。
  2. boolean isInterrupted()方法: 检测当前线程是否被中断
  3. boolean interrupted()方法: 检测当前线程是否被中断,如果发现当前线程被中断,则会清除中断标志

5 内存泄漏问题

5.1 什么是内存泄露

对于应用程序来说,当对象已经不再被使用,但是Java的垃圾回收器不能回收它们的时候,就产生了内存泄露
理解:

未引用对象将会被垃圾回收器回收,而引用对象却不会
未引用对象很显然是无用的对象。然而,无用的对象并不都是未引用对象有一些无用对象也有可能是引用对象,这部分对象正是内存泄露的来源

5.2 为什么内存泄露会发生

为什么会发生内存泄露,举例如下:

  1. 对象A引用对象B,A的生命周期(t1-t4)比B的生命周期(t2-t3)要长
  2. 当B在程序中不再被使用的时候,A仍然引用着B。在这种情况下,垃圾回收器是不会回收B对象的,这就可能造成了内存不足问题,
  3. 因为A可能不止引用着B对象,还可能引用其它生命周期比A短的对象,这就造成了大量无用对象不能被回收,且占据了昂贵的内存资源。
  4. 同样的,B对象也可能引用着一大堆对象,这些被B对象引用着的对象也不能被垃圾回收器回收,所有的这些无用对象消耗了大量内存资源。

5.3 怎样阻止内存泄露

1.使用List、Map等集合时,在使用完成后赋值为null

2.使用大对象时,在用完后赋值为null

3.目前已知的jdk1.6的substring()方法会导致内存泄露

4.避免一些死循环等重复创建或对集合添加元素,撑爆内存

5.简洁数据结构、少用静态集合

6.及时的关闭打开的文件,socket句柄等

7.多关注事件监听(listeners)和回调(callbacks),比如注册了一个listener,当它不再被使用的时候,忘了注销该listener,可能就会产生内存泄露

6 多线程

6.1 JAVA 线程实现/创建方式

6.1.1 继承 Thread 类

Thread 类本质上是实现了 Runnable 接口的一个实例,代表一个线程的实例

启动线程的唯一方法就是通过 Thread 类的 start()实例方法。start()方法是一个 native 方法,它将启动一个新线程,并执行run()方

public class MyThread extends Thread {
  public void run() {
  System.out.println("MyThread.run()");
  }
}
MyThread myThread1 = new MyThread();
myThread1.start();

6.1.2 实现 Runnable 接口

如果自己的类已经 extends 另一个类,就无法直接 extends Thread,此时,可以实现一个 Runnable接口。

public class MyThread extends OtherClass implements Runnable {  
	public void run() { 
   		System.out.println("MyThread.run()"); 
  	} 
}
//启动 MyThread,需要首先实例化一个 Thread,并传入自己的 MyThread 实例:
MyThread myThread = new MyThread();
Thread thread = new Thread(myThread);
thread.start();
//事实上,当传入一个 Runnable target 参数给 Thread 后,Thread 的 run()方法就会调用target.run()
public void run() {
  if (target != null) {
  	target.run();
  }
}

相比继承Thread类,实现Runnable接口的好处

  • 避免了 Java 单继承的局限性

  • 适合多个相同程序的代码去处理同一个资源的情况,把线程和程序的代码、数据有效分离,较好的体现了面向对象的设计思想

6.1.3 ExecutorService、Callable、Future 有返回值线程

有返回值的任务必须实现 Callable 接口,类似的,无返回值的任务必须 Runnable 接口

执行 Callable任务后,可以获取一个 Future 的对象,在该对象上调用 get 就可以获取到 Callable 任务返回的 Object了,再结合线程池接口 ExecutorService 就可以实现传说中有返回结果的多线程了。

//创建一个线程池
ExecutorService pool = Executors.newFixedThreadPool(taskSize);
// 创建多个有返回值的任务
List<Future> list = new ArrayList<Future>();
for (int i = 0; i < taskSize; i++) {
	Callable c = new MyCallable(i + " ");
	// 执行任务并获取 Future 对象
	Future f = pool.submit(c);
	list.add(f);
}
// 关闭线程池
pool.shutdown();
// 获取所有并发任务的运行结果
for (Future f : list) {
	// 从 Future 对象上获取任务的返回值,并输出到控制台
	System.out.println("res:" + f.get().toString());
}

6.2 线程池的优点

与每次需要时都创建线程相比,线程池可以降低创建线程的开销,这也是因为线程池在线程执行结束后进行的是回收操作,而不是真正的销毁线程

1、线程是稀缺资源,使用线程池可以减少创建和销毁线程的次数,每个工作线程都可以重复使用
2、可以根据系统的承受能力,调整线程池中工作线程的数量,防止因为消耗过多内存导致服务器崩溃

6.3 线程池的创建

java中作为线程池Executor底层实现类的ThredPoolExecutor的构造函数

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

corePoolSize:线程池核心线程数量

maximumPoolSize:线程池最大线程数量,要注意的是当核心线程满且阻塞队列也满时才会判断当前线程数是否小于最大线程数,并决定是否创建新线程。

keepAliverTime:当活跃线程数大于核心线程数时,空闲的多余线程最大存活时间

unit:存活时间的单位

workQueue:存放任务的队列,当线程数目超过核心线程数时用于保存任务的队列;主要有3种类型的BlockingQueue可供选择:无界队列,有界队列和同步移交

handler:超出线程范围和队列容量的任务的处理程序

可选择的阻塞队列BlockingQueue

无界队列

队列大小无限制,常用的为无界的LinkedBlockingQueue,使用该队列做为阻塞队列时要尤其当心,当任务耗时较长时可能会导致大量新任务在队列中堆积最终导致OOM

有界队列

常用的有两类,

  1. 一类是遵循FIFO原则的队列如ArrayBlockingQueue与有界的LinkedBlockingQueue,
  2. 另一类是优先级队列如PriorityBlockingQueue。PriorityBlockingQueue中的优先级由任务的Comparator决定。

使用有界队列时队列大小需和线程池大小互相配合线程池较小有界队列较大时可减少内存消耗,降低cpu使用率和上下文切换,但是可能会限制系统吞吐量

同步移交

不希望任务在队列中等待而是希望将任务直接移交给工作线程,可使用SynchronousQueue作为等待队列。

SynchronousQueue不是一个真正的队列,而是一种线程之间移交的机制

要将一个元素放入SynchronousQueue中,必须有另一个线程正在等待接收这个元素。

只有在使用无界线程池或者有饱和策略时才建议使用该队列。

6.4 线程池的处理流程

交一个任务到线程池中,线程池的处理流程如下:
1、判断线程池里的核心线程是否都在执行任务,如果不是(核心线程空闲或者还有核心线程没有被创建)则创建一个新的工作线程来执行任务。如果核心线程都在执行任务,则进入下个流程。

2、线程池判断工作队列是否已满,如果工作队列没有满,则将新提交的任务存储在这个工作队列里。如果工作队列满了,则进入下个流程。

3、判断线程池里的线程是否都处于工作状态,如果没有,则创建一个新的工作线程来执行任务。如果已经满了,则交给饱和策略来处理这个任务。
在这里插入图片描述
说明:

  1. 创建的线程池具体配置为:核心线程数量为5个;全部线程数量为10个;工作队列的长度为5。
  2. 我们通过queue.size()的方法来获取工作队列中的任务数。
  3. 运行原理:
    刚开始都是在创建新的线程,达到核心线程数量5个后,新的任务进来后不再创建新的线程,而是将任务加入工作队列
    任务队列到达上线5个后,新的任务又会创建新的普通线程,直到达到线程池最大的线程数量10个
    后面的任务则根据配置的饱和策略来处理。我们这里没有具体配置,使用的是默认的配置AbortPolicy:直接抛出异常

6.5 RejetedExecutionHandler:饱和策略

当队列和线程池都满了,说明线程池处于饱和状态,那么必须对新提交的任务采用一种特殊的策略来进行处理。

这个策略默认配置是AbortPolicy,表示无法处理新的任务而抛出异常。JAVA提供了4中策略:
1、AbortPolicy:直接抛出异常
2、CallerRunsPolicy:只用调用所在的线程运行任务
3、DiscardOldestPolicy:丢弃队列里最近的一个任务,并执行当前任务。
4、DiscardPolicy:不处理,丢弃掉。

6.5.1 AbortPolicy中止策略

该策略是默认饱和策略

public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
     throw new RejectedExecutionException("Task " + r.toString() +
                        " rejected from " +
                        e.toString());
   }

该策略时在饱和时会抛出RejectedExecutionException(继承自RuntimeException),调用者可捕获该异常自行处理。

6.5.2 DiscardPolicy抛弃策略

不做任何处理直接抛弃任务

6.5.3 DiscardOldestPolicy抛弃旧任务策略

先将阻塞队列中的头元素出队抛弃,再尝试提交任务

如果此时阻塞队列使用
PriorityBlockingQueue优先级队列,将会导致优先级最高的任务被抛弃,因此不建议将该种策略配合优先级队列使用

6.5.4 CallerRunsPolicy调用者运行

不抛弃任务也不抛出异常,直接运行任务的run方法,换言之将任务回退给调用者来直接运行

使用该策略时线程池饱和后将由调用线程池的主线程自己来执行任务,因此在执行任务的这段时间里主线程无法再提交新任务,从而使线程池中工作线程有时间将正在处理的任务处理完成。

6.6 java提供的四种常用线程池

在newCachedThreadPool中如果线程池长度超过处理需要,可灵活回收空闲线程,若无可回收,则新建线程

Java 里面线程池的顶级接口是 Executor,但是严格意义上讲 Executor 并不是一个线程池,而只是一个执行线程的工具真正的线程池接口是 ExecutorService

6.6.1 newCachedThreadPool

  public static ExecutorService newCachedThreadPool() {
   return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
                  60L, TimeUnit.SECONDS,
                  new SynchronousQueue<Runnable>());
 }

创建一个可根据需要创建新线程的线程池,但是在以前构造的线程可用时将重用它们。

对于执行很多短期异步任务的程序而言,这些线程池通常可提高程序性能。

调用 execute 将重用以前构造的线程(如果线程可用)。
如果现有线程没有可用的,则创建一个新线程并添加到池中。

终止并从缓存中移除那些已有 60 秒钟未被使用的线程。因此,长时间保持空闲的线程池不会使用任何资源。

6.6.2 newFixedThreadPool

public static ExecutorService newFixedThreadPool(int nThreads) {
   return new ThreadPoolExecutor(nThreads, nThreads,
                  0L, TimeUnit.MILLISECONDS,
                  new LinkedBlockingQueue<Runnable>());
 }

创建一个定长线程池,可控制线程最大并发数,超出的线程会在队列中等待。

使用固定大小的线程池并使用无限大的队列

创建一个可重用固定线程数的线程池,以共享的无界队列方式来运行这些线程。

在任意点,在大多数n Threads 线程会处于处理任务的活动状态。
如果在所有线程处于活动状态时提交附加任务,则在有可用线程之前,附加任务将在队列中等待。
如果在关闭前的执行期间由于失败而导致任何线程终止,那么一个新线程将代替它执行后续的任务(如果需要)。在某个线程被显式地关闭之前,池中的线程将一直存在。

6.6.3 newScheduledThreadPool

创建一个线程池,它可安排在给定延迟后运行命令或者定期地执行。

创建一个定长线程池,支持定时及周期性任务执行。

 public static ScheduledExecutorService newScheduledThreadPool(int
corePoolSize) {
   return new ScheduledThreadPoolExecutor(corePoolSize);
 }

6.6.4 newSingleThreadExecutor

Executors.newSingleThreadExecutor()返回一个线程池(这个线程池只有一个线程),这个线程池可以在线程死后(或发生异常时)重新启动一个线程来替代原来的线程继续执行下去!

7 volatile

7.1 内存模型的相关概念

任何时候对数据的操作都要通过和内存的交互来进行,会大大降低指令执行的速度。因此在CPU里面就有了高速缓存。

当程序在运行过程中,会将运算需要的数据从主存复制一份到CPU的高速缓存当中,那么CPU进行计算时就可以直接从它的高速缓存读取数据和向其中写入数据,当运算结束之后,再将高速缓存中的数据刷新到主存当中。

比如:i = i + 1;这段代码

  1. 当线程执行这个语句时,会先从主存当中读取i的值,然后复制一份到高速缓存当中,然后CPU执行指令对i进行加1操作,然后将数据写入高速缓存,最后将高速缓存中i最新的值刷新到主存当中。
  2. 这个代码在单线程中运行是没有任何问题的,但是在多线程中运行就会有问题
  3. 多核CPU中,每条线程可能运行于不同的CPU中,因此每个线程运行时有自己的高速缓存
  4. 如果一个变量在多个CPU中都存在缓存(一般在多线程编程时才会出现),那么就可能存在缓存不一致的问题

解决缓存不一致性问题

通常来说有以下2种解决方法:

  1. 通过在总线加LOCK#锁的方式

    但是由于在锁住总线期间,其他CPU无法访问内存,导致效率低下

  2. 通过缓存一致性协议

    缓存一致性协议。最出名的就是Intel 的MESI协议,MESI协议保证了每个缓存中使用的共享变量的副本是一致的

7.2 并发编程中的三个概念

并发编程中,我们通常会遇到以下三个问题:原子性问题,可见性问题,有序性问题
要想并发程序正确地执行,必须要保证原子性、可见性以及有序性。只要有一个没有被保证,就有可能会导致程序运行不正确。

原子性

原子性:即一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行

可见性

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

有序性

有序性:即程序执行的顺序按照代码的先后顺序执行。

7.3 Java内存模型

Java内存模型,它定义了程序中变量的访问规则,往大一点说是定义了程序执行的次序

在java内存模型中,也会存在缓存一致性问题和指令重排序的问题

Java语言 本身对 原子性、可见性以及有序性提供了哪些保证,如下:

原子性的保证

在Java中,对基本数据类型的变量的读取和赋值操作是原子性操作,即这些操作是不可被中断的,要么执行,要么不执行。

只有简单的读取、赋值(而且必须是将数字赋值给某个变量,变量之间的相互赋值不是原子操作)才是原子操作

Java内存模型只保证了基本读取和赋值是原子性操作

  • 如果要实现更大范围操作的原子性,可以通过synchronized和Lock来实现
  • 由于synchronized和Lock能够保证任一时刻只有一个线程执行该代码块,那么自然就不存在原子性问题了,从而保证了原子性。

可见性的保证

Java提供了volatile关键字来保证可见性

当一个共享变量被volatile修饰时,它会保证修改的值会立即被更新到主存当有其他线程需要读取时,它会去内存中读取新值

普通的共享变量不能保证可见性,因为普通共享变量被修改之后,什么时候被写入主存是不确定的,当其他线程去读取时,此时内存中可能还是原来的旧值,因此无法保证可见性。

另外,通过synchronized和Lock也能够保证可见性,synchronized和Lock能保证同一时刻只有一个线程获取锁然后执行同步代码,并且在释放锁之前会将对变量的修改刷新到主存当中。因此可以保证可见性

有序性的保证

Java内存模型中,允许编译器和处理器对指令进行重排序,但是重排序过程不会影响到单线程程序的执行,却会影响到多线程并发执行的正确性

Java里面,可以通过volatile关键字来保证一定的“有序性”
通过synchronized和Lock来保证有序性,很显然,synchronized和Lock保证每个时刻是有一个线程执行同步代码,相当于是让线程顺序执行同步代码,自然就保证了有序性。

7.4 volatile关键字的两层语义

一旦一个共享变量(类的成员变量、类的静态成员变量)被volatile修饰之后,那么就具备了两层语义:

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

7.5 volatile保证原子性吗

我们知道volatile关键字保证了操作的可见性,但是volatile能保证对变量的操作是原子性吗

可见性只能保证每次读取的是最新的值,但是volatile没办法保证对变量的操作的原子性

自增操作是不具备原子性的,它包括读取变量的原始值、进行加1操作、写入工作内存

在java 1.5的java.util.concurrent.atomic包下提供了一些原子操作类,即对基本数据类型的 自增(加1操作),自减(减1操作)、以及加法操作(加一个数),减法操作(减一个数)进行了封装,保证这些操作是原子性操作。atomic是利用CAS来实现原子性操作的(Compare And Swap),CAS实际上是利用处理器提供的CMPXCHG指令实现的,而处理器执行CMPXCHG指令是一个原子性操作。

7.5 volatile能保证有序性吗

提到volatile关键字能禁止指令重排序,所以volatile能在一定程度上保证有序性。
volatile关键字禁止指令重排序有两层意思

  1. 程序执行到volatile变量的读操作或者写操作时在其前面的操作的更改肯定全部已经进行,且结果已经对后面的操作可见;在其后面的操作肯定还没有进行
  2. 进行指令优化时不能将在对volatile变量访问的语句放在其后面执行,也不能把volatile变量后面的语句放到其前面执行。

举例如下:

  1. 由于flag变量为volatile变量,那么在进行指令重排序的过程的时候,不会将语句3放到语句1、语句2前面,也不会讲语句3放到语句4、语句5后面。但是要注意语句1和语句2的顺序、语句4和语句5的顺序是不作任何保证的
  2. 并且volatile关键字能保证,执行到语句3时,语句1和语句2必定是执行完毕了的,且语句1和语句2的执行结果对语句3、语句4、语句5是可见的
    //x、y为非volatile变量
    //flag为volatile变量
    x = 2;  //语句1
    y = 0;  //语句2
    flag = true; //语句3
    x = 4;   //语句4
    y = -1;  //语句5
    

7.6 使用volatile关键字的场景

synchronized关键字是防止多个线程同时执行一段代码,那么就会很影响程序执行效率,

volatile关键字在某些情况下性能要优于synchronized,但是要注意volatile关键字是无法替代synchronized关键字的,因为volatile关键字无法保证操作的原子性

通常来说,使用volatile必须具备以下2个条件:

  1. 变量的写操作不依赖于当前值
  2. 变量没有包含在具有其他变量的不变式中
  3. 这2个条件需要保证操作是原子性操作,才能保证使用volatile关键字的程序在并发时能够正确执行

7.7 volatile 关键字的作用

变量可见性、禁止重排序

8 什么叫线程安全

java中的线程安全是什么:

就是线程同步的意思,就是当一个程序对一个线程安全的方法或者语句进行访问的时候,其他的不能再对他进行操作了,必须等到这次访问结束以后才能对这个线程安全的方法进行访问

什么叫线程安全:

  1. 如果代码所在的进程中有多个线程在同时运行,而这些线程可能会同时运行这段代码。如果每次运行结果和单线程运行的结果是一样的,而且其他的变量的值也和预期的是一样的,就是线程安全的。
  2. 或者说:一个类或者程序所提供的接口对于线程来说是原子操作,或者多个线程之间的切换不会导致该接口的执行结果存在二义性,也就是说我们不用考虑同步的问题

线程安全问题都是由全局变量及静态变量引起的

  1. 若每个线程中对全局变量、静态变量只有读操作,而无写操作,一般来说,这个全局变量是线程安全的;

  2. 若有多个线程同时执行写操作,一般都需要考虑线程同步,否则就可能影响线程安全

存在竞争的线程不安全,不存在竞争的线程就是安全的

9 线程补充

9.1 终止线程 4 种方式

正常运行结束

程序运行结束,线程自动结束。

使用退出标志退出线程

一般 run()方法执行完,线程就会正常结束,然而,常常有些线程是伺服线程。它们需要长时间的运行,只有在外部某些条件满足的情况下,才能关闭这些线程。使用一个变量来控制循环,

例如:

  1. 最直接的方法就是设一个 boolean 类型的标志,并通过设置这个标志为 true 或 false 来控制 while循环是否退出
    public class ThreadSafe extends Thread {
    	public volatile boolean exit = false;
    	public void run() { 
    		while (!exit){
    		//do something
    		}
    	}
    }
    
  2. 代码说明:

    定义了一个退出标志 exit,当 exit 为 true 时,while 循环退出,exit 的默认值为 false.在定义 exit时,使用了一个 Java 关键字 volatile,这个关键字的目的是使 exit 同步,也就是说在同一时刻只能由一个线程来修改 exit 的值。

Interrupt 方法结束线程

使用 interrupt()方法来中断线程有两种情况

  1. 线程处于阻塞状态:如使用了 sleep,同步锁的 wait,socket 中的 receiver,accept 等方法时,会使线程处于阻塞状态。当调用线程的 interrupt()方法时,会抛出 InterruptException 异常。阻塞中的那个方法抛出这个异常,通过代码捕获该异常,然后 break 跳出循环状态,从而让我们有机会结束这个线程的执行。通常很多人认为只要调用 interrupt 方法线程就会结束,实际上是错的, 一定要先捕获 InterruptedException 异常之后通过 break 来跳出循环,才能正常结束 run 方法
  2. 线程未处于阻塞状态使用 isInterrupted()判断线程的中断标志来退出循环当使用interrupt()方法时,中断标志就会置 true,和使用自定义的标志来控制循环是一样的道理。

stop 方法终止线程(线程不安全)

可以直接使用 thread.stop()来强行终止线程,但是 stop 方法是很危险的,可能会产生不可预料的结果,

不安全主要是thread.stop()调用之后,创建子线程的线程就会抛出 ThreadDeatherror 的错误,并且会释放子线程所持有的所有锁

一般任何进行加锁的代码块,都是为了保护数据的一致性,如果在调用thread.stop()后导致了该线程所持有的所有锁的突然释放(不可控制),那么被保护数据就有可能呈现不一致性,其他线程在使用这些被破坏的数据时,有可能导致一些很奇怪的应用程序错误。因此,并不推荐使用 stop 方法来终止线程

9.2 sleep 与 wait 区别

一:对于 sleep()方法,我们首先要知道该方法是属于 Thread 类中的。而wait()方法,则是属于 Object类中的。

二:sleep()方法导致了程序暂停执行指定的时间,让出 cpu 该其他线程,但是他的监控状态依然保持者,当指定的时间到了又会自动恢复运行状态

三:在调用 sleep()方法的过程中,线程不会释放对象锁

四:而当调用 wait()方法的时候,线程会放弃对象锁,进入等待此对象的等待锁定池,只有针对此对象调用 notify()方法后本线程才进入对象锁定池准备获取对象锁进入运行状态

9.3 start 与 run 区别

1、start()方法来启动线程,真正实现了多线程运行。这时无需等待 run 方法体代码执行完毕,可以直接继续执行下面的代码

2、通过调用 Thread 类的 start()方法来启动一个线程, 这时此线程是处于就绪状态, 并没有运行。

3、方法 run()称为线程体,它包含了要执行的这个线程的内容,线程就进入了运行状态,开始运行run 函数当中的代码。 Run 方法运行结束, 此线程终止。然后 CPU 再调度其它线程

9.4 守护线程和守护进程

守护线程:也称“服务线程”,他是后台线程,它有一个特性,即为用户线程 提供 公共服务,在没有用户线程可服务时会自动离开
守护进程(Daemon)是运行在后台的一种特殊进程。

  1. 它独立于控制终端并且周期性地执行某种任务或等待处理某些发生的事件。
  2. 也就是说守护线程不依赖于终端,但是依赖于系统,与系统“同生共死”。
  3. 当 JVM 中所有的线程都是守护线程的时候,JVM 就可以退出了;如果还有一个或以上的非守护线程则 JVM 不会退出

10 HashMap 是否线程安全,为何不安全

它是非线程安全的,在涉及到多线程并发的情况,进行get操作有可能会引起死循环,导致CPU利用率接近100%

解决方案有Hashtable和Collections.synchronizedMap(hashMap),不过这两个方案基本上是对读写进行加锁操作一个线程在读写元素,其余线程必须等待,性能差。

11 ConcurrentHashMap

11.1 JDK1.7数据结构

jdk1.7中采用 Segment + HashEntry的方式进行实现,结构如下:
在这里插入图片描述
测试代码:


if (c * ssize < initialCapacity)
	++c;
int cap = MIN_SEGMENT_TABLE_CAPACITY;
while (cap < c)
 	cap <<= 1;

ConcurrentHashMap 初始化时,计算出 Segment 数组的大小 ssize 和每个 Segment 中 HashEntry 数组的大小 cap ,并初始化 Segment 数组的第一个元素;

  1. 其中 ssize 大小为2的幂次方,默认为16,
  2. cap大小也是2的幂次方,最小值为2,最终结果根据初始化容量 initialCapacity 进行计算
  3. 其中 Segment 在实现上继承了 ReentrantLock,这样就自带了锁的功能

11.2 JDK1.8数据结构

放弃了 Segment 臃肿的设计,取而代之的是采用 Node + CAS + Synchronized 来保证并发安全进行实现,结构如下:
在这里插入图片描述
只有在执行第一次 put 方法时才会调用 initTable() 初始化 Node 数组

put实现

当执行 put 方法插入数据时,根据key的hash值,在 Node 数组中找到相应的位置:

  1. 如果相应位置的 Node 还未初始化,则通过CAS插入相应的数据

    else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
      if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value, null)))
        break;          // no lock when adding to empty bin
    }
    
  2. 如果相应位置的 Node 不为空,且当前该节点不处于移动状态,则对该节点加 synchronized 锁;如果该节点的 hash 不小于0,则遍历链表更新节点或插入新节点

    if (fh >= 0) {
      binCount = 1;
      for (Node<K,V> e = f;; ++binCount) {
        K ek;
        if (e.hash == hash &&
         ((ek = e.key) == key ||
          (ek != null && key.equals(ek)))) {
          oldVal = e.val;
          if (!onlyIfAbsent)
            e.val = value;
          break;
       }
        Node<K,V> pred = e;
        if ((e = e.next) == null) {
          pred.next = new Node<K,V>(hash, key, value, null);
          break;
       }
     }
    }
    
  3. 如果该节点是 TreeBin 类型的节点,说明是红黑树结构,则通过 putTreeVal 方法往红黑树中插入节点;

    else if (f instanceof TreeBin) {
      Node<K,V> p;
      binCount = 2;
      if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key, value)) != null) {
        oldVal = p.val;
        if (!onlyIfAbsent)
          p.val = value;
     }
    }
    
  4. 如果 binCount 不为0,说明 put 操作对数据产生了影响,如果当前链表的个数达到8个,则通过treeifyBin 方法转化为红黑树,如果 oldVal 不为空,说明是一次更新操作,没有对元素个数产生影响,则直接返回旧值;

    if (binCount != 0) {
      if (binCount >= TREEIFY_THRESHOLD)
        treeifyBin(tab, i);
      if (oldVal != null)
        return oldVal;
      break;
    } 
    
  5. 如果插入的是一个新节点,则执行 addCount() 方法尝试更新元素个数 baseCount ;

11.3 CAS简单说明

独占锁是一种悲观锁synchronized就是一种独占锁,会导致其它所有需要锁的线程挂起,等待持有锁的线程释放锁

而另一个更加有效的锁就是乐观锁。所谓乐观锁就是,每次不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止

乐观锁用到的机制就是CAS,Compare and Swap。

CAS:Compare and Swap,是比较并交换的意思。

CAS 操作包含三个操作数 —— 内存位置(V)、预期原值(A)和新值(B)。
CAS 有效地说明了:我认为位置 V 应该包含值 A;如果包含该值,则将 B 放到这个位置否则,不要更改该位置,只告诉我这个位置现在的值即可

11.4 分析ConcurrentHashMap1.8的扩容实现

什么情况会触发扩容

当往hashMap中成功插入一个key/value节点时,有可能触发扩容动作

  1. 如果新增节点之后,所在链表的元素个数达到了阈值 8,则会调用 treeifyBin 方法把链表转换成红黑树,不过在结构转换之前,会对数组长度进行判断
  2. 新增节点之后,会调用 addCount 方法记录元素个数,并检查是否需要进行扩容,当数组元素个数达到阈值时,会触发 transfer 方法,重新调整节点的位置

11.5 HashMap的死循环

数据结构

  1. 在内部,HashMap使用一个Entry数组保存key、value数据,当一对key、value被加入时,会通过一个hash算法得到数组的下标index,
  2. 算法很简单,根据key的hash值,对数组的大小取模 hash & (length-1),并把结果插入数组该位置,如果该位置上已经有元素了,就说明存在hash冲突,这样会在index位置生成链表。
  3. 如果存在hash冲突,最惨的情况,就是所有元素都定位到同一个位置,形成一个长长的链表,这样get一个值时,最坏情况需要遍历所有节点,性能变成了O(n),所以元素的hash值算法和HashMap的初始化大小很重要。
  4. 当插入一个新的节点时,如果不存在相同的key,则会判断当前内部元素是否已经达到阈值(默认是数组大小的0.75),如果已经达到阈值,会对数组进行扩容,也会对链表中的元素进行rehash

在并发的情况,发生扩容时,可能会产生循环链表,在执行get的时候,会触发死循环,引起CPU的100%问题,所以一定要避免在并发环境下使用HashMap

11.6 ConcurrentHashMap 并发

ConcurrentHashMap,它内部细分了若干个小的 HashMap,称之为段(Segment)。默认情况下一个ConcurrentHashMap 被进一步细分为 16 个段,既就是锁的并发度。

ConcurrentHashMap 是由Segment 数组结构和HashEntry 数组结构组成

12 什么是 CAS

12.1 概念

CAS(Compare And Swap/Set)比较并交换CAS 算法的过程是这样:它包含 3 个参数 CAS(V,E,N)

  1. V 表示要更新的变量(内存值),
  2. E 表示预期值(旧的),
  3. N 表示新值。
  4. 当且仅当 V 值等于 E 值时,才会将 V的值设为 N,如果 V 值和 E 值不同,则说明已经有其他线程做了更新,则当前线程什么都不做。最后,CAS 返回当前 V 的真实值。

CAS 操作是抱着乐观的态度进行的(乐观锁),它总是认为自己可以成功完成操作。

当多个线程同时使用CAS 操作一个变量时,只有一个会胜出,并成功更新,其余均会失败。失败的线程不会被挂起,仅是被告知失败,并且允许再次尝试,当然也允许失败的线程放弃操作。

基于这样的原理,CAS 操作即使没有锁,也可以发现其他线程对当前线程的干扰,并进行恰当的处理。

12.2 原子包 java.util.concurrent.atomic(锁自旋)

JDK1.5 的原子包:java.util.concurrent.atomic 这个包里面提供了一组原子类。

  1. 其基本的特性就是在多线程环境下,当有多个线程同时执行这些类的实例包含的方法时,具有排他性,
  2. 当某个线程进入方法,执行其中的指令时,不会被其他线程打断,而别的线程就像自旋锁一样**,一直等到该方法执行完成**,才由 JVM 从等待队列中选择一个另一个线程进入,这只是一种逻辑上的理解。

12.3 ABA 问题

CAS 会导致“ABA 问题”。
CAS 算法实现一个重要前提需要取出内存中某时刻的数据,而在下时刻比较并替换,那么在这个时间差类会导致数据的变化

比如说:

  1. 一个线程 one 从内存位置 V 中取出 A,这时候另一个线程 two 也从内存中取出 A,
  2. 并且 two 进行了一些操作变成了 B,然后 two 又将 V 位置的数据变成 A,
  3. 这时候线程 one 进行 CAS 操作发现内存中仍然是 A,然后 one 操作成功。
  4. 尽管线程 one 的 CAS 操作成功,但是不代表这个过程就是没有问题
    的。

部分乐观锁的实现是通过版本号(version)的方式来解决 ABA 问题

  1. 乐观锁每次在执行数据的修改操作时,都会带上一个版本号,
  2. 一旦版本号和数据的版本号一致就可以执行修改操作并对版本号执行+1 操作,否则就执行失败。
  3. 因为每次操作的版本号都会随之增加,所以不会出现 ABA 问题,因为版本号只会增加不会减少

13 什么是 AQS(抽象的队列同步器)

AbstractQueuedSynchronizer 类如其名,抽象的队列式的同步器

AQS 定义了一套多线程访问共享资源的同步器框架,许多同步类实现都依赖于它,如常用的ReentrantLock/Semaphore/CountDownLatch。

AQS只是一个框架,具体资源的获取/释放方式交由自定义同步器去实现,AQS这里只定义了一个接口,具体资源的获取交由自定义同步器去实现了

同步器的实现是ABS核心(state资源状态计数)

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

?abc!

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值