JAVA工作1~3年面试准备:多线程

要想进阶成Java中级开发工程师,有一些东西总是绕不过去的,Java的知识体系里,像IO/NIO/AIO,多线程,JVM,网络编程,数据库,框架等必须有一定了解才行。最近在准备面试,所以对这些东西也做个记录。本篇记录的是多线程相关。

线程

进程/线程概念

​ 我们打开一个应用程序时,就会打开应用程序对应的进程。系统会给每个进程分配一定的资源(CPU和内存等)。分配资源后把该进程放入进程就绪队列,进程调度器选中它的时候就会为它分配CPU时间,程序开始真正运行。

​ 然后一个进程里面就包括一到多个线程,线程是进程的一个执行流,也是CPU调度和分派的基本单位。每个线程上都有要执行的代码,线程间共享进程的所有资源。然后每个线程又自己的堆栈和局部变量。在单核环境下,也允许开启多个线程运行,不过并不是同时运行的的,只是CPU处理得太快让多个线程看起来是在同时运行,在多核环境下线程能够做到真正意义上的并行。

并发 != 并行。并发只是多个进程间快速交替的执行,并行是某个时刻真的有多个线程在同时执行。

进程/线程区别

1) 进程是操作系统分配资源的最小单位,而线程是程序执行的最小单位;

2) 一个进程由一个或多个线程组成,线程是一个进程中代码的不同执行路线;

3) 进程有自己的独立的地址空间,而线程共享同一个进程的资源;线程之间的通信更方便,进程之间的通信比较复杂。

4) 线程的创建和切换花销比进程要小得多。

线程的状态/生命周期

线程有创建,可运行,运行中,阻塞,消亡五种状态。

新建: 继承Thread | 实现runnable()接口| 实现Callable接口 | ExecutorService

可运行: 新建的线程调用start() | 运行中的线程时间片用完 | 运行中的线程调用yield() | 从阻塞或锁池状态恢复

运行: 操作系统调度选中

阻塞: 等待用户输入 | sleep() | join()

消亡: run() | main() 方法运行结束

线程主要方法

sleep():让调用这个方法的正在执行的线程休眠一段时间

join():让调用这个方法的线程进入阻塞状态,一直等到调用这个方法的线程执行完了再继续运行。

yield():让调用这个方法的线程暂停,然后让操作系统重新调度。

interrupt():向调用这个方法的线程进入中断状态,注意,那个线程并不会被中断。

interrupted():让调用这个方法的线程停止中断状态。

isInterrupt():判断当前线程的中断状态。是在中断状态返回true。

stop()/suspend()/resume():停止/挂起/恢复挂起三个过时的方法,不建议使用。

sleep()与wait()方法区别:

sleep()是Thread类的静态方法,它的作用是让当前线程从运行状态转入阻塞状态,当一个线程通过sleep()方法暂停之后,该线程并不会释放它对同步监视器的加锁。

wait()是Object对象的方法,它的作用是让当前线程释放对该同步监视器的加锁,然后该线程则会进入该同步监视器的等待池中,直到该同步监视器调用notify()或notifyAll()来通知该线程。

wait()、notify使用实例:

