由浅入深的线程问题

1,168 阅读14分钟

理解线程

cpu线程和操作系统线程有什么区别?

两者都叫线程是因为他们都是调度的基本单位,软件操作系统调度的基本单位是OSThread,硬件的调度基本单位是CPU中的Thread。我们常说的几核几线程,就是指CPU中的Thread

操作系统中的进程有很多,相应的线程就有很多,但CPU上的线程只有固定的几个。另外操作系统中的Thread有自己的栈空间,和同一进程中的其他线程共享地址空间。

CPU 的线程与操作系统的线程有何关系?

进程和线程有什么区别?

进程之间不共享资源,而线程之间可以共享同一个进程的资源。

那为什么这么设计呢?

因为每个进程内部是自己完整的程序逻辑,不同的程序之间就不应该共享资源(例如变量、常量等)。而同一个进程的多个不同线程,需要都能操作到这个进程的资源,程序才能正常运行。

一个线程调用两次start方法会出现什么?

Java 的线程是不允许启动两次的,第二次调用必然会抛出 IllegalThreadStateException,这是一种运行时异常,多次调用 start被认为是编程错误。在第二次调用 start() 方法的时候,线程可能处于终止或者其他(非 NEW)状态,但是不论如何,都是不可以再次启动的。

实现一个 Runnable去构建线程有什么好处?

Runnable 的好处是,不会受Java不支持类多继承的限制,重用代码实现,当我们需要重复执行相应逻辑时优点明显。而且,也能更好的与现代 Java 并发库中的Executor之类框架结合使用。

多线程安全

多线程安全的本质是什么?

线程安全的本质问题是多个线程去操作共同资源的问题。锁机制是为了解决在多线程下,对共享资源的操作进行控制。所以锁机制的本质还是对资源的控制,而非某个方法或者代码块。

synchnorized 是怎么实现多线程安全的?

线程同步存在监视器Monitor,Monitor 对象是同步的基本实现单元

synchnorized 同步方法的Monitorthis,所有被同一个synchnorized标记的同步方法,拥有同一个Monitor,它们同时都不能被同一个线程访问。如果需要多个方法允许同时被多个线程访问,那就需要用synchnorized包代码块,并准备不同的对象,这些不同的对象标记就表示不同的Monitor

Object monitor = new Object()
synchnorized(monitor){
  //...
}

synchnorized和ReentrantLock有什么区别?

synchnorized是在Java5之前的同步机制,它可以锁方法,也可以锁住代码快。当一个线程已经获取当前锁时,其他试图获取的线程只能等待或者阻塞在那里。

也正是因为synchnorized实现同步,写起来很简单,一个关键字就够了,与此同时它也失去了一定的灵活性。

ReentrantLockJava5之后的出现解决了问题,ReentrantLock,通常翻译为再入锁。

再入锁通过代码直接调用lock()方法获取,代码书写也更加灵活。与此同时,ReentrantLock 提供了很多实用的方法,能够实现很多 synchronized 无法做到的细节控制,比如可以控制 fairness,也就是公平性,或者利用定义条件等。

所谓的公平性是指在竞争场景中,当公平性为真时,会倾向于将锁赋予等待时间最久的线程。公平性是减少线程“饥饿”(个别线程长期等待锁,但始终无法获取)情况发生的一个办法。但保证公平性则会引入额外开销,自然会导致一定的吞吐量下降。

ReentrantLock 相比 synchronized,因为可以像普通对象一样使用,所以可以利用其提供的各种便利方法,进行精细的同步操作,甚至是实现 synchronized 难以表达的用例,比如,可以判断是否有线程,或者某个特定线程,在排队等待获取锁、又或者可以响应中断请求。

那synchnorized是最慢的吗

关于二者的性能,synchronizedReentrantLock的性能不能一概而论,早期版本synchronized在很多场景下性能相差较大,在后续版本进行了较多改进,在低竞争场景中表现可能优于 ReentrantLock

stop()与interrupte()

为什么 Thread的stop() 是危险的,该方法被标记为过时?

因为它会直接杀死线程,有几率让线程在某些工作运行到一半的时候被结束,从而系统被强制停留在某种中间状态,导致出现问题。

而 Thread.interrupt() 并不是直接杀死线程,而是告诉线程「外界希望你停止」,具体的结束工作由线程自己来完成,所以会更安全。

interrupte方法只是会加个标记,表示有地方中断了该线程,该线程的程序还是会正常执行。所以需要开发者去判断这个中断标记位,在判断出中断的时候做结束收尾工作。那如何判断呢,可通过Thread.isInterrupte 这个静态方法或者isInterrupte方法?

  • Thread.isInterrupte 这个静态方法会改变它自身的值(true -> false),一般情况下就只能用一次

  • isInterrupte方法不会改变值,可以多次使用。

说说对InterruptedException的理解

