多线程面试指南

在这里将总结面试中和并发编程相关的常见知识点,如在第一部分中出现的这里将不进行详细阐述。面试指南中,我将用最简洁的语言描述,更多是以一种大纲的形式列出问答点,根据自己掌握的情况回答。

参考资料:

1. volatile 与 synchronized 的区别

(1)仅靠volatile不能保证线程的安全性。(原子性)

  • ① volatile 轻量级,只能修饰变量。synchronized重量级,还可修饰方法
  • ② volatile 只能保证数据的可见性,不能用来同步,因为多个线程并发访问 volatile 修饰的变量不会阻塞。

synchronized 不仅保证可见性,而且还保证原子性,因为,只有获得了锁的线程才能进入临界区,从而保证临界区中的所有语句都全部执行。多个线程争抢 synchronized 锁对象时,会出现阻塞。

(2)线程安全性

线程安全性包括两个方面,①可见性。②原子性。

从上面自增的例子中可以看出:仅仅使用 volatile 并不能保证线程安全性。而 synchronized 则可实现线程的安全性。

2. 什么是线程池?如果让你设计一个动态大小的线程池,如何设计,应该有哪些方法?线程池创建的方式?

  • 什么是线程池

    • 线程池顾名思义就是事先创建若干个可执行的线程放入一个池(容器)中,需要的时候从池中获取线程不用自行创建,使用完毕不需要销毁线程而是放回池中,从而减少创建和销毁线程对象的开销。
  • 设计一个动态大小的线程池,如何设计,应该有哪些方法

    • 一个线程池包括以下四个基本组成部分:
      • 线程管理器 (ThreadPool):用于创建并管理线程池,包括创建线程,销毁线程池,添加新任务;
      • 工作线程 (PoolWorker):线程池中线程,在没有任务时处于等待状态,可以循环的执行任务;
      • 任务接口 (Task):每个任务必须实现的接口,以供工作线程调度任务的执行,它主要规定了任务的入口,任务执行完后的收尾工作,任务的执行状态等;
      • 任务队列 (TaskQueue):用于存放没有处理的任务。提供一种缓冲机制;
    • 所包含的方法
      • private ThreadPool() 创建线程池
      • public static ThreadPool getThreadPool() 获得一个默认线程个数的线程池
      • public void execute(Runnable task) 执行任务,其实只是把任务加入任务队列,什么时候执行有线程池管理器决定
      • public void execute(Runnable[] task) 批量执行任务,其实只是把任务加入任务队列,什么时候执行有线程池管理器决定
      • public void destroy() 销毁线程池,该方法保证在所有任务都完成的情况下才销毁所有线程,否则等待任务完成才销毁
      • public int getWorkThreadNumber() 返回工作线程的个数
      • public int getFinishedTasknumber() 返回已完成任务的个数,这里的已完成是只出了任务队列的任务个数,可能该任务并没有实际执行完成
      • public void addThread() 在保证线程池中所有线程正在执行,并且要执行线程的个数大于某一值时。增加线程池中线程的个数
      • public void reduceThread() 在保证线程池中有很大一部分线程处于空闲状态,并且空闲状态的线程在小于某一值时,减少线程池中线程的个数
  • 线程池四种创建方式

    Java 通过 Executors 提供四种线程池,分别为:

    • new CachedThreadPool 创建一个可缓存线程池,如果线程池长度超过处理需要,可灵活回收空闲线程,若无可回收,则新建线程。
    • new FixedThreadPool 创建一个定长线程池,可控制线程最大并发数,超出的线程会在队列中等待。
    • new ScheduledThreadPool 创建一个定长线程池,支持定时及周期性任务执行。
    • new SingleThreadExecutor 创建一个单线程化的线程池,它只会用唯一的工作线程来执行任务,保证所有任务按照指定顺序(FIFO, LIFO, 优先级)执行。

3. 什么是并发和并行

 

并发

  • 并发是指两个任务都请求运行,而处理器只能按受一个任务,就把这两个任务安排轮流进行,由于时间间隔较短,使人感觉两个任务都在运行。
  • 如果用一台电脑我先给甲发个消息,然后立刻再给乙发消息,然后再跟甲聊,再跟乙聊。这就叫并发。
  • 多个线程操作相同的资源,保证线程安全,合理使用资源