//假设两个线程:想要第一个线程跑两圈然后,然后让第二个线程跑完,第一个线程再接着跑。main方法中:
TestThread tt = new TestThread();
new Thread(tt,"线程1").start();
new Thread(tt,"线程2").start();
​
class TestThread extends Thread{
    int num;
    @Override
    public void run(){
        synchronized (this) {
            try {
                for(int i = 0; i<3;i++){
                    System.out.println(String.format("线程名=[%s]调用了同步方法。%d",
                         Thread.currentThread().getName(),this.num++));
                    if(this.num==2){
                       //第一个线程进来跑两次,this.num == 2;然后让这个线程释放锁睡觉。下一个线程接过锁;
                        this.wait();    
                    }
                    if(this.num==3){
                       //第二个线程接过锁后跑,期间任意时候可叫醒第一个线程。(只两个线程时测试)
                        this.notify();  
                    }
                }
            } catch (Exception e) {
            }
        }
}

线程启动/停止

​ 我们创建线程之后,是通过线程的start()方法来启动。如果是用线程池的话,就是调用线程池的excecute()方法或者submit()方法来启动。

​ 当run() 或者 call() 方法执行完的时候线程会自动结束,主动停止线程的话,Java提供的终止方法只有一个stop(),但是不建议使用。因为它是一个过时的方法,是一种恶意的中断。可能会导致不可预知的错误,比如线程锁没有归还,io流不能关闭。然后我一般是设置一个volatile 状态变量,通过这个状态变量来退出run()方法的循环,从而结束这个线程的。

如果run()方法的while循环里面被阻塞的话,可以配合interrupt(),捕获InterruptedException 来结束此线程。

Java内存模型(JMM)

​ Java内存模型就是一组规则,它规定了多个线程之间的通信方式与通信细节。

​ 首先,JMM规定了所有的变量都存储在主内存中,每个线程都有自己的工作内存,线程需要使用到的变量,都会从主内存拷贝一份副本到自己的工作内存中,线程对变量的读写操作都是在自己的工作内存中进行,而不能直接读写主内存中的变量。不同线程之间无法直接访问对方工作内存中的变量,线程间变量值的传递需要通过主内存来完成。

​ Java内存模型是围绕着并发编程中原子性、可见性、有序性这三个特征来建立的。旧的内存模型(在JDK5之前),Java主要是依靠规定了8种对内存访问语句以及规则。比如如何执行变量的读/写,加锁/解锁,以及volatile变量的读/写等,来保证多线程程序的正确性。旧的内存模型很复杂,JDK5之后,JMM采用新的内存模型。新的内存模型使用happens-before的概念来阐述操作之间的内存可见性。happens-before就是先行发生原则,它定义了一些规则如:一个线程内写在前面的代码会先发生于写在后面的代码,还有一个锁的解锁操作会先发生于这个锁的解锁操作,还有volatile变量的写操作会先发生于这个变量的读操作,还有事件A如果先发生于事件B,事件B又先发生于事件C,那么A一定先发生于C 诸如此类的一些规则。通过这些规则我们可以解决并发环境下两个操作之间是否可能存在冲突的问题。

​ 总之,Java内存模型的提出,就是为了保证线程之间的可见性,原子性和有序性,然后保证我们对程序的运行顺序是可控的从而保证线程安全和提高程序运行效率。

JDK1.5前的JMM通过定义八种操作来完成内存交互。(unlock|lock|write<storage|assign|use|load<read

多线程间通信

​ 线程间通信是有两种机制,一个是共享内存,一个是消息传递。

​ 共享内存是一种隐式的通信方式,就是JMM模型定义的那样,线程之间共享一些公共状态,线程通过读或写等操作来影响这些公共状态,实现与其它线程的通信。

​ 消息传递则是一种显示的通信方式,就是我们通过wait()、notify()/notifyAll()这种显示的调用的方式发送消息,实现通信。这种显示的通信方式除了wait()、notify()/notifyAll()外,还有thread.join(),CountdownLatch,CyclicBarrier,FutureTask/Callable,condition.await() / condition.signal()等等。目的就是为了更直观更方便地控制线程执行的顺序,达到我们需要的效果。

一般说的synchronized用来做多线程同步功能,其实synchronized只是提供多线程互斥,而对象的wait()和notify()方法才提供线程的同步功能。

实现线程安全的办法:第一,是采用原子变量,线程安全问题最根本上是由于全局变量和静态变量引起的,定义变量用sig_atomic_t和volatile。第二,就是实现线程间同步,让线程有序访问变量

多线程关键字

synchronized

要点: 出现原因/作用 使用场景 原理

​ 在JMM模型里面,我们知道多个线程会共享同一个主内存上的数据,因为多个线程都能够对主内存上的数据进行读或写操作,所以就会存在一个数据如何同步的问题。因为同时读数据不会产生冲突,所以要解决同步问题,实际上就是要解决多个线程之间同时写数据会冲突的问题。对于这个问题,java就提供了这个Synchronized关键字,synchronized就可以保证在并发情况下,同一时刻只有一个线程执行某个方法或某段代码。只有一个线程执行写操作的话,自然就没有冲突的问题了。

​ synchronized的用法也很简单,它可以修饰普通方法、静态方法和代码块。这里就要说一下锁的概念了。Java中每一个对象都可以作为锁,这是synchronized实现同步的基础。当synchronized修饰普通方法时,锁对象就是当前调用这个方法的实例对象;当synchronized修饰静态方法时,锁对象就是当前类的class对象;当synchronized修饰代码块时,锁对象就是括号里面的对象。当一个线程想调用synchronized修饰的方法或代码块时,它必须要获取对应的锁才能够执行。比如多个线程同时调用一个同步方法,那么这多个线程就会去抢调用这个同步方法的实例对象对应的锁,抢到这个锁的线程就可以执行这个方法,没抢到的线程就会进入一个同步队列,等到抢到锁的线程执行结束或者出现异常释放锁后,同步队列中的线程就继续争抢对象锁。如此循环,这就保证了同一时刻只有一个线程在执行这段代码。也是通过这种方式实现同步和线程安全,也保证了线程之间的可见性和原子性。

  • 释放锁的情况还有一种就是调用锁对象的wait()方法,释放当前锁,并将当前线程放入等待队列,该线程将等待notify()唤醒,唤醒后进入同步队列。 (wait()/notify()要在synchronized中使用)

  • synchronized是非公平锁,新进入同步队列的线程会先尝试自旋获取锁,可能立即获得锁,而在队列中等候已久的线程则可能再次等待。

  • JDK1.6后,当执行synchronized同步块的时候jvm会根据启用的锁和当前线程的争用情况,决定如何执行同步操作;偏向锁-> 轻量级锁->自旋锁->重量级锁。如果线程争用激烈,那么应该禁用偏向锁。-XX:-UseBiasedLocking

volatile

​ 首先volatile也是java中的一个关键字,加入volatile关键字时,编译后的底层代码会多出一个lock前缀指令。这个lock前缀指令实际上相当于一个内存屏障,内存屏障会提供3个功能:

1)它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成;

2)它会强制将对缓存的修改操作立即写入主存;