如果在sleep过程的时候,线程被中断了,就会抛出InterruptedException,让你捕获。这也是为了节省资源,线程都中断了,sleep()是无意义的,一般情况需要去做线程终止后的收尾工作。

Thread thread = new Thread(new Runnable() {
       @Override
       public void run() {
           try {
               Thread.sleep(2000);
           } catch (InterruptedException e) {
               e.printStackTrace();
               //... TODO收尾工作
           }
       }
   });

线程间通信

考虑这样一个场景: 有两个线程去操作一个共享资源,假设这个共享资源是一个字符串,其中线程1要去写这个字符串,它是个耗时任务,线程2要去读这个字符串,怎么保证线程2读到的是线程1写入后的值?

方法1: 使用join()方法

void runTest() {
     System.out.println("run");
     //开启一个线程去设置共享的值,假设2s后才能完成
     Thread thread1 = new Thread(new Runnable() {
         @Override
         public void run() {
             try {
                 Thread.sleep(2000);
             } catch (InterruptedException e) {
                 e.printStackTrace();
                 //... TODO收尾工作
             }
             setShareString("i am set value thread");
         }
     });
     thread1.start();
     //开启一个线程去打印共享的值,假设1s就可以读值
     Thread thread2 = new Thread(new Runnable() {
         @Override
         public void run() {
             try {
                 thread1.join();
             } catch (InterruptedException e) {
                 e.printStackTrace();
             }
             printShareString();
         }
     });
     thread2.start();
 }

**方法2: 使用wait()和notify()/notifyAll() **

在打印同步方法里面,打印前调用wait(),进入等待状态

在设置共享值同步方法里,设置完成后通知notifyAll()所有正在等待的线程:你可以继续执行下去了。