并行

  • 并行就是两个任务同时运行,就是甲任务进行的同时,乙任务也在进行。(需要多核CPU)

  • 比如我跟两个网友聊天,左手操作一个电脑跟甲聊,同时右手用另一台电脑跟乙聊天,这就叫并行。

  • 服务能同时处理很多请求,提高程序性能

参考资料:

4. 什么是线程安全

当多个线程访问同一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替运行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获取正确的结果,那这个对象是线程安全的。——来自《深入理解Java虚拟机》

  • 定义

    • 某个类的行为与其规范一致。
    • 不管多个线程是怎样的执行顺序和优先级,或是 wait , sleep , join 等控制方式,如果一个类在多线程访问下运转一切正常,并且访问类不需要进行额外的同步处理或者协调,那么我们就认为它是线程安全的。
  • 如何保证线程安全?(更加详细的请转向第一部分 11. 线程安全

    • 对变量使用 volitate
    • 对程序段进行加锁 (synchronized , lock)
  • 注意

    • 非线程安全的集合在多线程环境下可以使用,但并不能作为多个线程共享的属性,可以作为某个线程独享的属性。
    • 例如 Vector 是线程安全的,ArrayList 不是线程安全的。如果每一个线程中 new 一个 ArrayList,而这个ArrayList 只是在这一个线程中使用,肯定没问题。

非线程安全!=不安全?

有人在使用过程中有一个不正确的观点:我的程序是多线程的,不能使用 ArrayList 要使用 Vector,这样才安全。

非线程安全并不是多线程环境下就不能使用。注意我上面有说到:多线程操作同一个对象。注意是同一个对象。比如最上面那个模拟,就是在主线程中 new 的一个 ArrayList 然后多个线程操作同一个 ArrayList 对象。

如果是每个线程中 new 一个 ArrayList,而这个 ArrayList 只在这一个线程中使用,那么肯定是没问题的。

线程安全十万个为什么?

问:平时项目中使用锁和 synchronized 比较多,而很少使用 volatile,难道就没有保证可见性? 答:锁和 synchronized 即可以保证原子性,也可以保证可见性。都是通过保证同一时间只有一个线程执行目标代码段来实现的。

问:锁和 synchronized 为何能保证可见性? 答:根据 JDK 7的Java doc 中对 concurrent 包的说明,一个线程的写结果保证对另外线程的读操作可见,只要该写操作可以由 happen-before 原则推断出在读操作之前发生。

The results of a write by one thread are guaranteed to be visible to a read by another thread only if the write operation happens-before the read operation. The synchronized and volatile constructs, as well as the Thread.start() and Thread.join() methods, can form happens-before relationships.

问:既然锁和 synchronized 即可保证原子性也可保证可见性,为何还需要 volatile? 答:synchronized和锁需要通过操作系统来仲裁谁获得锁,开销比较高,而 volatile 开销小很多。因此在只需要保证可见性的条件下,使用 volatile 的性能要比使用锁和 synchronized 高得多。

问:既然锁和 synchronized 可以保证原子性,为什么还需要 AtomicInteger 这种的类来保证原子操作? 答:锁和 synchronized 需要通过操作系统来仲裁谁获得锁,开销比较高,而 AtomicInteger 是通过CPU级的CAS操作来保证原子性,开销比较小。所以使用 AtomicInteger 的目的还是为了提高性能。

问:还有没有别的办法保证线程安全 答:有。尽可能避免引起非线程安全的条件——共享变量。如果能从设计上避免共享变量的使用,即可避免非线程安全的发生,也就无须通过锁或者 synchronized 以及 volatile 解决原子性、可见性和顺序性的问题。

问:synchronized 即可修饰非静态方式,也可修饰静态方法,还可修饰代码块,有何区别 答:synchronized 修饰非静态同步方法时,锁住的是当前实例;synchronized 修饰静态同步方法时,锁住的是该类的 Class 对象;synchronized 修饰静态代码块时,锁住的是 synchronized 关键字后面括号内的对象。

参考资料:

5. volatile 关键字的如何保证内存可见性

  • volatile 关键字的作用

    • 保证内存的可见性
    • 防止指令重排
    • 注意:volatile 并不保证原子性
  • 内存可见性

    • volatile 保证可见性的原理是在每次访问变量时都会进行一次刷新,因此每次访问都是主内存中最新的版本。所以 volatile 关键字的作用之一就是保证变量修改的实时可见性。
  • 当且仅当满足以下所有条件时,才应该使用 volatile 变量

    • 对变量的写入操作不依赖变量的当前值,或者你能确保只有单个线程更新变量的值。
    • 该变量没有包含在具有其他变量的不变式中。
  • volatile 使用建议

    • 在两个或者更多的线程需要访问的成员变量上使用 volatile。当要访问的变量已在 synchronized 代码块中,或者为常量时,没必要使用volatile。
    • 由于使用 volatile 屏蔽掉了 JVM 中必要的代码优化,所以在效率上比较低,因此一定在必要时才使用此关键字。
  • volatile 和 synchronized区别

    • volatile 不会进行加锁操作:

      volatile 变量是一种稍弱的同步机制在访问 volatile 变量时不会执行加锁操作,因此也就不会使执行线程阻塞,因此 volatile 变量是一种比 synchronized 关键字更轻量级的同步机制。

    • volatile 变量作用类似于同步变量读写操作:

      从内存可见性的角度看,写入 volatile 变量相当于退出同步代码块,而读取 volatile 变量相当于进入同步代码块。

    • volatile 不如 synchronized安全:

      在代码中如果过度依赖 volatile 变量来控制状态的可见性,通常会比使用锁的代码更脆弱,也更难以理解。仅当 volatile 变量能简化代码的实现以及对同步策略的验证时,才应该使用它。一般来说,用同步机制会更安全些。

    • volatile 无法同时保证内存可见性和原子性:

      加锁机制(即同步机制)既可以确保可见性又可以确保原子性,而 volatile 变量只能确保可见性,原因是声明为volatile的简单变量如果当前值与该变量以前的值相关,那么 volatile 关键字不起作用,也就是说如下的表达式都不是原子操作:“count++”、“count = count+1”。

5. 什么是线程?线程和进程有什么区别?为什么要使用多线程

(1)线程和进程

  • 进程是操作系统分配资源的最小单位
  • 线程是CPU调度的最小单位

(2)使用线程的原因

  • 使用多线程可以减少程序的响应时间;
  • 与进程相比,线程的创建和切换开销更小;
  • 多核电脑上,可以同时执行多个线程,提高资源利用率;
  • 简化程序的结构,使程序便于理解和维护;

6. 多线程共用一个数据变量需要注意什么?

  • 当我们在线程对象(Runnable)中定义了全局变量,run方法会修改该变量时,如果有多个线程同时使用该线程对象,那么就会造成全局变量的值被同时修改,造成错误.
  • ThreadLocal 是JDK引入的一种机制,它用于解决线程间共享变量,使用 ThreadLocal 声明的变量,即使在线程中属于全局变量,针对每个线程来讲,这个变量也是独立的。
  • volatile 变量每次被线程访问时,都强迫线程从主内存中重读该变量的最新值,而当该变量发生修改变化时,也会强迫线程将最新的值刷新回主内存中。这样一来,不同的线程都能及时的看到该变量的最新值。

7. 内存泄漏与内存溢出

Java内存回收机制

  不论哪种语言的内存分配方式,都需要返回所分配内存的真实地址,也就是返回一个指针到内存块的首地址。Java中对象是采用 new、反射、clone、反序列化等方法创建的, 这些对象的创建都是在堆(Heap)中分配的,所有对象的回收都是由Java虚拟机通过垃圾回收机制完成的。GC 为了能够正确释放对象,会监控每个对象的运行状况,对他们的申请、引用、被引用、赋值等状况进行监控,Java 会使用有向图的方法进行管理内存,实时监控对象是否可以达到,如果不可到达,则就将其回收,这样也可以消除引用循环的问题。

  在 Java 语言中,判断一个内存空间是否符合垃圾收集标准有两个:一个是给对象赋予了空值 null,以下再没有调用过,另一个是给对象赋予了新值,这样重新分配了内存空间。

Java内存泄露引起原因

  首先,什么是内存泄露?经常听人谈起内存泄露,但要问什么是内存泄露,没几个说得清楚。

  内存泄露:是指无用对象(不再使用的对象)持续占有内存或无用对象的内存得不到及时释放,从而造成的内存空间的浪费称为内存泄露。内存泄露有时不严重且不易察觉,这样开发者就不知道存在内存泄露,但有时也会很严重,会提示 Out of memory

  内存溢出:指程序运行过程中无法申请到足够的内存而导致的一种错误。内存泄露是内存溢出的一种诱因,不是唯一因素   那么,Java 内存泄露根本原因是什么呢?长生命周期的对象持有短生命周期对象的引用就很可能发生内存泄露,尽管短生命周期对象已经不再需要,但是因为长生命周期对象持有它的引用而导致不能被回收,这就是 Java 中内存泄露的发生场景。具体主要有如下几大类

静态集合类

  静态集合类,使用Set、Vector、HashMap等集合类的时候需要特别注意。当这些类被定义成静态的时候,由于他们的生命周期跟应用程序一样长,这时候就有可能发生内存泄漏。

// 例子 
class StaticTest 
{ 
    private static Vector v = new Vector(10); 
    public void init() 
    { 
        for (int i = 1; i < 100; i++) 
        { 
            Object object = new Object(); 
            v.add(object); 
            object = null; 
        } 
    } 
} 
View Code

 

  在上面的代码中,循环申请object对象,并添加到Vector中,然后设置object=null(就是清除栈中引用变量object),但是这些对象被vector引用着,必然不能被GC回收,造成内存泄露。因此要释放这些对象,还需要将它们从vector中删除,最简单的方法就是将vector=null,清空集合类中的引用。

监听器

  在 Java 编程中,我们都需要和监听器打交道,通常一个应用中会用到很多监听器,我们会调用一个控件,诸如 addXXXListener() 等方法来增加监听器,但往往在释放的时候却没有去删除这些监听器,从而增加了内存泄漏的机会。

各种连接

  比如数据库连接(dataSourse.getConnection()),网络连接 (socket) 和 IO 连接,除非其显式的调用了其close() 方 法将其连接关闭,否则是不会自动被 GC 回收的。对于 Resultset 和 Statement 对象可以不进行显式回收,但 Connection 一定要显式回收,因为 Connection 在任何时候都无法自动回收,而 Connection一旦回收,Resultset 和 Statement 对象就会立即为 NULL。但是如果使用连接池,情况就不一样了,除了要显式地关闭连接,还必须显式地关闭 Resultset Statement 对象(关闭其中一个,另外一个也会关闭),否则就会造成大量的 Statement 对象无法释放,从而引起内存泄漏。这种情况下一般都会在 try 里面去的连接,在 finally 里面释放连接。

内部类和外部模块等的引用

  内部类的引用是比较容易遗忘的一种,而且一旦没释放可能导致一系列的后继类对象没有释放。在调用外部模块的时候,也应该注意防止内存泄漏,如果模块A调用了外部模块B的一个方法,如: public void register(Object o) 这个方法有可能就使得A模块持有传入对象的引用,这时候需要查看B模块是否提供了出去引用的方法,这种情况容易忽略,而且发生内存泄漏的话,还比较难察觉。

单例模式

  因为单利对象初始化后将在 JVM 的整个生命周期内存在,如果它持有一个外部对象的(生命周期比较短)引用,那么这个外部对象就不能被回收,从而导致内存泄漏。如果这个外部对象还持有其他对象的引用,那么内存泄漏更严重。

8. 如何减少线程上下文切换

使用多线程时,不是多线程能提升程序的执行速度,使用多线程是为了更好地利用 CPU 资源!

程序在执行时,多线程是 CPU 通过给每个线程分配 CPU 时间片来实现的,时间片是CPU分配给每个线程执行的时间,因时间片非常短,所以CPU 通过不停地切换线程执行。

线程不是越多就越好的,因为线程上下文切换是有性能损耗的,在使用多线程的同时需要考虑如何减少上下文切换

一般来说有以下几条经验

  • 无锁并发编程。多线程竞争时,会引起上下文切换,所以多线程处理数据时,可以用一些办法来避免使用锁,如将数据的 ID 按照Hash取模分段,不同的线程处理不同段的数据
  • CAS算法。Java 的 Atomic 包使用 CAS 算法来更新数据,而不需要加锁。
  • 控制线程数量。避免创建不需要的线程,比如任务很少,但是创建了很多线程来处理,这样会造成大量线程都处于等待状态
  • 协程。在单线程里实现多任务的调度,并在单线程里维持多个任务间的切换
  • 协程可以看成是用户态自管理的“线程”。不会参与CPU时间调度,没有均衡分配到时间。非抢占式的

还可以考虑我们的应用是IO密集型的还是CPU密集型的。

  • 如果是IO密集型的话,线程可以多一些。
  • 如果是CPU密集型的话,线程不宜太多。

9. 线程间通信和进程间通信

线程间通信

  • synchronized 同步

    • 这种方式,本质上就是 “共享内存” 式的通信。多个线程需要访问同一个共享变量,谁拿到了锁(获得了访问权限),谁就可以执行。
  • while 轮询的方式

    • 在这种方式下,线程A不断地改变条件,线程 ThreadB 不停地通过 while 语句检测这个条件(list.size()==5) 是否成立 ,从而实现了线程间的通信。但是这种方式会浪费 CPU 资源。之所以说它浪费资源,是因为 JVM 调度器将 CPU 交给线程B执行时,它没做啥“有用”的工作,只是在不断地测试某个条件是否成立。就类似于现实生活中,某个人一直看着手机屏幕是否有电话来了,而不是: 在干别的事情,当有电话来时,响铃通知TA电话来了。
  • wait/notify 机制

    • 当条件未满足时,线程A调用 wait() 放弃CPU,并进入阻塞状态。(不像 while 轮询那样占用 CPU)

      当条件满足时,线程B调用 notify() 通知线程A,所谓通知线程A,就是唤醒线程A,并让它进入可运行状态。

  • 管道通信

    • java.io.PipedInputStream 和 java.io.PipedOutputStream 进行通信

进程间通信

  • 管道(Pipe) :管道可用于具有亲缘关系进程间的通信,允许一个进程和另一个与它有共同祖先的进程之间进行通信。
  • 命名管道(named pipe) :命名管道克服了管道没有名字的限制,因此,除具有管道所具有的功能外,它还允许无亲缘关 系 进程间的通信。命名管道在文件系统中有对应的文件名。命名管道通过命令mkfifo或系统调用mkfifo来创建。
  • 信号(Signal) :信号是比较复杂的通信方式,用于通知接受进程有某种事件发生,除了用于进程间通信外,进程还可以发送 信号给进程本身;Linux除了支持Unix早期信号语义函数sigal外,还支持语义符合Posix.1标准的信号函数sigaction(实际上,该函数是基于BSD的,BSD为了实现可靠信号机制,又能够统一对外接口,用sigaction函数重新实现了signal函数)。
  • 消息(Message)队列 :消息队列是消息的链接表,包括Posix消息队列system V消息队列。有足够权限的进程可以向队列中添加消息,被赋予读权限的进程则可以读走队列中的消息。消息队列克服了信号承载信息量少,管道只能承载无格式字节流以及缓冲区大小受限等缺
  • 共享内存 :使得多个进程可以访问同一块内存空间,是最快的可用IPC形式。是针对其他通信机制运行效率较低而设计的。往往与其它通信机制,如信号量结合使用,来达到进程间的同步及互斥。
  • 内存映射(mapped memory) :内存映射允许任何多个进程间通信,每一个使用该机制的进程通过把一个共享的文件映射到自己的进程地址空间来实现它。
  • 信号量(semaphore) :主要作为进程间以及同一进程不同线程之间的同步手段。
  • 套接口(Socket) :更为一般的进程间通信机制,可用于不同机器之间的进程间通信。起初是由Unix系统的BSD分支开发出来的,但现在一般可以移植到其它类Unix系统上:linux和System V的变种都支持套接字。

参考资料:

10. 什么是同步和异步,阻塞和非阻塞?

同步和异步关注的是消息通信机制 (synchronous communication/ asynchronous communication)

同步

  • 在发出一个同步调用时,在没有得到结果之前,该调用就不返回。
  • 例如:按下电饭锅的煮饭按钮,然后等待饭煮好,把饭盛出来,然后再去炒菜。

异步

  • 在发出一个异步调用后,调用者不会立刻得到结果,该调用就返回了。
  • 例如:按下电钮锅的煮饭按钮,直接去炒菜或者做别的事情,当电饭锅“滴滴滴”响的时候,再回去把饭盛出来。显然,异步式编程要比同步式编程高效得多。

阻塞和非阻塞关注的是程序在等待调用结果(消息,返回值)时的状态.

阻塞

  • 调用结果返回之前,当前线程会被挂起。调用线程只有在得到结果之后才会返回。
  • 例子:你打电话问书店老板有没有《分布式系统》这本书,你如果是阻塞式调用,你会一直把自己“挂起”,直到得到这本书有没有的结果

非阻塞

  • 在不能立刻得到结果之前,该调用不会阻塞当前线程。
  • 例子:你打电话问书店老板有没有《分布式系统》这本书,你不管老板有没有告诉你,你自己先一边去玩了, 当然你也要偶尔过几分钟check一下老板有没有返回结果。

参考资料:

11. Java中的锁

本小结参考:Java 中的锁 - Java 并发性和多线程 - 极客学院Wiki

  锁像 synchronized 同步块一样,是一种线程同步机制,但比 Java 中的 synchronized 同步块更复杂。因为锁(以及其它更高级的线程同步机制)是由 synchronized 同步块的方式实现的,所以我们还不能完全摆脱 synchronized 关键字(译者注:这说的是 Java 5 之前的情况)。

  自 Java 5 开始,java.util.concurrent.locks 包中包含了一些锁的实现,因此你不用去实现自己的锁了。但是你仍然需要去了解怎样使用这些锁,且了解这些实现背后的理论也是很有用处的。可以参考我对 java.util.concurrent.locks.Lock 的介绍,以了解更多关于锁的信息。

一个简单的锁

让我们从 java 中的一个同步块开始:

public class Counter{
    private int count = 0;

    public int inc(){
        synchronized(this){
            return ++count;
        }
    }
}

 

可以看到在 inc()方法中有一个 synchronized(this)代码块。该代码块可以保证在同一时间只有一个线程可以执行 return ++count。虽然在 synchronized 的同步块中的代码可以更加复杂,但是++count 这种简单的操作已经足以表达出线程同步的意思。

以下的 Counter 类用 Lock 代替 synchronized 达到了同样的目的:

 1 public class Counter{
 2     private Lock lock = new Lock();
 3     private int count = 0;
 4 
 5     public int inc(){
 6         lock.lock();
 7         int newCount = ++count;
 8         lock.unlock();
 9         return newCount;
10     }
11 }
View Code

 

lock()方法会对 Lock 实例对象进行加锁,因此所有对该对象调用 lock()方法的线程都会被阻塞,直到该 Lock 对象的 unlock()方法被调用。

这里有一个 Lock 类的简单实现:

public class Counter{
public class Lock{
    private boolean isLocked = false;

    public synchronized void lock()
        throws InterruptedException{
        while(isLocked){
            wait();
        }
        isLocked = true;
    }

    public synchronized void unlock(){
        isLocked = false;
        notify();
    }
}
View Code

 

注意其中的 while(isLocked) 循环,它又被叫做 “自旋锁”。自旋锁以及 wait() 和 notify() 方法在线程通信这篇文章中有更加详细的介绍。当 isLocked 为 true 时,调用 lock() 的线程在 wait() 调用上阻塞等待。为防止该线程没有收到 notify() 调用也从 wait() 中返回(也称作虚假唤醒),这个线程会重新去检查 isLocked 条件以决定当前是否可以安全地继续执行还是需要重新保持等待,而不是认为线程被唤醒了就可以安全地继续执行了。如果 isLocked 为 false,当前线程会退出 while(isLocked) 循环,并将 isLocked 设回 true,让其它正在调用 lock() 方法的线程能够在 Lock 实例上加锁。

当线程完成了临界区(位于 lock()和 unlock()之间)中的代码,就会调用 unlock()。执行 unlock()会重新将 isLocked 设置为 false,并且通知(唤醒)其中一个(若有的话)在 lock()方法中调用了 wait()函数而处于等待状态的线程。

锁的可重入性

Java 中的 synchronized 同步块是可重入的。这意味着如果一个 Java 线程进入了代码中的 synchronized 同步块,并因此获得了该同步块使用的同步对象对应的管程上的锁,那么这个线程可以进入由同一个管程对象所同步的另一个 java 代码块。下面是一个例子:

public class Reentrant{
    public synchronized outer(){
        inner();
    }

    public synchronized inner(){
        //do something
    }
}

 

注意 outer()和 inner()都被声明为 synchronized,这在 Java 中和 synchronized(this) 块等效。如果一个线程调用了 outer(),在 outer()里调用 inner()就没有什么问题,因为这两个方法(代码块)都由同一个管程对象(”this”) 所同步。如果一个线程已经拥有了一个管程对象上的锁,那么它就有权访问被这个管程对象同步的所有代码块。这就是可重入。线程可以进入任何一个它已经拥有的锁所同步着的代码块。

前面给出的锁实现不是可重入的。如果我们像下面这样重写 Reentrant 类,当线程调用 outer() 时,会在 inner()方法的 lock.lock() 处阻塞住。

public class Reentrant2{
    Lock lock = new Lock();

    public outer(){
        lock.lock();
        inner();
        lock.unlock();
    }

    public synchronized inner(){
        lock.lock();
        //do something
        lock.unlock();
    }
}
View Code

 

调用 outer() 的线程首先会锁住 Lock 实例,然后继续调用 inner()。inner()方法中该线程将再一次尝试锁住 Lock 实例,结果该动作会失败(也就是说该线程会被阻塞),因为这个 Lock 实例已经在 outer()方法中被锁住了。

两次 lock()之间没有调用 unlock(),第二次调用 lock 就会阻塞,看过 lock() 实现后,会发现原因很明显:

public class Lock{
    boolean isLocked = false;

    public synchronized void lock()
        throws InterruptedException{
        while(isLocked){
            wait();
        }
        isLocked = true;
    }

    ...
}
View Code

 

一个线程是否被允许退出 lock()方法是由 while 循环(自旋锁)中的条件决定的。当前的判断条件是只有当 isLocked 为 false 时 lock 操作才被允许,而没有考虑是哪个线程锁住了它。

为了让这个 Lock 类具有可重入性,我们需要对它做一点小的改动:

public class Lock{
    boolean isLocked = false;
    Thread  lockedBy = null;
    int lockedCount = 0;

    public synchronized void lock()
        throws InterruptedException{
        Thread callingThread =
            Thread.currentThread();
        while(isLocked && lockedBy != callingThread){
            wait();
        }
        isLocked = true;
        lockedCount++;
        lockedBy = callingThread;
  }

    public synchronized void unlock(){
        if(Thread.curentThread() ==
            this.lockedBy){
            lockedCount--;

            if(lockedCount == 0){
                isLocked = false;
                notify();
            }
        }
    }

    ...
}
View Code

 

注意到现在的 while 循环(自旋锁)也考虑到了已锁住该 Lock 实例的线程。如果当前的锁对象没有被加锁(isLocked = false),或者当前调用线程已经对该 Lock 实例加了锁,那么 while 循环就不会被执行,调用 lock()的线程就可以退出该方法(译者注:“被允许退出该方法”在当前语义下就是指不会调用 wait()而导致阻塞)。

除此之外,我们需要记录同一个线程重复对一个锁对象加锁的次数。否则,一次 unblock()调用就会解除整个锁,即使当前锁已经被加锁过多次。在 unlock()调用没有达到对应 lock()调用的次数之前,我们不希望锁被解除。

现在这个 Lock 类就是可重入的了。

锁的公平性

Java 的 synchronized 块并不保证尝试进入它们的线程的顺序。因此,如果多个线程不断竞争访问相同的 synchronized 同步块,就存在一种风险,其中一个或多个线程永远也得不到访问权 —— 也就是说访问权总是分配给了其它线程。这种情况被称作线程饥饿。为了避免这种问题,锁需要实现公平性。本文所展现的锁在内部是用 synchronized 同步块实现的,因此它们也不保证公平性。饥饿和公平中有更多关于该内容的讨论。

在 finally 语句中调用 unlock()

如果用 Lock 来保护临界区,并且临界区有可能会抛出异常,那么在 finally 语句中调用 unlock()就显得非常重要了。这样可以保证这个锁对象可以被解锁以便其它线程能继续对其加锁。以下是一个示例:

lock.lock();
try{
    //do critical section code,
    //which may throw exception
} finally {
    lock.unlock();
}

 

这个简单的结构可以保证当临界区抛出异常时 Lock 对象可以被解锁。如果不是在 finally 语句中调用的 unlock(),当临界区抛出异常时,Lock 对象将永远停留在被锁住的状态,这会导致其它所有在该 Lock 对象上调用 lock()的线程一直阻塞。

12. 并发包(J.U.C)下面,都用过什么

  • concurrent下面的包
    • Executor 用来创建线程池,在实现Callable接口时,添加线程。
    • FeatureTask 此 FutureTask 的 get 方法所返回的结果类型。
    • TimeUnit
    • Semaphore
    • LinkedBlockingQueue
  • 所用过的类
    • Executor

13. 从volatile说到,i++原子操作,线程安全问题

从 volatile 说到,i++原子操作,线程安全问题 - CSDN博客https://blog.csdn.net/zbw18297786698/article/details/53420780

参考资料

posted @ 2019-07-06 23:05  惯看秋风  阅读(10)  评论(0)    收藏  举报