3)如果是写操作,它会导致其他CPU中对应的缓存行无效。其它线程因为缓存失效所以会重读主内存拿到它修改后的值。

也就是说volatile能保证线程之间的可见性与原子性。值得注意的是它并不能保证复合操作的原子性。如自增自减操作。

Lock

​ Lock是一个接口。jdk1.5后出现的。我们了解到如果一个代码块被synchronized修饰了,当一个线程获取了对应的锁,并执行该代码块时,其他线程便只能一直等待,等待获取锁的线程释放锁,那么如果这个获取锁的线程由于要等待IO或者其他原因(比如调用sleep方法)被阻塞了,但是又没有释放锁,其它线程也只能继续等待,而什么也做不了。这样就很影响了程序的效率。而Lock就可以通过tryLock()方法做到让等待的线程只等待一定的时间,如果一定时间内没有获得锁就可以继续等或者可以做其他事情。Lock也可以通过lock.lockInterruptibly()方法直接中断线程的等待过程。

​ 还有一种情况是,当有多个线程读写文件时,读操作和写操作会发生冲突现象,写操作和写操作会发生冲突现象,但是读操作和读操作不会发生冲突现象。但是采用synchronized关键字来实现同步的话,就会导致一个问题:如果多个线程都只是进行读操作,当一个线程在进行读操作时,其他线程也只能等待无法进行读操作。而Lock能够做到多个线程都进行读操作而不会发生冲突。

​ 另外,通过Lock可以通过tryLock()方法知道线程有没有成功获取到锁。这个是synchronized无法办到的。

Lock类常用的有四个方法:lock()、unLock()、tryLock()、lockInterruptibly()。lock()就是用来获取锁。如果锁已被其他线程获取,则进行等待。unLock()就是用来释放锁。一般放在finally块中保证一定执行。避免死锁。tryLock(long waittime)就是用来尝试获取锁,无论如何都会立即返回。故拿不到锁时也能做其他事。lockInterruptibly()就是获取不到锁是可以中断线程的等待过程。而不是一直等待。

synchronized volatile Lock三者区别:

synchronized和volatile区别。

1.1在说他们的区别之前,得先说一下JMM。JMM就是java内存模型,它设计是用来解决并发过程中如何处理可见性、原子性和有序性的问题。JMM定义了主内存和线程之间的关系。就是主内存就是堆内存嘛。然后主内存中存储着所有实例/静态变量/数组。每个线程都有自己的工作内存,然后线程会把它要用到的共享对象从主内存拷贝到自己的工作内存缓存起来。线程对共享变量的写操作,并不是直接作用在主内存上,而是只在自己的工作内存进行操作。写操作结束之后才会把结果传给主内存,然后主内存上的值就修改成对应结果。然后之后其它线程再用到这个值的时候就会从主内存中拿到新修改的值。这个就是JMM定义的主内存和工作内存之间交互的规则。