private synchronized void printShareString() {
    try {
        wait();
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
    System.out.println("print: "+ mShareString);
}

private synchronized void setShareString(String s) {
    mShareString = s;
    notifyAll();
}

wait()notify()/notifyAll() 一定是成对出现的

Java的线程池

Java 并发类库提供的线程池有哪几种? 分别有什么特点?

1.newCachedThreadPool

它是一种用来处理大量短时间工作任务的线程池。它的特点是:

它会试图缓存线程并重用,当无缓存线程可用时,就会创建新的工作线程;

如果线程闲置的时间超过一定的时间(可以根据需要设置),则被终止并移出缓存。所以即使长时间闲置,这种线程池也不会消耗什么资源。

2.newFixedThreadPool(int nThreads)

fixed 翻译过来:固定

它可以重用指定数目(nThreads)的线程,任何时候最多有nThreads 个工作线程是活动的。

如果任务数量超过了活动队列数目,将在工作队列中等待空闲线程出现。如果有工作线程退出,将会有新的工作线程被创建,以补足指定的数目 nThreads

3.newSingleThreadExecutor

它的特点在于工作线程数目被限制为 1

所以它保证了所有任务的都是被顺序执行,最多会有一个任务处于活动状态,并且不允许使用者改动线程池实例,因此可以避免其改变线程数目。

4. newSingleThreadScheduledExecutor

创建的是个ScheduledExecutorService,可以进行定时或周期性的工作调度。

5.newWorkStealingPool(int parallelism)

这是一个经常被人忽略的线程池,Java 8 才加入这个创建方法,其内部会构建ForkJoinPool,利用Work-Stealing算法,并行地处理任务,不保证处理顺序。

Android多线程

为什么 UI 线程是一个死循环但却没有把界面卡死

因为 UI线程是一个大循环,每一圈都是一次界面刷新操作,而不是对某一次界面刷新过程进行内部的死循环,所以不会卡死界面。

使用 AsyncTask 会导致内存泄露的根本原因是什么?

你可能在很多地方看到这个问题的解释:因为它是内部类,持有了外部类的引用,所以当外部类的内存资源在需要被释放时,不能被GC回收。

仔细想想,这个解释是很奇怪的。按这种解释的话,不仅仅是 AsyncTask ,只有是内部类,都会出现这个问题。

其实 AsyncTask 的内存泄露可以说不是个问题,之所以会出现内存泄露的根本原因是, AsyncTask 的后台线程在做任务时,如果这个时候去释放外部类资源,由于被 AsyncTask 持有,就会导致它不能被释放。所以本质上是因为存在线程去引用它,这个时候GC是不会认为它需要被回收。但当 AsyncTask 的后台任务做完,线程就会挂掉,之后,GC就会去回收。这个场景,和new一个匿名内部类Thread对象的是一样的。

这个后台任务一般是耗时很短的(如果耗时过长,要考虑的是 AsyncTask 是否真的合适你的场景 ?),所以 AsyncTask 的内存泄露在某种程度上说并不需要用很多网文说的弱引用等方法去特意解决。简而言之,它是会出现内存泄漏,但时间非常短,没有必要一定去解决它。

进阶Q&A

1.自旋锁的出现是为了解决什么问题?

我们知道阻塞或唤醒一个Java线程需要操作系统切换CPU状态来完成,这种状态转换需要耗费处理器时间。如果同步代码块中的内容过于简单,比状态转换消耗的时间还要短,很浪费性能。

自旋锁的出现可以解决这一场景的性能问题:

自旋锁采用让当前线程不停循环体内执行实现,当循环条件被其他线程改变时,才能进入临界区。由于自旋锁只是将当前线程不停执行循环体,不进行线程状态的改变,所以响应会更快。但当线程不停增加时,性能下降明显。

但自旋锁本身是有缺点的,它不能代替阻塞。自旋等待虽然避免了线程切换的开销,但它要占用处理器时间。如果锁被占用的时间很短,自旋等待的效果就会非常好。反之,如果锁被占用的时间很长,那么自旋的线程只会白浪费处理器资源。所以,自旋等待的时间必须要有一定的限度。

(1)在单核CPU上,自旋锁是无用,因为当自旋锁尝试获取锁不成功会一直尝试,这会一直占用CPU,其他线程不可能运行,同时由于其他线程无法运行,所以当前线程无法释放锁。

(2)自旋锁的实现原理同样也是CAS,AtomicInteger中调用unsafe进行自增操作的源码中的do-while循环就是一个自旋操作,如果修改数值失败则通过循环来执行自旋,直至修改成功。

2.CAS是做什么的

CAS(比较与交换,Compare and swap) 是一种无锁算法。无锁,即在不使用锁的情况下实现多线程之间的变量同步,它是不会通过阻塞线程来实现同步,所以也叫非阻塞同步。

CAS是乐观锁,当多个线程尝试使用CAS同时更新同一个变量时,只有其中一个线程能更新变量的值,而其它线程都失败,失败的线程并不会被挂起,而是被告知这次竞争中失败,并可以再次尝试。

乐观锁和悲观锁

根据线程同步的不同角度看。对同个数据的并发操作,悲观锁就认为自己在操作数据时一定会有别的线程来修改数据,所以为了在操作数据时保证数据不被更改,就会加锁。比如synchronized关键字和Lock的实现类都是悲观锁。

而乐观锁则不同,对同个数据的并发操作,它乐观的认为在操作数据时不会有数据来更改,它只会在更新数据的时候去判断是否有数据更改。如果这个数据没有被更新,当前线程将自己修改的数据成功写入。如果数据已经被其他线程更新,则根据不同的实现方式执行不同的操作

3.什么是锁的升级和降级

锁的升级和降级是JVM优化synchronized运行的机制,当JVM检测到不同的竞争程度,就会切到合适的锁实现。主要有三种不同的锁:偏斜锁(Biased Locking)、轻量级锁和重量级锁。

当没有竞争出现时,默认会使用偏斜锁。JVM 会利用 CAS 操作(compare and swap),在对象头上的 Mark Word 部分设置线程 ID,以表示这个对象偏向于当前线程,所以并不涉及真正的互斥锁。

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

synchronized是悲观锁,这种线程一旦得到锁,其他需要锁的线程就挂起的情况就是悲观锁。

java偏向锁,轻量级锁与重量级锁为什么会相互膨胀?

3.synorizon的底层实现

synchronized代码块是由一对儿 monitorenter/monitorexit 指令实现的,Monitor 对象是同步的基本实现单元。在 Java 6 之前,Monitor 是依赖于底层的操作系统的Mutex Lock(互斥锁)来实现的线程同步。因为需要进行用户态到内核态的切换,所以同步操作是一个无差别的重量级操作。

Monitor可以理解为一个同步工具或一种同步机制,通常被描述为一个对象。每一个Java对象就有一把看不见的锁,称为内部锁或者Monitor锁。

Monitor是线程私有的数据结构,每一个线程都有一个可用monitor record列表,同时还有一个全局的可用列表。每一个被锁住的对象都会和一个monitor关联,同时monitor中有一个Owner字段存放拥有该锁的线程的唯一标识,表示该锁被这个线程占用。

较新版本的JDK中,提供了三种不同Monitor实现。包含常说的三种不同的锁:偏斜锁(Biased Locking)、轻量级锁和重量级锁,大大改进了其性能。

偏向锁通过对比MarkWord解决加锁问题,避免执行CAS操作。而轻量级锁是通过用CAS操作自旋来解决加锁问题,避免线程阻塞和唤醒而影响性能。重量级锁是将除了拥有锁的线程以外的线程都阻塞。

4.Java对象头的MarkWord存储了什么信息

MarkWord默认存储对象的HashCode,分代年龄和锁标志位信息。但在不同锁状态下会存储不同的内容。

对象头markword.png

参考写的不错的blog:

不可不说的Java“锁”事