这样就有两个问题,第一个是,线程什么时候刷新本地内存上共享变量的值到主内存,第二个是,其它线程又是什么时候把主内存的值同步到本地内存。这两个都是不确定的,就会导致一个可见性的问题。为了解决这个问题,就有了Synchronized和volatile这两个关键字。

使用Synchronized的话,线程a得到锁后会锁定当前变量,只有当前线程可以访问该变量,其他线程被阻塞住。然后线程a释放锁以后会把数据同步到主内存。然后另一个线程b获得锁以后会从主内存获得数据到本地内存。这就完成了一个同步,实现了可见性。

使用volatile修饰变量时,对这个变量进行写操作时,JVM会向处理器发送一条lock指令,将这个变量所在的缓存行的数据写回到主内存,然后其他线程工作内存上这个变量的缓存就变为了失效状态,会重新把这个变量从主内存读取到自己的工作内存。

所以,synchronized和volatile都能实现可见性。volatile本质是告诉jvm当前变量在工作内存中的值是不确定的,需要从主内存读取,它不会阻塞其它线程; synchronized则是锁定当前变量,只有当前线程可以访问该变量,其他线程被阻塞住。这是他们的一个区别。关于可见性的。

然后对于原子性。Volatile和Synchronized都能保证原子性,但是volatile不能保证复合操作的原子性,比如对一个共享对象进行自增自减操作,就涉及到从主内存中获取值,对这个值进行自增自减操作,然后把值写会主内存这三个操作。这三个操作是不能保证原子性的。synchronized可以,因为只有拿到锁的线程才可以对这个共享变量进行操作嘛。这是他们的第二个区别。关于原子性的。

然后对于有序性,volatile标记的变量不会被编译器优化;synchronized标记的变量可以被编译器优化。就是说volatile不允许重排序的,它会有一个内存屏障,屏障前的代码一定比屏障后的代码先执行。但synchronized可以重排序。因为它是独占线程,不会有安全问题。这是他们的第三个区别,关于有序性的。

还有一个区别就是volatile仅能使用在变量级别;synchronized则可以使用在变量、方法、和类级别的。然后volatile的优点在于它的花销比较小。比较适合作为状态标志。

// 结合使用 volatile 和 synchronized 实现 “开销较低的读-写锁”
private volatile int value;
public int getValue() { return value; }
public synchronized int increment() {
    return value++;
}

synchronized和Lock区别:

①synchronized是java的关键字,而ReentrantLock是一个类。都能通过锁来控制同步。Synchronized的锁更重量级一些,Lock类的锁更轻量级。

②然后synchronized会自动释放锁,Lock需要主动调用unlock()方法释放锁。特别是异常时,如果没有主动unlock()释放锁,很可能造成死锁,所以unlock一般都是放在finally块中执行。

③然后synchronized没有获得锁时会一直等待,不能中断,相当于是阻塞式的。Lock可以让等待锁的线程响应中断。

④然后synchronized不能知道当前线程有没有获得锁,而lock通过tryLock()可以知道有没有获得锁。

⑤还有Lock可以提高多个线程进行读操作的效率。ReadWriteLock

  • 可重入锁就是一个线程得到一个对象锁后再次请求该对象锁,是永远可以拿到锁的。这时候仅仅是把状态值进行累加。synchronized 和 ReentrantLock都是可重入锁。

  • 死锁实例:

//死锁
synchronized(DeadLock.obj1){    //线程1中持有obj1的锁后,又去拿obj2的锁.
  Thread.sleep(1000);//获取obj1后先等一会儿,让Lock2有足够的时间锁住obj2
  synchronized(DeadLock.obj2){  //这时obj2已经被线程2拿着。所以这里线程1会陷入等待。
    System.out.println("Lock1 lock obj2");
  }
}
synchronized(DeadLock.obj2){    //线程2中持有obj2的锁后,又去拿obj1的锁。  
  System.out.println("Lock2 lock obj2");
  Thread.sleep(1000); //获取obj2后先等一会儿,让Lock1有足够的时间锁住obj1
  synchronized(DeadLock.obj1){  //这时obj1已经被线程1拿着。所以这里线程2也会陷入等待。
    System.out.println("Lock2 lock obj1");
  }
}
//两个线程都陷入了等待,都等对方释放锁。就死锁了。所以锁不要乱嵌套,会死掉的!!

线程池

要点: 概念 作用 类型

在没用线程池之前,我们是需要使用线程就去创建一个,实现起来也很简单,但是问题如果并发的线程数量很多,而且每个线程执行的时间又比较短的话,系统就会很频繁地创建,切换和销毁线程,这都是很耗时间的,会降低系统的效率。然后这个问题就跟我们数据库的连接一样,然后数据库有对应的数据库连接池。线程对应就有线程池。目的都是为了实现一个资源复用的效果。

使用Java线程池的好处:1) 重用存在的线程,减少对象创建、消亡的开销,提升性能。2)可有效控制最大并发线程数,提高系统资源的使用率,同时避免过多资源竞争,避免堵塞。3)提供定时执行、定期执行、单线程、并发数控制等功能。

Java中常用的线程池有四种,每种线程池有不同的适用场景:

newCachedThreadPoolnewFixedThreadPoolScheduledThreadPoolnewSingleThreadPool

newCachedThreadPool

概念:创建一个可缓存线程池,调用 execute() 执行任务时它可以重用之前构造的并且还可用的线程,然后如果没有可用的线程的话,它就会池里面添加一个新的线程去执行任务。默认这个线程池能存在Integer.MAX_VALUE个线程,然后默认会终止和从从缓存中移除那些60s都没有被使用的线程。就是说,长时间保持这个空闲的线程池也不会占用资源。

适用:适合执行大量短暂异步的程序,或者希望提交的任务尽快分配线程执行的情况。

ExecutorService cacheThreadPool = Executors.newCachedThreadPool();  //可缓存线程池。

补充:使用CachedThreadPool,要非常注意控制任务的数量,否则由于大量线程同时运行,很有会造成系统瘫痪

newFixedThreadPool

概念:创建一个指定工作线程数量的线程池,这样就能控制最大并发数。然后池中的线程数小于核心线程数时,每提交一个任务,线程池就会创建一个工作线程去执行这个任务。然后如果池中的线程数超过核心线程数了,那么这些新提交的任务就会被放到池队列中。然后如果你还要继续提交任务,把池队列也给放满了,就是把池队列的21亿个位置都放满了。那么这时候就判断,如果池中的线程数量小于线程池的最大线程数量,线程池的最大线程数也是21亿,那就继续创建线程去执行。如果池中的线程数量等于线程池的最大线程数量了,就会抛出异常,拒绝任务,至于如何拒绝处理新增的任务,取决于线程池的饱和策略RejectedExecutionHandler了。

适用:对于需要保证所有提交的任务都要被执行的情况,它的性能好很多 

补充:

  • 如果在关闭前的执行期间由于失败而导致任何线程终止,那么一个新线程将代替它执行后续的任务

  • 当线程池中线程数超过corePoolSize,空闲时间达到keepAliveTime时,关闭空闲线程,直到=corePoolSize

  • 当设置allowCoreThreadTimeOut(true)时,线程池中corePoolSize线程空闲时间达到keepAliveTime也将关闭

ScheduledThreadPool

概念:创建一个线程池,它可安排在给定延迟后运行命令或者定期地执行。池中保存的线程数,即使线程是空闲的也包括在内。

适用:周期性执行任务的场景

newSingleThreadPool

概念:创建一个使用单个 worker 线程的 Executor,以无界队列方式来运行该线程。可保证顺序地执行各个任务,并且在任意给定的时间不会有多个线程是活动的。与其他等效的 newFixedThreadPool(1)不同,可保证无需重新配置此方法所返回的执行程序即可使用其他的线程。

适用:一个任务一个任务执行的场景

补充:

  • 常用的线程池模式:半同步/半异步模式(生产者消费者模式)、领导者跟随者模式。

  • new SingleThreadPool与new FixedThreadPool(1)的区别: 二者都能顺序地执行任务,但SingleThreadExecutor不能配置去重新加入线程。也就是说new SingleThreadPool只会有一个线程执行任务,new FixedThreadPool(1)可以通过配置改变线程数量,这时候可能就不是一个线程在执行任务而已了。

final ExecutorService fixed = Executors.newFixedThreadPool(1);
ThreadPoolExecutor executor = (ThreadPoolExecutor) fixed;
executor.setCorePoolSize(4);    //如果通过这样可以改变fix的任务数:FinalizableDelegatedExecutorService 

J.U.C下的常见类

ConcurrentHashMap,Lock,volatile,ThreadPoolExecute,Callable,

CountDownLatch,CyclicBarrier,Atomic,Future和FutureTask,Semaphore,

很多都是基于一个CAS算法:

  • CAS的全称是Compare And Swap 即比较交换,当多个线程同时使用CAS操作一个变量时,只有一个会胜出,并成功更新,其余均会失败,但失败的线程并不会被挂起,仅是被告知失败,并且允许再次尝试,当然也允许失败的线程放弃操作;CAS 是一种无锁的非阻塞算法的实现,无锁天生就免疫死锁。同时CAS是一种系统原语,原语属于操作系统用语范畴,是由若干条指令组成的,用于完成某个功能的一个过程,并且原语的执行必须是连续的,在执行过程中不允许被中断,也就是说CAS是一条CPU的原子指令,不会造成所谓的数据不一致问题。

  • CAS 包含了三个操作数:需要读写的内存值: V,进行比较的预估值: A,拟写入的更新值: B。当且仅当 V == A 时, V = B, 否则,将不做任何操作;

ConcurrentHashMap

​ 在1.8版本以前,ConcurrentHashMap采用分段锁的概念,默认一个ConcurrentHashMap中有16个子HashMap,所以相当于一个二级哈希。对于所有的操作都是先定位到子HashMap,然后每次操作对子HashMap加锁,使锁更加细化,再作相应的操作。避免多线程锁得几率,提高并发效率。1.8之后已经改变了这种思路,而是利用CAS+Synchronized来保证并发更新的安全,当然底层采用数组+链表+红黑树的存储结构。

按照1.8源码,可以确定put整个流程如下:

final V putVal(K key, V value, boolean onlyIfAbsent) {
    // 1)判空;ConcurrentHashMap的key、value都不允许为null
    if (key == null || value == null) throw new NullPointerException();
    // 2)计算hash。利用方法计算hash值
    int hash = spread(key.hashCode()); //两次hash,减少hash冲突,可以均匀分布
    int binCount = 0;
    // 3)遍历table,进行节点插入操作。插入过程如下:
    for (Node<K,V>[] tab = table;;) {
        Node<K,V> f; int n, i, fh;
      // 3.1)如果没有初始化就先调用initTable()方法来进行初始化过程
      if (tab == null || (n = tab.length) == 0)
                  tab = initTable();
      // 3.2)/如果插入位置没有hash冲突就直接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;                 
      }
      // 3.2)如果在进行扩容,则先进行扩容操作
      else if ((fh = f.hash) == MOVED)
        tab = helpTransfer(tab, f);
      // 3.3)如果以上条件都不满足,那就要进行加锁操作,也就是存在hash冲突,锁住链表或者红黑树的头结点
      else {
        V oldVal = null;
        //如果以上条件都不满足,那就要进行加锁操作,也就是存在hash冲突,锁住链表或者红黑树的头结点
        synchronized (f) {if (tabAt(tab, i) == f) { // JDK1.8是乐观锁,当有冲突的时候才进行并发处理
         ... //最后一个如果该链表的数量大于阈值8,就要先转换成黑红树的结构
        }}
      }
      // 3.4)如果添加成功就调用addCount()方法统计size,并且检查是否需要扩容
      addCount(1L, binCount);//统计size,并且检查是否需要扩容
      return null;
    }
}

与1.7的ConcurrentHashMap区别:

  • JDK1.8的实现降低锁的粒度,JDK1.7版本锁的粒度是基于Segment的,包含多个HashEntry,而JDK1.8锁的粒度就是首节点。

  • JDK1.8使用内置锁synchronized来代替重入锁ReentrantLock。

  • JDK1.8使用红黑树来优化链表

按照1.8源码,可以确定get整个流程如下:

  public V get(Object key) {
        Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
        //1)首先根据key进行hash技术,如果table为空,直接返回null。否则定位到table[]中的i。
        int h = spread(key.hashCode());
        if ((tab = table) != null && (n = tab.length) > 0 &&
            (e = tabAt(tab, (n - 1) & h)) != null) {
            //2)若table[i]存在,则继续查找
            if ((eh = e.hash) == h) {// 2.1)首先比较链表头部,如果匹配则返回对应值。
                if ((ek = e.key) == key || (ek != null && key.equals(ek)))
                    return e.val;
            }
            else if (eh < 0)// 2.2)然后若为红黑树,查找树。否则就循环链表查找。
                return (p = e.find(h, key)) != null ? p.val : null;
            while ((e = e.next) != null) {// 循环链表查找
                if (e.hash == h &&
                    ((ek = e.key) == key || (ek != null && key.equals(ek))))
                    return e.val;
            }
        }
        return null;// 都找不到就返回为null
}

AtomicInteger

java8 提供的包 java.util.concurrent.atomic 包含了许多有用的类实现原子操作。原子操作是多个线程同时执行,确保其是安全的,且并不需要synchronized 关键字。这里介绍 AtomicInteger、AtomicBoolean, AtomicLong 和 AtomicReference,这里主要演示AtomicInteger类。

本质上,原子操作严重依赖于CAS,即比较与交换算法,如果只需要并发修改单个可变变量的情况下,优先使用原子类。

public static void main(String[] args){
  AtomicInteger ai = new AtomicInteger(10);
  for(int i = 0; i < 100; i++){
      new Thread( () -> ai.addAndGet(1) ).start();  
  }
  System.out.println(ai);   //是110。线程安全。
}

Callable

代码实现:

public static void main(String[] args){
  System.out.println("开始执行");
  //1.实现Callable接口,并通过Thread类启动
  FutureTask<String> result = new FutureTask<>(new Callable<String>() {
      @Override
      public String call() throws Exception {
          Thread.sleep(2000);
          return "睡了2s后...";
      }
  });
  new Thread(result).start();
​
  //2.get()等待线程执行结束,接收线程运算后的结果
  try {
      //也可以 result.canclel(true|false);
      String sum = result.get();    //此时main下面的方法是阻塞的   
      System.out.println(sum);
  } catch (Exception e) {
      e.printStackTrace();
  }
  System.out.println("执行完了");   //不get()的话,就直接执行完了
}

Callable与Runnable区别:

综上例子可以看到: Callable 和 Future接口的区别

(1)Callable是类似于Runnable的接口,实现Callable接口的类和实现Runnable的类都是可被其它线程执行的任务。

(2)Callable规定的方法是call(),有返回值,可以抛出异常;而Runnable规定的方法是run(),没有返回值,也不能抛出异常。

(3)运行Callable任务可拿到一个Future对象, 通过这个对象的isDone()方法可以检查call()是否完成,可以用cancel()方法取消任务的执行,还可以用get()方法获取任务执行的结果。

CountDownLatch

CountDownLatch概念

CountDownLatch是一个同步工具类,用来协调多个线程之间的同步,或者说起到线程之间的通信的作用。它能够让一个线程在等待另外一些线程完成各自工作之后,再继续执行。

它是使用一个计数器进行实现。计数器初始值为线程的数量。当每一个线程完成自己任务后,计数器的值就会减一。当计数器的值为0时,表示所有的线程都已经完成了任务,然后在CountDownLatch上等待的线程就可以恢复执行任务。

然后CountDownLatch是一次性的,计数器的值只能在构造方法中初始化一次,之后没有任何机制再次对其设置值,当CountDownLatch使用完毕后,它不能再次被使用。

CountDownLatch的用法

CountDownLatch典型用法1:某一线程在开始运行前等待n个线程执行完毕。将CountDownLatch的计数器初始化为n new CountDownLatch(n) ,每当一个任务线程执行完毕,就将计数器减1 countdownlatch.countDown(),当计数器的值变为0时,在CountDownLatch上 await() 的线程就会被唤醒。一个典型应用场景就是启动一个服务时,主线程需要等待多个组件加载完毕,之后再继续执行。

CountDownLatch典型用法2:实现多个线程开始执行任务的最大并行性。注意是并行性,不是并发,强调的是多个线程在某一时刻同时开始执行。类似于赛跑,将多个线程放到起点,等待发令枪响,然后同时开跑。做法是初始化一个共享的CountDownLatch(1),将其计数器初始化为1,多个线程在开始执行任务前首先 coundownlatch.await(),当主线程调用 countDown() 时,计数器变为0,多个线程同时被唤醒。

典型用法:new CountDownLatch(n) -> countdownlatch.countDown() -> countdownlatch.await() 代码如下

public static void main(String[] args){
  System.out.println("开始执行");
  final CountDownLatch latch = new CountDownLatch(1);
  new Thread(() -> {
      try {
          System.out.println("任务执行");
          Thread.sleep(2000);
          latch.countDown();
      } catch (Exception e) {
          e.printStackTrace();
      }
  }).start();
  //await()阻塞,知道latch调用countDown()至0.
  latch.await();
  System.out.println("执行完了");
}

CyclicBarrier

字面意思循环栅栏,通过它可以实现让一组线程等待至某个状态之后再全部同时执行。叫做循环是因为当所有等待线程都被释放以后,CyclicBarrier可以被重用。

第一个版本比较常用,用来挂起当前线程,直至所有线程都到达barrier状态再同时执行后续任务;

第二个版本是让这些线程等待至一定的时间,如果还有线程没有到达barrier状态就直接让到达barrier的线程执行后续任务。

public static void main(String[] args){
  CyclicBarrier cb = new CyclicBarrier(3);
  for(int i=0;i<3;i++){
      final int taskNum = i;
      new Thread(() -> {
          try {
              System.out.println("任务"+taskNum+"开始等待");
              Thread.sleep((taskNum+2)*1000);
              cb.await();
          } catch (Exception e) {
              e.printStackTrace();
          }
          System.out.println("任务"+taskNum+"继续执行其他事情");
      }).start();
  }
}

Semaphore

Semaphore翻译成字面意思为 信号量,Semaphore可以控制并行执行的线程个数,就是可以在Semaphore实例化的时候就指定线程的最大并行数。然后每个线程可以通过 acquire() 方法获取一个许可,如果没有获得线程就会等待,获得许可的线程执行完之后,使用release() 释放一个许可。然后处于等待状态的线程就可以继续拿到这个许可了。

有意思的是,如果没有acquire()就release()的话,就相当于最大并行数会随之调用而多了一个。

Semaphore semaphore = new Semaphore(3); //最多3个线程同时进行
for (int i = 0; i < 5; i++) {
    final int taskNum = i;
    new Thread(()-> {
        try {
            semaphore.acquire();    //取得1个信号。
            System.out.println("线程"+taskNum+"开工");
            Thread.sleep(3000);     //这里会看到,第三个线程之后,进不来,要等待释放了信号才能进来。
            semaphore.release();    //让出1个信号。
        } catch (Exception e) {
            e.printStackTrace();
        }
    }).start();;
}

ThreadLocal

ThreadLocal修饰的变量,会在每个线程中都创建一个副本,即每个线程内部都会有一个该变量,且在线程内部任何地方都可以使用,线程之间互不影响,这样一来就不存在线程安全问题,也不会严重影响程序执行性能。对于同一个static ThreadLocal,不同线程只能从中get,set,remove自己的变量,而不会影响其他线程的变量。所以特别适用于各个线程依赖不同的变量值完成操作的场景。最常见的ThreadLocal使用场景为 用来解决 数据库连接、Session管理等。每个ThreadLocal只能保存一个变量副本,如果想要上线一个线程能够保存多个副本以上,就需要创建多个ThreadLocal。它有四个方法:initialValue()、set()、 get()、 remove()。

ThreadLocal内部的ThreadLocalMap键为弱引用,会有内存泄漏的风险。

适用于无状态,副本变量独立后不影响业务逻辑的高并发场景。如果如果业务逻辑强依赖于副本变量,则不适合用ThreadLocal解决,需要另寻解决方案。

private static ThreadLocal<Connection> connectionHolder = new ThreadLocal<Connection>(){
  public Connection initialValue() {
      return DriverManager.getConnection(DB_URL);
  }
};
//如此依赖,每一个线程中获取到的connection都是它自己独享的。不会有线程安全问题。
public static Connection getConnection() {
    return connectionHolder.get();
}

猜你喜欢

转载自blog.csdn.net/HelloWorld_In_Java/article/details/82453617
今日推荐