凌晨四点的洛杉矶


  • 首页

  • 关于

  • 标签

  • 分类

  • 归档

  • 搜索

后端开发面经

发表于 2019-09-29 | 分类于 java面试准备 , 笔经面经 |

乐信集团

  1. protected关键字修饰的范围?

    答:

    private:只能被自身访问

    default(默认):同包可以访问

    protected:同包和不同包的子类可以访问

    public:可以被所有类访问

  2. final关键字有什么作用?abstract final修饰类的情形是否存在?

    答:

    • final类不能被继承,没有子类,final类中的方法默认是final的。
    • final方法不能被子类的方法覆盖,但可以被继承。
    • final成员变量表示常量,只能被赋值一次,赋值后值不再改变。
    • final不能用于修饰构造方法。

    final类型运用于数据:

    • 基本数据类型(int、double、char…)运用final时,使数值恒定不变;
    • 对象引用运用final时,final使得引用恒定不变,引用内部的数据若不是final型,可以进行修改。
    • 数组类型运用final时,final使得数组引用恒定不变,数组内部的数据若不是final型,可以进行修改。

    final与static:

    • final指明数据为一个常量,恒定无法修改;
    • static指明数据只占用一份存储区域;

    不存在,因为被final修饰的方法意味着该方法不可以被子类重写,这跟abstract的语义相互矛盾。

  3. i++在两个线程分别执行100次,最大值和最小值分别多少?为什么?如何使得i的值为200?

    答:

    i++不是原子操作,也就是说,它不是单独一条指令,而是3条指令:

    1、从内存中把i的值取出来放到CPU的寄存器中

    2、CPU寄存器的值+1

    3、把CPU寄存器的值写回内存

    注:

    注:对于多线程,线程共用一个内存,如果线程A在寄存器执行操作后而没有写入内存,则会切
    换到另一个线程。

    i的值的变化范围为2到200:

    • 结果为2的情况是:

      A线程执行第一次i++后,寄存器的值为1,但是没有刷新到主内存,这时候B线程开始执行第一次i++,寄存器的值也为1,也没有刷新到主内存。后面A线程开始执行完第99次i++操作后,寄存器为99,并且每次都刷新到主内存中去,此时内存的值为99。然后B线程接着执行完第一次i++操作,把1刷新到主内存中去,把99覆盖掉。这时,线程A开始执行自己的第100次i++操作,此时线程A读到内存中的值为1,存到寄存器然后++,此时寄存器的值为2,不刷新到内存里去,内存的值依旧为1。然后,线程B执行完剩余的i++操作,并且每次都刷新值到主内存中去,这时内存的值为100。然后线程A执行完第100次操作的剩余部分,将2刷新到主内存,这时候内存值为2,结束。

    • 结果为200的情况:

      线程A和线程B每次执行完i++操作都写入内存,并且每次操作交替进行。

如何使得i的值为200?

  • 锁机制。给方法添加synchronized关键字或者使用ReentrantLock都可以解决这个问题。这里可以拓展说下synchronized关键字和ReentrantLock的优劣。我认为一般使用synchronized更好,因为JVM团队一直以来都在优先改进这个机制,可以尽早获得更好的性能,并且synchronized对大多数开发人员来说更加熟悉,方便代码的阅读。

    • API层面:对于Synchronized来说,它是java语言的关键字,是原生语法层面的互斥,需要jvm实现。而ReentrantLock它是JDK 1.5之后提供的API层面的互斥锁,需要lock()和unlock()方法配合try/finally语句块来完成。

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      private ReentrantLock lock = new ReentrantLock();
      public void run() {
      lock.lock();
      try{
      for(int i=0;i<5;i++){
      System.out.println(Thread.currentThread().getName()+":"+i);
      }
      }finally{
      lock.unlock();
      }
      }
    • 等待可中断:当持有锁的线程长期不释放锁的时候,正在等待的线程可以选择放弃等待,改为处理其他事情。可等待特性对处理执行时间非常长的同步快很有帮助。

    • 可实现公平锁。公平锁是指多个线程在等待同一个锁时,必须按照申请的时间顺序来依次获得锁;而非公平锁则不能保证这一点。非公平锁在锁被释放时,任何一个等待锁的线程都有机会获得锁。synchronized的锁是非公平锁,ReentrantLock默认情况下也是非公平锁,但可以通过带布尔值的构造函数要求使用公平锁。

      1
      2
      3
      4
      5
      6
      7
      8
      9
      /**
      * Creates an instance of {@code ReentrantLock} with the
      * given fairness policy.
      *
      * @param fair {@code true} if this lock should use a fair ordering policy
      */
      public ReentrantLock(boolean fair) {
      sync = fair ? new FairSync() : new NonfairSync();
      }
    • ReentrantLock可以绑定多个condition对象,和多个条件关联。synchronized中,锁对象的wait()和notify()或notifyAll()方法可以实现一个隐含的条件。但如果要和多于一个的条件关联的时候,就不得不额外添加一个锁。

  • 使用AtomicInteger原子类。为什么AtomicInteger使用CAS完成?因为传统的锁机制需要陷入内核态,造成上下文切换,但是一般持有锁的时间很短,频繁的陷入内核开销太大,所以随着机器硬件支持CAS后,JAVA推出基于compare and set机制的AtomicInteger,实际上就是一个CPU循环忙等待。因为持有锁时间一般较短,内核开销较大,所以大部分情况CAS比锁性能更优。

    注意:volatile不能解决这个线程安全问题。因为volatile只能保证可见性,不能保证原子性。

    拓展:

    i++和++i的线程安全分为两种情况:

  1. 如果i是局部变量(在方法里定义的),那么是线程安全的。因为局部变量是线程私有的,别的线程访问不到,其实也可以说没有线程安不安全之说,因为别的线程对他造不成影响。
  2. 如果i是全局变量(类的成员变量),那么是线程不安全的。因为如果是全局变量的话,同一进程中的不同线程都有可能访问到。

    如果有大量线程同时执行i++操作,i变量的副本拷贝到每个线程的线程栈,当同时有两个线程栈以上的线程读取线程变量,假如此时是1的话,那么同时执行i++操作,再写入到全局变量,最后两个线程执行完,i会等于3而不会是2,所以,出现不安全性。

  1. 假设有一张数据库表tableA,字段分别为(name,age,city),使用SQl语言求每个城市的平均年龄。

    答:

    1
    select city,avg(age) as '平均年龄' from tableA group by city
  2. 假设有一张数据库表tableA,字段分别为(name,age,city),使用SQL语言求每个城市的第二大年龄是多少。(数据库保证每个城市至少有两条记录以上)

    答:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    /* 分组后并列排名:组内相同数值排名相同*/
    select s.name,s.city,s.age,s.rank FROM
    (
    select tablea.*,
    IF(city = @last_city,
    CASE
    WHEN age = @last_age THEN @rank
    ELSE @rank := @rank+1
    END,
    @rank := 1) as rank,
    @last_city := city,
    @last_age := age
    from tablea, (select @last_city := '', @last_age := '', @rank := 0) r
    order by city,age desc
    )as s where s.rank = 2
  3. 假设有一张数据库表tableA,字段分别为(name,age,city),使用SQl语言求纽约中年龄最大的人的姓名。

    答:

    1
    2
    3
    select name,age from tableA 
    where city = '纽约' and
    age = (select max(age) from tableA where city = '纽约')
  4. 有表Student(Sno,Sname,Sage,Ssex)学生表,表Course(Cno,Cname,Tno)课程表,表SC(Sno,Cno,score)成绩表,表Teacher(Tno,Tname)教师表

    • 统计列印各科成绩,各分数段人数:课程ID,课程名

      1
      2
      3
      4
      5
      6
      select c.Cno,c.Cname,
      count(case when s.score between 85 and 100 then 1 end) as '[100-85分]',
      count(case when s.score between 70 and 85 then 1 end) as '[85-70分]',
      count(case when s.score between 60 and 70 then 1 end) as '[70-60分]',
      count(case when s.score<60 then 1 end) as '[60分以下]' from Course c, SC s where c.Cno = s.Cno
      group by c.Cno,c.Cname
    • 查询两门以上不及格课程的同学的学号及其平均成绩

      1
      2
      3
      4
      5
      6
      select Sno,avg(score) from SC where Sno in (
      select Sno
      from SC
      where score < 60
      group by Sno having count(*) >= 2
      ) group by Sno
  5. Java中的子类是否要实现父类所有的抽象方法?

    答:

    • 子类如果是非抽象类的话,那么一定要实现父类中所有的抽象方法;
    • 子类也是抽象类,那么可以不实现父类中所有的抽象方法,可以实现一部分抽象方法。
  6. Java的抽象类中可以定义final方法吗?Java的抽象类可以被private修饰吗?

    答:

    可以。但是final是不能修饰abstract所修饰的方法的。

    • Java抽象类是内部类时,可以被private修饰。
    • Java抽象类不是内部类时,不可以被private修饰。

中信银行信用卡

  1. Spring Boot和Spring MVC的区别?
  2. 如果想引入一个jar包,如何在Spring MVC中配置xml文件?
  3. 在数据库表中建立一个字段,只有一个字节的长度,用varchar还是char好?为什么?
  4. 多线程实现的几种方式?
  5. 线程的几种状态和切换的方式?
  6. Linux用过吗?了解哪些命令?
  7. 一个http请求比较慢的时候,应该从哪些方面去排查并且优化?
  8. 当并发请求较多时如何进行优化处理?

4399

  1. 什么是守护线程,实现守护线程的基本流程和原理是什么?

  2. HashMap线程不安全的体现?

  3. HashSet底层实现原理

  4. redis数据存储在内存还是磁盘?

  5. Nosql用过哪些?

  6. ConcurrentHashmap加锁体现在哪里?

  7. 数据库分区分表如何实现?

  8. Java Bean和普通的类有什么区别

  9. 如何读取Spring Boot配置文件中的属性?(有哪些注解)

  10. Synchronized关键字底层语义原理

    答:

    之所以这个synchronized关键字会起带同步的作用,就是因为monitor。monitor只是一个简称,通常称为intrinsic lock或者monitor lock。

    每个对象都有一个monitor lock与之相关联,如果一个线程需要排他和一致访问对象的某个域,它就必须先获得对象的monitor lock,当它完成任务之后再释放这个锁,让其它线程可以访问到。只要一个线程获得了一个monitor lock,那么其它线程就没办法获取到这个锁。也就是说,当其它线程尝试获得锁的时候就会阻塞。

    Synchronized进行编译,会在同步块的前后分别形成monitorenter和monitorexit这个两个字节码指令。在执行monitorenter指令时,首先要尝试获取对象锁。如果这个对象没被锁定,或者当前线程已经拥有了那个对象锁,把锁的计算器加1。相应的,在执行monitorexit指令时会将锁计算器就减1,当计算器为0时,锁就被释放了。如果获取对象锁失败,那当前线程就要阻塞,直到对象锁被另一个线程释放为止。

    在方法执行期间,执行线程持有了monitor,其他任何线程都无法再获得同一个monitor。如果一个同步方法执行期间抛出了异常,并且方法内部无法处理此异常,那这个同步方法所持有的monitor将在异常抛到同步方法之外时自动释放。

    无论方法是正常结束还是异常结束,方法中调用过的每条monitorenter指令都有执行其对应monitorexit指令。

    注意:Synchronized是一种可重入锁。

    synchronized的可重入性

    从互斥锁的设计上来说,当一个线程试图操作一个由其他线程持有的对象锁的临界资源时,将会处于阻塞状态,但当一个线程再次请求自己持有对象锁的临界资源时,这种情况属于重入锁,请求将会成功,在java中synchronized是基于原子性的内部锁机制,是可重入的,因此在一个线程调用synchronized方法的同时在其方法体内部调用该对象另一个synchronized方法,也就是说一个线程得到一个对象锁后再次请求该对象锁,是允许的,这就是synchronized的可重入性。如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    public class AccountingSync implements Runnable{
    static AccountingSync instance=new AccountingSync();
    static int i=0;
    static int j=0;
    @Override
    public void run() {
    for(int j=0;j<1000000;j++){

    //this,当前实例对象锁
    synchronized(this){
    i++;
    increase();//synchronized的可重入性
    }
    }
    }

    public synchronized void increase(){
    j++;
    }


    public static void main(String[] args) throws InterruptedException {
    Thread t1=new Thread(instance);
    Thread t2=new Thread(instance);
    t1.start();t2.start();
    t1.join();t2.join();
    System.out.println(i);
    }
    }

    正如代码所演示的,在获取当前实例对象锁后进入synchronized代码块执行同步代码,并在代码块中调用了当前实例对象的另外一个synchronized方法,再次请求当前实例锁时,将被允许,进而执行方法体代码,这就是重入锁最直接的体现,需要特别注意另外一种情况,当子类继承父类时,子类也是可以通过可重入锁调用父类的同步方法。

    注意:由于synchronized是基于monitor实现的,因此每次重入,monitor中的计数器仍会加1。

    Java虚拟机对synchronized的优化

    锁的状态总共有四种,无锁状态、偏向锁、轻量级锁和重量级锁。随着锁的竞争,锁可以从偏向锁升级到轻量级锁,再升级的重量级锁,但是锁的升级是单向的,也就是说只能从低到高升级,不会出现锁的降级。

    • 自旋锁
    • 锁粗化:
      • 偏向锁
      • 轻量级锁
      • 重量级锁
    • 锁消除:消除锁是虚拟机另外一种锁的优化,这种优化更彻底,Java虚拟机在JIT编译时(可以简单理解为当某段代码即将第一次被执行时进行编译,又称即时编译),通过对运行上下文的扫描,去除不可能存在共享资源竞争的锁,通过这种方式消除没有必要的锁,可以节省毫无意义的请求锁时间。
  11. ReentrantLock和synchronized使用分析?

    答:

    ReentrantLock是Lock的实现类,是一个互斥的同步器,在多线程高竞争条件下,ReentrantLock比synchronized有更加优异的性能表现。

    1、用法比较

    • Lock使用起来比较灵活,但是必须有释放锁的配合动作
    • Lock必须手动获取与释放锁,而synchronized不需要手动释放和开启锁
    • Lock只适用于代码块锁,而synchronized可用于修饰方法、代码块等

    2、特性比较

    • ReentrantLock的优势体现在:
      • 具备尝试非阻塞地获取锁的特性:当前线程尝试获取锁,如果这一时刻锁没有被其他线程获取到,则成功获取并持有锁
      • 能被中断地获取锁的特性:与synchronized不同,获取到锁的线程能够响应中断,当获取到锁的线程被中断时,中断异常将会被抛出,同时锁会被释放
      • 超时获取锁的特性:在指定的时间范围内获取锁;如果截止时间到了仍然无法获取锁,则返回

    3、注意事项

    • 在使用ReentrantLock类的时,一定要注意三点:
      • 在finally中释放锁,目的是保证在获取锁之后,最终能够被释放
      • 不要将获取锁的过程写在try块内,因为如果在获取锁时发生了异常,异常抛出的同时,也会导致锁无故被释放。
      • ReentrantLock提供了一个newCondition的方法,以便用户在同一锁的情况下可以根据不同的情况执行等待或唤醒的动作。
  12. 什么是覆盖索引?覆盖索引的优点?覆盖索引的使用场景?

    答:

    通常大家都会根据查询的where条件来创建合适的索引,不过这只是索引优化的一个方面。索引确实是一种查找数据的高效方式,但是mysql也可以使用索引来直接获取列的数据,这样就不再需要读取数据行。如果索引的叶子节点中已经包含要查询的数据,那么还有什么必要再回表查询呢?

    如果一个索引包含或者说覆盖所有需要查询的字段的值,我们就称之为“覆盖索引”。

    覆盖索引的优点

    • 索引项通常远小于记录,所以如果只需读取索引,那mysql就会极大的减少数据访问量。这对缓存的负载非常重要,因为这种情况下响应时间大部分花费在数据拷贝上。覆盖索引对于I/O密集型的应用也很有帮助,因为索引比数据更小,更容易全部放入内存中。
    • 因为索引是按照列值顺序存储的,所以对于I/O密集型的范围查询会比随机从磁盘读取每一行数据的I/O要少的多。对于某些存储引擎,例如MyISAM,甚至可以通过optimize命令使得索引完全顺序排列,这让简单的范围查询能使用完全顺序的索引访问。
    • 一些存储引擎如MyISAM在内存中只缓存索引,数据则依赖于操作系统来缓存,因此要访问数据需要一次系统调用。这可能会导致严重的性能问题,尤其是那些系统调用占用了数据访问中的最大开销的场景。
    • 由于InnoDB的聚簇索引,覆盖索引对InnoDB特别有用。InnoDB的二级索引在叶子节点中保存了数据行的主键值,所以如果二级索引能够覆盖查询,则可以避免对主键索引的二次查询。

    判断标准

    使用explain,可以通过输出的extra列来判断,对于一个索引覆盖查询,显示为using index,MySQL查询优化器在执行查询前会决定是否有索引覆盖查询。

字节跳动后端开发面经

发表于 2019-07-10 | 分类于 java面试准备 , 笔经面经 |

提前批

No.1

  1. Java多态的原理?

    答:参考:

    ​ https://blog.csdn.net/SEU_Calvin/article/details/52191321

    ​ https://zhuanlan.zhihu.com/p/27912079

  2. Java 中接口和抽象类的区别?

    答:

    • 抽象类中可以有普通的成员变量,而接口中没有。
    • 抽象类中可以包含静态方法,而接口不可以。
    • 如果抽象类实现接口,则可以把接口中方法映射到抽象类中作为抽象方法而不必实现,而在抽象类的子类中实现接口中方法
    • 抽象类只能单继承,而接口可以被多个类实现。
    • 抽象类中可以有构造方法,而接口中不可以。
    • 抽象类可以但不是必须有抽象属性和抽象方法,但是一旦有了抽象方法,就一定要把这个类声明为抽象类。
    • 在传统版本上,接口中的所有方法必须是非静态的,且是abstract的,且是public的。普通方法可以不写修饰符,也会默认为public和abstract。但在java版本1.8中,你可以为方法添加默认方法,这时候实现类不继承该方法也是可以编译通过的。
  3. Java中四种引用的关系?

    答:

    • 强引用:强引用是最普遍的引用,如果一个对象具有强引用,垃圾回收器不会回收该对象,当内存空间不足时,JVM 宁愿抛出 OutOfMemoryError异常;只有当这个对象没有被引用时,才有可能会被回收。

    • 软引用:如果一个对象只具有软引用,则

      • 当内存空间足够,垃圾回收器就不会回收它。
      • 当内存空间不足了,就会回收该对象;
      • JVM会优先回收长时间闲置不用的软引用的对象,对那些刚刚构建的或刚刚使用过的“新”软引用对象会尽可能保留;
      • 如果回收完还没有足够的内存,才会抛出内存溢出异常。只要垃圾回收器没有回收它,该对象就可以被程序使用。

      软引用可以和一个引用队列(ReferenceQueue)联合使用。如果软引用所引用对象被垃圾回收,JAVA虚拟机就会把这个软引用加入到与之关联的引用队列中。

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      ReferenceQueue<String> referenceQueue = new ReferenceQueue<>();
      String str = new String("abc");
      SoftReference<String> softReference = new SoftReference<>(str, referenceQueue);

      str = null;
      // Notify GC
      System.gc();

      System.out.println(softReference.get()); // abc

      Reference<? extends String> reference = referenceQueue.poll();
      System.out.println(reference); //null

      注意:软引用对象是在jvm内存不够的时候才会被回收,我们调用System.gc()方法只是起通知作用,JVM什么时候扫描回收对象是JVM自己的状态决定的。就算扫描到软引用对象也不一定会回收它,只有内存不够的时候才会回收。

      应用场景:

      浏览器的后退按钮。按后退时,这个后退时显示的网页内容是重新进行请求还是从缓存中取出呢?这就要看具体的实现策略了。

      1. 如果一个网页在浏览结束时就进行内容的回收,则按后退查看前面浏览过的页面时,需要重新构建;
      2. 如果将浏览过的网页存储到内存中会造成内存的大量浪费,甚至会造成内存溢出。

      这时候就可以使用软引用,很好的解决了实际的问题:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      // 获取浏览器对象进行浏览
      Browser browser = new Browser();
      // 从后台程序加载浏览页面
      BrowserPage page = browser.getPage();
      // 将浏览完毕的页面置为软引用
      SoftReference softReference = new SoftReference(page);

      // 回退或者再次浏览此页面时
      if(softReference.get() != null) {
      // 内存充足,还没有被回收器回收,直接获取缓存
      page = softReference.get();
      } else {
      // 内存不足,软引用的对象已经回收
      page = browser.getPage();
      // 重新构建软引用
      softReference = new SoftReference(page);
      }
    • 弱引用:弱引用与软引用的区别在于:只具有弱引用的对象拥有更短暂的生命周期,它只能生存到下一次垃圾收集发生之前。当垃圾回收器扫描到只具有弱引用的对象时,无论当前内存空间是否足够,都会回收它。不过,由于垃圾回收器是一个优先级很低的线程,因此不一定会很快发现那些只具有弱引用的对象。

      注意:如果一个对象是偶尔(很少)的使用,并且希望在使用时随时就能获取到,但又不想影响此对象的垃圾收集,那么你应该用Weak Reference来记住此对象。

    • 虚引用:虚引用顾名思义,就是形同虚设。与其他几种引用都不同,虚引用并不会决定对象的生命周期。如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收器回收。

      应用场景:

      虚引用主要用来跟踪对象被垃圾回收器回收的活动。 虚引用与软引用和弱引用的一个区别在于:

      虚引用必须和引用队列(ReferenceQueue)联合使用。当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象的内存之前,把这个虚引用加入到与之关联的引用队列中。

      1
      2
      3
      4
      String str = new String("abc");
      ReferenceQueue queue = new ReferenceQueue();
      // 创建虚引用,要求必须与一个引用队列关联
      PhantomReference pr = new PhantomReference(str, queue);

      程序可以通过判断引用队列中是否已经加入了虚引用,来了解被引用的对象是否将要进行垃圾回收。如果程序发现某个虚引用已经被加入到引用队列,那么就可以在所引用的对象的内存被回收之前采取必要的行动。

      下面通过一张表格来说明它们的回收时间、用途:

      z1

      参考:https://juejin.im/post/5b82c02df265da436152f5ad#heading-3

  4. Java多线程实现的几种方式?Runnable接口有哪些优势?

    答:

    • 通过继承Thread类来实现

    • 通过实现Runnable接口

    • 通过内部类实现(有些情况我们的线程就想执行一次,以后就用不到了)

    • 基于线程池的方式实现

    • 带有返回值的线程实现方式,步骤如下:

      • 创建一个类实现Callable接口,实现call方法。这个接口类似于Runnable接口,但比Runnable接口更加强大,增加了异常和返回值。
      • 创建一个FutureTask,指定Callable对象,做为线程任务。
      • 创建线程,指定线程任务
      • 启动线程

      Runnable接口的实现优势有:

      • 使用接口的方式可以让我们的程序降低耦合度,Runnable就是一个线程任务,线程任务和线程的控制分离,那么一个线程任务可以提交给多个线程来执行。比如车站的售票窗口,每个窗口可以看做是一个线程,他们每个窗口做的事情都是一样的,也就是售票。这样我们程序在模拟现实的时候就可以定义一个售票任务,让多个窗口同时执行这一个任务。那么如果要改动任务执行计划,只要修改线程任务类,所有的线程就都会按照修改后的来执行。相比较继承Thread类的方式来创建线程的方式,实现Runnable接口是更为常用的。
      • java不允许多继承,因此实现了Runnable接口的类可以再继承其他类。

      参考:https://blog.csdn.net/king_kgh/article/details/78213576

  5. Java中堆栈的区别?堆栈的增长方向有哪些不同?

    答:

    • 堆中主要存放new出来的实例变量和数组,栈描述的是Java方法执行的内存模型,而栈是由一个个栈帧组成,而每个栈帧中都拥有:局部变量表、操作数栈、动态链接、方法出口信息。局部变量表主要存放了编译器可知的各种数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference类型,它不同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此对象相关的位置)。
    • 对比堆和栈,只要把握一个重要的信息,new的对象是存储在堆中的,但为了存取方便,会在栈中声明一个引用变量指向堆中的对象,这样存取方便而且速度快。另一方面,栈中的对象超过作用域即被释放,Java会立即释放掉内存空间另作他用;而堆中即使超出变量的作用范围也不会变成垃圾,只有在没有引用变量指向它时会变成垃圾,但又不会立刻被回收,只有在一个不确定的时间才会被垃圾回收器回收掉,这也就是为什么Java会比较占用内存的原因了。再一点还要注意,就是被static修饰的变量,它是在程序启动之初就会被分配内存,它的生命周期会一直到程序结束,所以使用static一定要慎重。

    栈的增长方向是从高地址到地址,堆的增长方向是从低地址到高地址(简化的Linux/x86模型)。

  6. 输入一个url,发生了什么?

    答:

    1. 浏览器查询DNS,获取域名对应的IP地址,即是服务器的IP地址。其中查询DNS的过程先对浏览器自身DNS缓存的查询,浏览器自身没有缓存对应的DNS,就去查询操作系统中的DNS缓存,操作系统中没有,就去读取本地的Host文件。如果上述都找不到对应的DNS缓存,就去本地域名服务器查询对应的DNS缓存。
    2. 浏览器获取到域名对应的IP地址后,就向服务器发起三次握手,建立连接。
    3. TCP/IP连接建立后,浏览器向服务器发送HTTP请求。
    4. 服务器收到请求后,根据路径参数映射到特定的请求处理器进行处理,并将处理结果及响应的视图返回给浏览器。
    5. 浏览器解析视图,如果遇上js、css等文件的引用,则继续向服务器发送资源请求。
    6. 浏览器根据资源、数据进行渲染,呈现一个完整的页面。
    7. 最终,浏览器向服务器发起四次挥手,关闭连接。

    这个简单的过程涉及到的协议有:DNS协议、TCP协议、IP协议、OSPF路由选择协议、ARP协议、HTTP协议。

  7. ping的原理是什么?

    答:

    z3

    PING过程的两种情况,一种是在同一网段内,一种是在不同网段内:

    • 在同一网段内(主机Aping主机B):

      • 如果主机A当中缓存有主机B的MAC地址(即主机A当中的MAC地址表有B的MAC地址),就会直接将这个MAC地址封装到ICMP报文中向主机B发送,当主机B收到了这个报文后,发现是主机A的ICPM回显请求,就按同样的格式,将ICMP的回显报文发送回去给主机A,这样就完成了同一网段内的ping过程。

      • 如果主机A的MAC地址表没有B的MAC地址,则会向外发送一个ARP广播报文。交换机会收到这个报文后,交换机有学习MAC地址的功能,所以他会检索自己有没有保存主机B的MAC地址,如果有,就返回给主机A,如果没有,就会向所有端口发送ARP广播,其它主机收到后,发现不是在找自己,就纷纷丢弃了该报文,不去理会。直到主机B收到了报文后,就立即响应,我的MAC地址是多少,同时学到主机A的MAC地址,并按同样的ARP报文格式返回给主机A。这时候主机A学习到了主机B的MAC地址,就把这个MAC地址封装到ICMP协议的二层报文中向主机B发送。当主机B收到了这个报文后,发现是主机A的ICPM回显请求,就按同样的格式,将ICMP的回显报文发送回去给主机A,这样就完成了同一网段内的ping过程。

      • 下面是ICMP报文和ICMP回显报文:

        z2

    • 在不同网段内(主机Aping主机C),如果主机A要ping主机C,那么主机A发现主机C的IP和自己不是同一网段,他就去找网关转发:

      • 如果主机A的MAC地址表中有网关的MAC地址,则直接将这个MAC地址封装成ICMP报文发送给网关路由器。z5当路由器收到主机A发过来的ICMP报文,发现目的地址是其本身的MAC地址,根据目的IP2.1.1.1,查路由表,发现2.1.1.1/24的路由表项,得到一个出口指针,这个出口指针意指主机C的MAC地址。然后去掉原来的MAC头部,加上自己的MAC地址向主机C转发(如果网关也没有主机C的MAC地址,还是要像前面一个步骤一样,ARP广播一下即可相互学习。路由器2端口能学到主机C的MAC地址,主机C也能学到路由器2端口的MAC)。最后,在主机C已学到路由器2端口MAC,路由器2端口转发给路由器1端口,路由1端口学到主机A的MAC的情况下,他们就不需要再做ARP解析,就将ICMP的回显报文发送回去。

      • 如果主机A的MAC地址表没有网关的MAC地址,会先发送一个ARP广播,学到网关的MAC,再发封装ICMP报文给网关路由器。z5后面的过程跟上述一样。

      • 下面是ICMP报文和ICMP回显报文:

        z4

    参考:

    https://blog.csdn.net/f2006116/article/details/51159895

    https://baike.baidu.com/item/ICMP

    https://zh.wikipedia.org/wiki/%E5%9C%B0%E5%9D%80%E8%A7%A3%E6%9E%90%E5%8D%8F%E8%AE%AE

  8. http1.0和http1.1之间有什么区别?

    答:

    • HTTP1.1支持长连接和请求的流水线处理,并且默认使用长连接。而HTTP1.0默认使用短连接,需要在request中增加“Connection:keep-alive”,header才能支持长连接。
      • 流水线的方式处理请求:客户端每遇到一个对象引用就立即发出一个请求,而不必等到收到前一个响应之后才能发出下一个请求。
    • 分块传输数据:发送方将消息实体分割成为任意大小的组块,并单独地发送。在每个组块之前,加上该组块的长度,使接收方可以确保自己能够完整地接收到这个组块。并且,在组块最末尾的地方,发送方生成了长度为零的组块,接收方可据此判断整条消息是否已安全地传输完毕。
    • HTTP1.1新增了一个状态码 100 Continue,用于客户端在发送POST数据给服务器前,征询服务器的情况,看服务器是否处理POST的数据。
  9. HTTP的请求头里都包含了些什么?HTTP如何发起请求?

    答:

    HTTP报文由3部分组成(请求行+请求头+请求体)

    z1

    • 请求行:包括请求方法、请求URI、HTTP请求的协议及版本。

      方法-URI-协议/版本:如GET /index.jsp HTTP/1.1

      • GET就是请求方法,根据HTTP标准,HTTP协议请求可以使用多种请求方法。HTTP 1.1支持七种请求方法:GET、POST、HEAD、OPTIONS、PUT、DELETE和TRACE等。常用的为请求方法是GET和POST。
      • /index.jsp表示URI,URI指定了要访问的网络资源。
      • HTTP/1.1是协议和协议的版本。
    • 请求头:即HTTP请求的报文头。报文头包含若干个属性,格式为“属性名:属性值”(键值对),服务端据此获取客户端的信息。

      z8

      z9

    • 请求体:即HTTP请求的报文体。它将一个页面表单中的组件值通过param1=value1&param2=value2的键值对形式编码成一个格式化串,它承载多个请求参数的数据。不但报文体可以传递请求参数,请求URL也可以通过类似于“/chapter15/user.html? param1=value1&param2=value2”的方式传递请求参数。

      z2

      下面列举常见的HTTP请求报文头属性

      • Accept:请求报文可通过一个“Accept”报文头属性告诉服务端 客户端接受什么类型的响应。

        如下报文头相当于告诉服务端,俺客户端能够接受的响应类型仅为纯文本数据啊,你丫别发其它什么图片啊,视频啊过来,那样我会歇菜的~~~:

        1
        Accept:text/plain
      • Cookie:客户端的Cookie就是通过这个报文头属性传给服务端的哦!如下所示:

        1
        Cookie: $Version=1; Skin=new;jsessionid=5F4771183629C9834F8382E23BE13C4C

        服务端是怎么知道客户端的多个请求是隶属于一个Session呢?

        注意到后台的那个jsessionid=5F4771183629C9834F8382E23BE13C4C木有?原来就是通过HTTP请求报文头的Cookie属性的jsessionid的值关联起来的!(当然也可以通过重写URL的方式将会话ID附带在每个URL的后面哦)

      • Referer:表示这个请求是从哪个URL过来的。

        假如你通过google搜索出一个商家的广告页面,你对这个广告页面感兴趣,鼠标一点发送一个请求报文到商家的网站,这个请求报文的Referer报文头属性值就是http://www.google.com。

        唐僧到了西天.
        如来问:侬是不是从东土大唐来啊?
        唐僧:厉害!你咋知道的!
        如来:哈哈,我偷看了你的Refere。

      • Cache-Control:对缓存进行控制,如一个请求希望响应返回的内容在客户端要被缓存一年,或不希望被缓存就可以通过这个报文头达到目的。

        如以下设置,相当于让服务端将对应请求返回的响应内容不要在客户端缓存:

        1
        Cache-Control: no-cache
      • 其他请求报文头属性:

        参见:http://en.wikipedia.org/wiki/List_of_HTTP_header_fields

      如何访问请求报文头

      由于请求报文头是客户端发过来的,服务端当然只能读取了。

      以下是HttpServletRequest一些用于读取请求报文头的API:

      1
      2
      3
      4
      5
      1. //获取请求报文中的属性名称  
      2. java.util.Enumeration<java.lang.String> getHeaderNames();
      3.
      4. //获取指定名称的报文头属性的值
      5. java.lang.String getHeader(java.lang.String name)

      由于一些请求报文头属性“太著名”了,因此HttpServletRequest为它们提供了VIP的API:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10


      1. //获取报文头中的Cookie(读取Cookie的报文头属性)
      2. Cookie[] getCookies() ;
      3.
      4. //获取客户端本地化信息(读取 Accept-Language 的报文头属性)
      5. java.util.Locale getLocale()
      6.
      7. //获取请求报文体的长度(读取Content-Length的报文头属性)
      8. int getContentLength();

      HttpServletRequest可以通过

      1
      HttpSession getSession()

      获取请求所关联的HttpSession,其内部的机理是通过读取请求报文头中Cookie属性的JSESSIONID的值,在服务端的一个会话Map中,根据这个JSESSIONID获取对应的HttpSession的对象。

    HTTP是如何发起请求的:

    DNS域名解析、三次握手建立连接、发起HTTP请求。

    参考:

    https://blog.csdn.net/u010256388/article/details/68491509

    https://blog.csdn.net/yezitoo/article/details/78193794

  10. HashMap的实现原理?

    答:

    • JDK1.7:HashMap底层数据结构为数组+链表,使用一个Entry数组存储数据,用key的hash值取模来决定key会被存放数组中的哪个位置,而hash值由key的hashcode通过某些运算得来。如果key的hash值取模后的结果相同,那些这些key会被放到数组中的同一个位置,这时候就会发生冲突。因此,相同的元素在同一个数组位置会形成链表。在hashcode特别差的情况下,冲突会频繁发生,这时候形成的链表就会很长,那么put/get操作可能会遍历整个链表,时间复杂度会退化到O(n)。
    • JDK1.8:HashMap底层数据结构为数组+链表+红黑树,使用Node数组存储数据,但这个Node结点可能是链表结构也可能是红黑树结构;与JDK1.7不同的是,如果插入同一位置的元素超过8个,就会调用treeifyBin函数,将链表转换为红黑树。那么即使hashcode完全相同,由于红黑树的特点,查找某个特定原色,也只需要O(logn)的开销,也就是说put/get操作的时间复杂度最差只有O(logn)。
  11. 进程的五种状态以及如何进行切换的?

    答:

    z7

    • 就绪 -> 运行:对就绪状态的进程,当进程调度程序按一种选定的策略从中选中一个就绪进程,为之分配了CPU(也叫处理机)后,该进程便由就绪状态变为运行状态;
    • 运行 -> 阻塞:正在执行的进程因发生某等待事件而无法执行,则进程由执行状态变为阻塞状态。如下:
      • 进程提出输入/输出请求(IO请求)而变成等待外部设备传输信息的状态;
      • 进程申请资源(主存空间或外部设备)得不到满足时变成等待资源状态;
      • 进程运行中出现了故障(程序出错或主存储器读写错等)变成等待干预状态等等。
    • 阻塞 -> 就绪:处于阻塞状态的进程,在其等待的事件已经发生,如输入/输出操作完成,资源得到满足或错误处理完毕时,处于等待状态的进程并不马上转入运行状态,而是先转入就绪状态,然后再由系统进程调度程序在适当的时候将该进程转为运行状态;
    • 运行 -> 就绪:正在执行的进程,因时间片用完而被暂停执行,或在采用抢先式优先级调度算法的系统中,当有更高优先级的进程要运行而被迫让出处理机时,该进程便由执行状态转变为就绪状态。
  12. 进程之间的通信方式?

    答:

    • 管道(pipe):管道是一种半双工的通信方式,数据只能单向流动,而且只能在具有血缘关系的进程间使用。进程的血缘关系通常指父子进程关系。
    • 有名管道(named pipe):有名管道也是半双工的通信方式,但是它允许无亲缘关系进程间通信。
    • 信号量(semophore):信号量是一个计数器,可以用来控制多个进程对共享资源的访问。它通常作为一种锁机制,防止某进程正在访问共享资源时,其他进程也访问该资源。因此,主要作为进程间以及同一进程内不同线程之间的同步手段。
    • 消息队列(message queue):消息队列是由消息组成的链表,存放在内核中,并由消息队列标识符标识。消息队列克服了信号传递信息少,管道只能承载无格式字节流以及缓冲区大小受限等缺点。
    • 信号(signal):信号是一种比较复杂的通信方式,用于通知接收进程某一事件已经发生。
    • 共享内存(shared memory):共享内存就是映射一段能被其他进程所访问的内存,这段共享内存由一个进程创建,但多个进程都可以访问。共享内存是最快的IPC方式,它是针对其他进程间的通信方式运行效率低而专门设计的。它往往与其他通信机制,如信号量配合使用,来实现进程间的同步和通信。
    • 套接字(socket):套接口也是一种进程间的通信机制,与其他通信机制不同的是它可以用于不同及其间的进程通信。
  13. 电梯调度算法?

    答:

    • 先来先服务(FIFO)
    • 最短寻道时间优先(SSTF)
    • 扫描算法:
      • SCAN算法:也就是很形象的电梯调度算法。比如磁盘扫描,先按照一个方向,从当前磁道向内磁道方向访问(磁道减少的方向),当访问到最内层的磁道号以后,就会反向,向着磁道增加的方向继续访问未访问过的磁道号。这就好比坐电梯,中间一直往下面接人,下面没人的时候反向,向上去接人。
      • CSCAN算法:循环扫描算法,同SCAN算法不同的是,这个算法扫描到最里面的磁道后,会立即跳到最外层的磁道,然后按照原来访问的方向去扫描。故也称单向扫描调度算法。
  14. String、StringBuffer、StringBuilder的区别?

    答:

    • 首先明确一点,String是不可变的对象,而StringBuffer和StringBuilder是可变的字符序列,他们两个都是类似于String的字符串缓冲区。两者不同的是StringBuffer是线程安全的,而StringBuilder是非线程安全的。
    • 每次对String类型的对象进行改变的时候其实都等于新生成了一个新的String对象,然后将栈里的指针(引用)指向了新的String对象。而如果是使用StringBuffer或者StringBuilder的话,每次改变都是对原有对象本身进行操作。
    • 初始化的方式不同,StringBuffer和StringBuilder只能用构造函数的形式进行初始化,而String对象除了可以用构造函数进行初始化以外,还可以直接赋值。
  15. new一个String对象会产生几个对象?

    答:

    这种情况首先应该明白堆栈存储的是字符串对象和字符串的对象引用,而字符串常量池是在方法区中。

    下面我们看一段代码:

    1
    2
    3
    4
    5
    String str1 = “abc”;
    String str2 = “abc”;
    String str3 = “abc”;
    String str4 = new String(“abc”);
    String str5 = new String(“abc”);

    z3

    分析:我们在new str4这个对象的时候,会先去常量池中查找是否有“abc”这个字面量。如果有,则不做任何事情,没有则创建对应的常量对象。然后会在堆中new一个String对象,通过new操作符创建的字符串对象不指向字符串池中的任何对象,但是可以通过使用字符串的intern()方法来指向其中的某一个。最后,将堆中对象的地址赋值给str4,创建一个引用。(上图中堆和常量池中的指向只是一种对应关系)

    所以,常量池中没有“abc”字面量则创建两个对象,否则创建一个对象,以及创建一个引用。

    拓展:

    根据字面量,往往会提出这样的变式题:

    String str1 = new String(“A”+”B”) ; 会创建多少个对象?
    String str2 = new String(“ABC”) + “ABC” ; 会创建多少个对象?

    str1:
    字符串常量池:”A”,”B”,”AB” : 3个
    堆:new String(“AB”) :1个
    引用: str1 :1个
    总共 : 4个

    str2 :
    字符串常量池:”ABC” : 1个
    堆:new String(“ABC”) :1个
    引用: str2 :1个
    总共 : 2个

    参考:https://segmentfault.com/a/1190000009888357

  16. JDK动态代理和CGLIB动态代理的区别?

    答:

    • JDK动态代理的前提是目标类有要实现的接口,而CGLIB动态代理的前提是目标类不能被final修饰,因为被final修饰的类不能被继承。由于CGLIB动态代理的实质是动态生成的子类继承了目标类,在运行期动态地在内存中创建一个子类。

    • JDK动态代理的实质是动态地生成一个类去实现目标类所实现的接口,而CGLIB动态代理的实质是动态生成的子类继承了目标类,在运行期动态地在内存中创建一个子类。

    • JDK动态代理是不需要第三方库支持的,只需要JDK环境就可以代理,创建JDK代理工厂的条件:

      • 代理工厂类实现InvocationHandler接口;

      • 使用java.lang.reflect包中的Proxy类提供的newProxyInstance方法来生成代理对象;

        1
        Proxy.newProxyInstance(ClassLoader loader, Class<?>[] interfaces, InvocationHandler h)
      • 被代理类(目标类)一定要实现接口。

    • CGLIB的实现依赖于cglib类库,创建CGLIB代理工厂的条件:

      • 通过cglib类库提供的Enhancer类的create静态方法来创建代理类;

        1
        Enhancer.create(Class type, Callback callback)
        • type是原对象的Class对象
        • callback是回调方法接口
      • cglib类库中的callback方法通过实现它的MethodInterceptor接口的intercept方法来进行回调,因此代理工厂类需要实现MethodInterceptor接口:

        1
        public Object intercept(Object obj, java.lang.reflect.Method method, Object[] args, MethodProxy proxy) throws Throwable;
        • obj是被代理的对象
        • method是执行的方法
        • args是执行方法的参数数组
        • proxy用来执行未被拦截的原方法
      • 被代理类(目标类)不能被final所修饰。

    参考:https://blog.csdn.net/yhl_jxy/article/details/80635012

  17. Spring AOP的实现原理是什么?

    答:

    其实真要说AOP的实现原理,很难说的上来,但是我知道动态代理类的生成是依据JVM反射等机制动态生成的,并且代理类和委托类的关系是在运行时才确定的。而如果是从动态代理的两种方式的来讲,无非是看JDK和CGLIB动态代理是怎么实现的。(Spring AOP使用的是动态代理)接下来就要去看两种方式的实现区别和实现原理,并且清除的阐述代理模式是怎么运作的。

  18. 为什么重写equals时必须重写hashCode方法?

    答:

    简单来说,就是假如我们要比较HashMap中的key是否相等,我们首先会调用key的hashcode()方法,而没有重写过的hashcode()方法是这个对象在内存中的地址。重写hashcode()之后,若两个key的hashcode值一样。此时并不能说明这两个key是相等的(相同含义),这时候要调用equals()方法比较两个key对象的内容是否一样。如果我们没有重写equals()方法,则比较的是两个对象的内存地址是否相等(即是否指向同一个地方),就达不到比较两个key对象的意义,因为不同key对象equals(没有重写)的话一定会返回false。

  19. 算法题:

    • 判断一颗二叉树是否对称

    • 松鼠捡豆(动态规划)

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      //松鼠捡豆
      //max数组表示当前元素能捡到的豆的最大数量
      public static int getMostBean(int row, int col, int[][] bean) {

      int[][] max = new int[row][col];
      max[0][0] = bean[0][0];
      for(int i = 0 ; i < row ; i++) {
      for(int j = 0 ; j < col ; j++) {
      if(i == 0 && j > 0)
      max[i][j] = max[i][j-1] + bean[i][j];
      else if(i > 0 && j == 0)
      max[i][j] = max[i-1][j] + bean[i][j];
      else if(i > 0 && j > 0)
      max[i][j] = bean[i][j] + Math.max(max[i-1][j], max[i][j-1]);
      }
      }
      return max[row-1][col-1];
      }

No.2

  1. 讲一讲你对volatile关键字的理解

    答:

    • 保证被修饰的变量对所有线程的可见性,即当一条线程修改了这个变量时,其他线程也是可以立即得知的,而普通变量的修改是需要通过工作内存和主内存之间的通信来完成。
    • 禁止指令重排序。现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。

    volatile关键字保证的是一个线程对于它的会立即刷新到主内存中去,并置其他线程的副本为无效,但是并不保证对volatile变量的操作都具有原子性。

  2. 讲一讲HashMap与HashTable的区别?多个线程同时使用一个HashMap时你觉得会发生什么?

    答:

    区别:

    • HashMap是线程不安全的,而HashTable是线程安全的。
    • HashMap最多只允许一条记录的键值为Null,允许多条记录的值为Null;HashTable不允许记录的键或值为Null。
    • HashTable是基于Dictionary类,而HashMap是基于AbstractMap。

多个线程同时使用一个HashMap,会出现链表闭环的情况(多个线程同时put数据)。这时候如果进行get数据,就会进入死循环。

多个线程同时使用一个HashMap,会出现数据覆盖的问题。举个栗子,HashMap进行put操作时是先计算hashCode找到桶,然后遍历桶内的链表找到插入位置插入。如果2个线程t1、t2分别put一个hashCode相同的元素e1、e2,就可能导致找到相同的插入位置(a),t1里a.next=e1,t2里a.next=e2,就只有一个数据保留了下来,丢了一个。

  1. 讲讲你对ThreadLocal的理解?(在多线程环境下,如何防止自己的变量被其它线程篡改?)

    答:

    ThreadLocal顾名思义可以理解为线程本地变量,用来维护线程中的变量不受其他线程的干扰。也就是说如果定义了一个ThreadLocal,每个线程往这个ThreadLocal中的读写是隔离的,互相之间不会影响。

    • 它大致的实现思路是怎样的?

      Thread类有一个类型为ThreadLocal.ThreadLocalMap的实例变量threadLocals,也就是说每个线程有一个自己的ThreadLocalMap。ThreadLocalMap有自己的独立实现,可以简单地将它的key视作ThreadLocal,value为代码中放入的值(实际上key并不是ThreadLocal本身,而是它的一个弱引用)。每个线程在往某个ThreadLocal里塞值的时候,都会往自己的ThreadLocalMap里存,读也是以某个ThreadLocal作为引用,在自己的map里找对应的key,从而实现了线程隔离。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    public void set(T value) {
    Thread t = Thread.currentThread();
    ThreadLocalMap map = getMap(t);
    if (map != null)
    map.set(this, value);
    else
    createMap(t, value);
    }

    public T get() {
    Thread t = Thread.currentThread();
    ThreadLocalMap map = getMap(t);
    if (map != null) {
    ThreadLocalMap.Entry e = map.getEntry(this);
    if (e != null) {
    @SuppressWarnings("unchecked")
    T result = (T)e.value;
    return result;
    }
    }
    return setInitialValue();
    }

    ThreadLocalMap getMap(Thread t) {
    return t.threadLocals;
    }

    说明:可以发现,每个线程中都有一个ThreadLocalMap数据结构,当执行set方法时,其值是保存在当前线程的threadLocals变量中,当执行set方法时,是从当前线程的threadLocals变量获取。

    所以在线程1中set的值,对线程2来说是摸不到的,而且在线程2中重新set的话,也不会影响到线程1中的值,保证了线程之间不会相互干扰。

    一个ThreadLocal只能保存一个键值对,但是一个线程可以创建多个ThreadLocal对象,并且各个线程之间的数据互不干扰。

    在ThreadLoalMap中,也是初始化一个大小16的Entry数组,Entry对象用来保存每一个key-value键值对,只不过这里的key永远都是ThreadLocal对象,是不是很神奇,通过ThreadLocal对象的set方法,结果把ThreadLocal对象自己当做key,放进了ThreadLoalMap中。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    /**
    * 初始容量,必须为2的幂
    */
    private static final int INITIAL_CAPACITY = 16;

    /**
    * Entry表,大小必须为2的幂
    */
    private Entry[] table;

    /**
    * 表里entry的个数
    */
    private int size = 0;

    /**
    * 重新分配表大小的阈值,默认为0
    */
    private int threshold;

    /**
    * 设置resize阈值以维持最坏2/3的装载因子
    */
    private void setThreshold(int len) {
    threshold = len * 2 / 3;
    }

    /**
    * 环形意义的下一个索引
    */
    private static int nextIndex(int i, int len) {
    return ((i + 1 < len) ? i + 1 : 0);
    }

    /**
    * 环形意义的上一个索引
    */
    private static int prevIndex(int i, int len) {
    return ((i - 1 >= 0) ? i - 1 : len - 1);
    }

    z8

    这里需要注意的是,ThreadLoalMap的Entry是继承WeakReference,和HashMap很大的区别是,Entry中没有next字段,所以就不存在链表的情况了。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    static class Entry extends WeakReference<java.lang.ThreadLocal<?>> {
    // 往ThreadLocal里实际塞入的值
    Object value;

    Entry(java.lang.ThreadLocal<?> k, Object v) {
    super(k);
    value = v;
    }
    }
    • 为什么要弱引用?

      因为如果这里使用普通的key-value形式来定义存储结构,实质上就会造成节点的生命周期与线程强绑定,只要线程没有销毁,那么节点在GC分析中一直处于可达状态,没办法被回收,而程序本身也无法判断是否可以清理节点。弱引用是Java中四档引用的第三档,比软引用更加弱一些,如果一个对象没有强引用链可达,那么一般活不过下一次GC。当某个ThreadLocal已经没有强引用可达,则随着它被垃圾回收,在ThreadLocalMap里对应的Entry的键值会失效,这为ThreadLocalMap本身的垃圾清理提供了便利。

    • 内存泄漏

      1
      2
      3
      4
      5
      6
      7
      8
      9
      static class Entry extends WeakReference<ThreadLocal<?>> {
      /** The value associated with this ThreadLocal. */
      Object value;

      Entry(ThreadLocal<?> k, Object v) {
      super(k);
      value = v;
      }
      }

      通过之前的分析已经知道,当使用ThreadLocal保存一个value时,会在ThreadLocalMap中的数组插入一个Entry对象,按理说key-value都应该以强引用保存在Entry对象中,但在ThreadLocalMap的实现中,key被保存到了WeakReference对象中。

      这就导致了一个问题,ThreadLocal在没有外部强引用时,发生GC时会被回收,如果创建ThreadLocal的线程一直持续运行,那么这个Entry对象中的value就有可能一直得不到回收,发生内存泄露。

      解释:如果一个ThreadLocal对象被回收了,我们往里面放的value对于【当前线程->当前线程的threadLocals(ThreadLocal.ThreadLocalMap对象)->Entry数组->某个entry.value】这样一条强引用链是可达的,因此value不会被回收。

    • 如何解决内存泄漏?

      当我们仔细读过ThreadLocalMap的源码,我们可以推断,如果在使用的ThreadLocal的过程中,显式地进行remove是个很好的编码习惯,这样是不会引起内存泄漏。
      那么如果没有显式地进行remove呢?只能说如果对应线程之后调用ThreadLocal的get和set方法都有很高的概率会顺便清理掉无效对象,断开value强引用,从而大对象被收集器回收。

      在调用ThreadLocal的get()、set()可能会清除ThreadLocalMap中key为null的Entry对象,这样对应的value就没有GC Roots可达了,下次GC的时候就可以被回收,当然如果调用remove方法,肯定会删除对应的Entry对象。

    • 使用场景

      直接定位到 ThreadLocal 的源码,可以看到源码注释中有很清楚的解释:它是线程的局部变量,这些变量只能在这个线程内被读写,在其他线程内是无法访问的。

      ThreadLocal 定义的通常是与线程关联的私有静态字段(例如,用户ID或事务ID)。

      变量有局部的还有全局的,局部变量没什么好说的,一涉及到全局,那自然就会出现多线程的安全问题,要保证多线程安全访问,不出现脏读脏写,那就要涉及到线程同步了。而 ThreadLocal 相当于提供了介于局部变量与全局变量中间的这样一种线程内部的全局变量。

      总结了半天,发现使用场景说到底就概括成一个:就是当我们只想在本身的线程内使用的变量,可以用 ThreadLocal 来实现,并且这些变量是和线程的生命周期密切相关的,线程结束,变量也就销毁了。

      所以说 ThreadLocal 不是为了解决线程间的共享变量问题的,如果是多线程都需要访问的数据,那需要用全局变量加同步机制。

      举几个例子说明一下:

      1、比如线程中处理一个非常复杂的业务,可能方法有很多,那么,使用 ThreadLocal 可以代替一些参数的显式传递;

      2、比如用来存储用户 Session。Session 的特性很适合 ThreadLocal ,因为 Session 之前当前会话周期内有效,会话结束便销毁。我们先笼统但不正确的分析一次 web 请求的过程:

      • 用户在浏览器中访问 web 页面;
      • 浏览器向服务器发起请求;
      • 服务器上的服务处理程序(例如tomcat)接收请求,并开启一个线程处理请求,期间会使用到 Session ;
      • 最后服务器将请求结果返回给客户端浏览器。

      从这个简单的访问过程我们看到正好这个 Session 是在处理一个用户会话过程中产生并使用的,如果单纯的理解一个用户的一次会话对应服务端一个独立的处理线程,那用 ThreadLocal 在存储 Session ,简直是再合适不过了。但是例如 tomcat 这类的服务器软件都是采用了线程池技术的,并不是严格意义上的一个会话对应一个线程。并不是说这种情况就不适合 ThreadLocal 了,而是要在每次请求进来时先清理掉之前的 Session ,一般可以用拦截器、过滤器来实现。

      3、在一些多线程的情况下,如果用线程同步的方式,当并发比较高的时候会影响性能,可以改为 ThreadLocal 的方式,例如高性能序列化框架 Kyro 就要用 ThreadLocal 来保证高性能和线程安全;

      4、还有像线程内上线文管理器、数据库连接等可以用到 ThreadLocal;

java中的finalize()方法总结

发表于 2019-05-30 | 分类于 java面试准备 , java基础 |

注:本文的目的并不是鼓励使用finalize方法,而是大致理清其作用、问题以及GC执行finalize的过程。

finalize()的作用

  • finalize()是Object的protected方法,子类可以覆盖该方法以实现资源清理工作,GC在回收对象之前调用该方法。

  • finalize()与C++中的析构函数不是对应的。C++中的析构函数调用的时机是确定的(对象离开作用域或delete掉),但Java中的finalize的调用具有不确定性

  • 不建议用finalize方法完成“非内存资源”的清理工作,但建议用于:

    ① 清理本地对象(通过JNI创建的对象);

    ② 作为确保某些非内存资源(如Socket、文件等)释放的一个补充:在finalize方法中显式调用其他资源释放方法。其原因可见下文[finalize的问题]

finalize()的问题

  • 一些与finalize相关的方法,由于一些致命的缺陷,已经被废弃了,如System.runFinalizersOnExit()方法、Runtime.runFinalizersOnExit()方法
  • System.gc()与System.runFinalization()方法增加了finalize方法执行的机会,但不可盲目依赖它们
  • Java语言规范并不保证finalize方法会被及时地执行、而且根本不会保证它们会被执行
  • finalize方法可能会带来性能问题。因为JVM通常在单独的低优先级线程中完成finalize的执行
  • 对象再生问题:finalize方法中,可将待回收对象赋值给GC Roots可达的对象引用,从而达到对象再生的目的
  • finalize方法至多由GC执行一次(用户当然可以手动调用对象的finalize方法,但并不影响GC对finalize的行为)

finalize()的执行过程(生命周期)

(1) 首先,大致描述一下finalize流程:当对象变成(GC Roots)不可达时,GC会判断该对象是否覆盖了finalize方法,若未覆盖,则直接将其回收。否则,若对象未执行过finalize方法,将其放入F-Queue队列,由一低优先级线程执行该队列中对象的finalize方法。执行finalize方法完毕后,GC会再次判断该对象是否可达,若不可达,则进行回收,否则,对象 “复活” 。

(2) 具体的finalize流程:

对象可由两种状态,涉及到两类状态空间:

一是终结状态空间 F = {unfinalized, finalizable, finalized};

二是可达状态空间 R = {reachable, finalizer-reachable, unreachable}。

各状态含义如下:

  • unfinalized: 新建对象会先进入此状态,GC并未准备执行其finalize方法,因为该对象是可达的
  • finalizable: 表示GC可对该对象执行finalize方法,GC已检测到该对象不可达。正如前面所述,GC通过F-Queue队列和一专用线程完成finalize的执行
  • finalized: 表示GC已经对该对象执行过finalize方法
  • reachable: 表示GC Roots引用可达
  • finalizer-reachable(f-reachable):表示不是reachable,但可通过某个finalizable对象可达
  • unreachable:对象不可通过上面两种途径可达

状态变迁图:

f1

变迁说明:

  1. 新建对象首先处于[reachable, unfinalized]状态(A)
  2. 随着程序的运行,一些引用关系会消失,导致状态变迁,从reachable状态变迁到f-reachable(B, C, D)或unreachable(E, F)状态
  3. 若JVM检测到处于unfinalized状态的对象变成f-reachable或unreachable,JVM会将其标记为finalizable状态(G,H)。若对象原处于[unreachable, unfinalized]状态,则同时将其标记为f-reachable(H)。
  4. 在某个时刻,JVM取出某个finalizable对象,将其标记为finalized并在某个线程中执行其finalize方法。由于是在活动线程中引用了该对象,该对象将变迁到(reachable, finalized)状态(K或J)。该动作将影响某些其他对象从f-reachable状态重新回到reachable状态(L, M, N)
  5. 处于finalizable状态的对象不能同时是unreahable的,由第4点可知,将对象finalizable对象标记为finalized时会由某个线程执行该对象的finalize方法,致使其变成reachable。这也是图中只有八个状态点的原因
  6. 程序员手动调用finalize方法并不会影响到上述内部标记的变化,因此JVM只会至多调用finalize一次,即使该对象“复活”也是如此。程序员手动调用多少次不影响JVM的行为
  7. 若JVM检测到finalized状态的对象变成unreachable,回收其内存(I)
  8. 若对象并未覆盖finalize方法,JVM会进行优化,直接回收对象(O)
  9. 注:System.runFinalizersOnExit()等方法可以使对象即使处于reachable状态,JVM仍对其执行finalize方法

一些代码示例

对象复活

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
public class GC {  

public static GC SAVE_HOOK = null;

public static void main(String[] args) throws InterruptedException {
SAVE_HOOK = new GC();
SAVE_HOOK = null;
System.gc();
Thread.sleep(500);
if (null != SAVE_HOOK) { //此时对象应该处于(reachable, finalized)状态
System.out.println("Yes , I am still alive");
} else {
System.out.println("No , I am dead");
}
SAVE_HOOK = null;
System.gc();
Thread.sleep(500);
if (null != SAVE_HOOK) {
System.out.println("Yes , I am still alive");
} else {
System.out.println("No , I am dead");
}
}

@Override
protected void finalize() throws Throwable {
super.finalize();
System.out.println("execute method finalize()");
SAVE_HOOK = this;
}
}

执行结果如下:

1
2
3
execute method finalize()
Yes , I am still alive
No , I am dead

参考:

https://www.cnblogs.com/Smina/p/7189427.html

JDK提供的并发容器总结

发表于 2019-05-01 | 分类于 java面试准备 , 并发编程 |

JDK提供的并发容器总结

JDK提供的这些容器大部分在 java.util.concurrent 包中:

  • ConcurrentHashMap: 线程安全的HashMap。
  • CopyOnWriteArrayList: 线程安全的List,在读多写少的场合性能非常好,远远好于Vector。
  • ConcurrentLinkedQueue: 高效的并发队列,使用链表实现。可以看做一个线程安全的 LinkedList,这是一个非阻塞队列。
  • BlockingQueue: 这是一个接口,JDK内部通过链表、数组等方式实现了这个接口。表示阻塞队列,非常适合用于作为数据共享的通道。
  • ConcurrentSkipListMap: 跳表的实现。这是一个Map,使用跳表的数据结构进行快速查找。

ConcurrentHashMap

我们知道 HashMap 不是线程安全的,在并发场景下如果要保证一种可行的方式是使用 Collections.synchronizedMap() 方法来包装我们的 HashMap。但这是通过使用一个全局的锁来同步不同线程间的并发访问,因此会带来不可忽视的性能问题。

因此就有了 HashMap 的线程安全版本——ConcurrentHashMap 的诞生。在ConcurrentHashMap中,无论是读操作还是写操作都能保证很高的性能:在进行读操作时(几乎)不需要加锁,而在写操作时通过锁分段技术只对所操作的段加锁而不影响客户端对其它段的访问。

关于 ConcurrentHashMap 相关问题,我在 这几道Java集合框架面试题几乎必问 这篇文章中已经提到过。

下面梳理一下关于 ConcurrentHashMap 比较重要的问题:

ConcurrentHashMap 和 Hashtable 的区别

ConcurrentHashMap 和 Hashtable 的区别主要体现在实现线程安全的方式上不同。

  • 底层数据结构: JDK1.7的 ConcurrentHashMap 底层采用 分段的数组+链表 实现,JDK1.8 采用的数据结构跟HashMap1.8的结构一样,数组+链表/红黑二叉树。Hashtable 和 JDK1.8 之前的 HashMap 的底层数据结构类似都是采用 数组+链表 的形式,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的;
  • 实现线程安全的方式(重要): ① 在JDK1.7的时候,ConcurrentHashMap(分段锁) 对整个桶数组进行了分割分段(Segment),每一把锁只锁容器其中一部分数据,多线程访问容器里不同数据段的数据,就不会存在锁竞争,提高并发访问率。 到了 JDK1.8 的时候已经摒弃了Segment的概念,而是直接用 Node 数组+链表+红黑树的数据结构来实现,并发控制使用 synchronized 和 CAS 来操作。(JDK1.6以后 对 synchronized锁做了很多优化) 整个看起来就像是优化过且线程安全的 HashMap,虽然在JDK1.8中还能看到 Segment 的数据结构,但是已经简化了属性,只是为了兼容旧版本;② Hashtable(同一把锁) :使用 synchronized 来保证线程安全,效率非常低下。当一个线程访问同步方法时,其他线程也访问同步方法,可能会进入阻塞或轮询状态,如使用 put 添加元素,另一个线程不能使用 put 添加元素,也不能使用 get,竞争会越来越激烈效率越低。

两者的对比图:

图片来源:http://www.cnblogs.com/chengxiao/p/6842045.html

HashTable:

j1

JDK1.7的ConcurrentHashMap:

j2

JDK1.8的ConcurrentHashMap(TreeBin: 红黑二叉树节点,Node: 链表节点):

j3

ConcurrentHashMap线程安全的具体实现方式/底层具体实现

JDK1.7(上面有示意图)

首先将数据分为一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据时,其他段的数据也能被其他线程访问。

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

Segment 实现了 ReentrantLock,所以 Segment 是一种可重入锁,扮演锁的角色。HashEntry 用于存储键值对数据。

1
2
3
static class Segment<K,V> extends ReentrantLock implements Serializable {

}

一个 ConcurrentHashMap 里包含一个 Segment 数组。Segment 的结构和HashMap类似,是一种数组和链表结构,一个 Segment 包含一个 HashEntry 数组,每个 HashEntry 是一个链表结构的元素,每个 Segment 守护着一个HashEntry数组里的元素,当对 HashEntry 数组的数据进行修改时,必须首先获得对应的 Segment的锁。

JDK1.8 (上面有示意图)

ConcurrentHashMap 取消了 Segment 分段锁,采用 CAS 和 synchronized 来保证并发安全。ConcurrentHashMap 底层数据结构跟 HashMap1.8 的结构类似,数组+链表/红黑二叉树。Java 8在链表长度超过一定阈值(8)时将链表(寻址时间复杂度为O(N))转换为红黑树(寻址时间复杂度为O(log(N)))

synchronized只锁定当前链表或红黑二叉树的首节点,这样只要hash不冲突,就不会产生并发,效率又提升N倍。源码示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
/** Implementation for put and putIfAbsent */
final V putVal(K key, V value, boolean onlyIfAbsent) {
if (key == null || value == null) throw new NullPointerException();
int hash = spread(key.hashCode());
int binCount = 0;
for (Node<K,V>[] tab = table;;) {
Node<K,V> f; int n, i, fh;
if (tab == null || (n = tab.length) == 0)
tab = initTable();
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
}
else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f);
else {
V oldVal = null;
synchronized (f) {
if (tabAt(tab, i) == f) {
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;
}
}
}
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;
}
}
}
}
if (binCount != 0) {
if (binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i);
if (oldVal != null)
return oldVal;
break;
}
}
}
addCount(1L, binCount);
return null;
}

CopyOnWriteArrayList

CopyOnWriteArrayList 简介

1
2
3
public class CopyOnWriteArrayList<E>
extends Object
implements List<E>, RandomAccess, Cloneable, Serializable

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

这和我们之前在多线程章节讲过 ReentrantReadWriteLock 读写锁的思想非常类似,也就是读读共享、写写互斥、读写互斥、写读互斥。JDK中提供了 CopyOnWriteArrayList 类比相比于在读写锁的思想又更进一步。为了将读取的性能发挥到极致,CopyOnWriteArrayList 读取是完全不用加锁的,并且更厉害的是:写入也不会阻塞读取操作,只有写入和写入之间需要进行同步等待。这样一来,读操作的性能就会大幅度提升。那它是怎么做的呢?

CopyOnWriteArrayList 是如何做到的?

CopyOnWriteArrayList 类的所有可变操作(add,set等等)都是通过创建底层数组的新副本来实现的。当 List 需要被修改的时候,我并不修改原有内容,而是对原有数据进行一次复制,将修改的内容写入副本。写完之后,再将修改完的副本替换原来的数据,这样就可以保证写操作不会影响读操作了。

从 CopyOnWriteArrayList 的名字就能看出CopyOnWriteArrayList 是满足CopyOnWrite 的ArrayList,所谓CopyOnWrite 也就是说:在计算机,如果你想要对一块内存进行修改时,我们不在原有内存块中进行写操作,而是将内存拷贝一份,在新的内存中进行写操作,写完之后呢,就将指向原来内存指针指向新的内存,原来的内存就可以被回收掉了。

CopyOnWriteArrayList 读取和写入源码简单分析

CopyOnWriteArrayList 读取操作的实现

读取操作没有任何同步控制和锁操作,理由就是:内部数组 array 不会发生修改,只会被另外一个 array 替换,因此可以保证数据安全。

1
2
3
4
5
6
7
8
9
10
11
12
/** The array, accessed only via getArray/setArray. */
private transient volatile Object[] array;
public E get(int index) {
return get(getArray(), index);
}
@SuppressWarnings("unchecked")
private E get(Object[] a, int index) {
return (E) a[index];
}
final Object[] getArray() {
return array;
}

CopyOnWriteArrayList 写入操作的实现

CopyOnWriteArrayList 写入操作 add() 方法在添加集合的时候加了锁,保证了同步,避免了多线程写的时候会 copy 出多个副本出来。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/**
* Appends the specified element to the end of this list.
*
* @param e element to be appended to this list
* @return {@code true} (as specified by {@link Collection#add})
*/
public boolean add(E e) {
final ReentrantLock lock = this.lock;
lock.lock();//加锁
try {
Object[] elements = getArray();
int len = elements.length;
Object[] newElements = Arrays.copyOf(elements, len + 1);//拷贝新数组
newElements[len] = e;
setArray(newElements);
return true;
} finally {
lock.unlock();//释放锁
}
}

ConcurrentLinkedQueue

Java提供的线程安全的 Queue 可以分为阻塞队列和非阻塞队列,其中阻塞队列的典型例子是 BlockingQueue,非阻塞队列的典型例子是ConcurrentLinkedQueue,在实际应用中要根据实际需要选用阻塞队列或者非阻塞队列。 阻塞队列可以通过加锁来实现,非阻塞队列可以通过 CAS 操作实现。

从名字可以看出,ConcurrentLinkedQueue这个队列使用链表作为其数据结构.ConcurrentLinkedQueue 应该算是在高并发环境中性能最好的队列了。它之所有能有很好的性能,是因为其内部复杂的实现。

ConcurrentLinkedQueue 内部代码我们就不分析了,大家知道 ConcurrentLinkedQueue 主要使用 CAS 非阻塞算法来实现线程安全就好了。

ConcurrentLinkedQueue 适合在对性能要求相对较高,同时对队列的读写存在多个线程同时进行的场景,即如果对队列加锁的成本较高则适合使用无锁的 ConcurrentLinkedQueue 来替代。

BlockingQueue

BlockingQueue 简单介绍

上面我们己经提到了 ConcurrentLinkedQueue 作为高性能的非阻塞队列。下面我们要讲到的是阻塞队列——BlockingQueue。阻塞队列(BlockingQueue)被广泛使用在“生产者-消费者”问题中,其原因是BlockingQueue提供了可阻塞的插入和移除的方法。当队列容器已满,生产者线程会被阻塞,直到队列未满;当队列容器为空时,消费者线程会被阻塞,直至队列非空时为止。

BlockingQueue 是一个接口,继承自 Queue,所以其实现类也可以作为 Queue 的实现来使用,而 Queue 又继承自 Collection 接口。

下面是 BlockingQueue 的相关实现类:

j4

下面主要介绍一下:ArrayBlockingQueue、LinkedBlockingQueue、PriorityBlockingQueue,这三个 BlockingQueue 的实现类。

ArrayBlockingQueue

ArrayBlockingQueue 是 BlockingQueue 接口的有界队列实现类,底层采用数组来实现。ArrayBlockingQueue一旦创建,容量不能改变。其并发控制采用可重入锁来控制,不管是插入操作还是读取操作,都需要获取到锁才能进行操作。当队列容量满时,尝试将元素放入队列将导致操作阻塞;尝试从一个空队列中取一个元素也会同样阻塞。

ArrayBlockingQueue 默认情况下不能保证线程访问队列的公平性,所谓公平性是指严格按照线程等待的绝对时间顺序,即最先等待的线程能够最先访问到 ArrayBlockingQueue。而非公平性则是指访问 ArrayBlockingQueue 的顺序不是遵守严格的时间顺序,有可能存在,当 ArrayBlockingQueue 可以被访问时,长时间阻塞的线程依然无法访问到 ArrayBlockingQueue。

如果保证公平性,通常会降低吞吐量。如果需要获得公平性的 ArrayBlockingQueue,可采用如下代码:

1
private static ArrayBlockingQueue<Integer> blockingQueue = new ArrayBlockingQueue<Integer>(10,true);

LinkedBlockingQueue

LinkedBlockingQueue 底层基于单向链表实现的阻塞队列,可以当做无界队列也可以当做有界队列来使用,同样满足FIFO的特性,与ArrayBlockingQueue 相比起来具有更高的吞吐量。为了防止 LinkedBlockingQueue 容量迅速增,损耗大量内存,通常在创建LinkedBlockingQueue 对象时,会指定其大小,如果未指定,容量等于Integer.MAX_VALUE。

相关构造方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/**
*某种意义上的无界队列
* Creates a {@code LinkedBlockingQueue} with a capacity of
* {@link Integer#MAX_VALUE}.
*/
public LinkedBlockingQueue() {
this(Integer.MAX_VALUE);
}

/**
*有界队列
* Creates a {@code LinkedBlockingQueue} with the given (fixed) capacity.
*
* @param capacity the capacity of this queue
* @throws IllegalArgumentException if {@code capacity} is not greater
* than zero
*/
public LinkedBlockingQueue(int capacity) {
if (capacity <= 0) throw new IllegalArgumentException();
this.capacity = capacity;
last = head = new Node<E>(null);
}

PriorityBlockingQueue

PriorityBlockingQueue 是一个支持优先级的无界阻塞队列。默认情况下元素采用自然顺序进行排序,也可以通过自定义类实现 compareTo() 方法来指定元素排序规则,或者初始化时通过构造器参数 Comparator 来指定排序规则。

PriorityBlockingQueue 并发控制采用的是 ReentrantLock,队列为无界队列(ArrayBlockingQueue 是有界队列,LinkedBlockingQueue 也可以通过在构造函数中传入 capacity 指定队列最大的容量,但是 PriorityBlockingQueue 只能指定初始的队列大小,后面插入元素的时候,如果空间不够的话会自动扩容)。

简单地说,它就是 PriorityQueue 的线程安全版本。 PriorityBlockingQueue 不可以插入 null 值,同时,插入队列的对象必须是可比较大小的(comparable),否则报 ClassCastException 异常。

它的插入操作 put 方法不会 block,因为它是无界队列( take 方法在队列为空的时候会阻塞)。

推荐文章:

《解读 Java 并发队列 BlockingQueue 》

https://javadoop.com/post/java-concurrent-queue

ConcurrentSkipListMap

为了引出ConcurrentSkipListMap,先带着大家简单理解一下跳表。

对于一个单链表,即使链表是有序的,如果我们想要在其中查找某个数据,也只能从头到尾遍历链表,这样效率自然就会很低,跳表就不一样了。跳表是一种可以用来快速查找的数据结构,有点类似于平衡树。它们都可以对元素进行快速的查找。但一个重要的区别是:对平衡树的插入和删除往往很可能导致平衡树进行一次全局的调整。而对跳表的插入和删除只需要对整个数据结构的局部进行操作即可。这样带来的好处是:在高并发的情况下,你会需要一个全局锁来保证整个平衡树的线程安全。而对于跳表,你只需要部分锁即可。这样,在高并发环境下,你就可以拥有更好的性能。而就查询的性能而言,跳表的时间复杂度也是 O(logn) 。所以在并发数据结构中,JDK 使用跳表来实现一个 Map。

跳表的本质是同时维护了多个链表,并且链表是分层的,

j5

最低层的链表维护了跳表内所有的元素,每上面一层链表都是下面一层的子集。

跳表内的所有链表的元素都是排序的。查找时,可以从顶级链表开始找,一旦发现被查找的元素大于当前链表中的取值,就会转入下一层链表继续找。这也就是说在查找过程中,搜索是跳跃式的。

比如在跳表中查找元素18,查找过程如下图所示:

j6

查找18 的时候原来需要遍历 18 次,现在只需要 7 次即可。针对链表长度比较大的时候,构建索引查找效率的提升就会非常明显。

从上面很容易看出,跳表是一种利用空间换时间的算法。

使用跳表实现 Map 和使用哈希算法实现 Map 的另外一个不同之处是:哈希并不会保存元素的顺序,而跳表内所有的元素都是排序的。因此在对跳表进行遍历时,你会得到一个有序的结果。所以,如果你的应用需要有序性,那么跳表就是你不二的选择。

JDK 中实现这一数据结构的类是ConcurrentSkipListMap。

参考:

  • 《实战Java高并发程序设计》
  • https://javadoop.com/post/java-concurrent-queue
  • https://juejin.im/post/5aeebd02518825672f19c546
  • 并发容器总结
  • 数据结构与算法之美

AQS原理以及AQS同步组件总结

发表于 2019-04-28 | 分类于 java面试准备 , 并发编程 |

常见问题:

AQS原理了解吗?

CountDownLatch和CyclicBarrier了解吗,两者的区别是什么?

用过Semaphore吗?

本节思维导图:

a1

AQS 简单介绍

AQS的全称为(AbstractQueuedSynchronizer),这个类在java.util.concurrent.locks包下面:

a2

AQS是一个用来构建锁和同步器的框架,使用AQS能简单且高效地构造出应用广泛的大量的同步器,比如我们提到的ReentrantLock,Semaphore,其他的诸如ReentrantReadWriteLock,SynchronousQueue,FutureTask等等皆是基于AQS的。当然,我们自己也能利用AQS非常轻松容易地构造出符合我们自己需求的同步器。

AQS 原理

在面试中被问到并发知识的时候,大多都会被问到“请你说一下自己对于AQS原理的理解”。下面给大家一个示例供大家参加,面试不是背题,大家一定要加入自己的思想,即使加入不了自己的思想也要保证自己能够通俗的讲出来而不是背出来。

下面大部分内容其实在AQS类注释上已经给出了,不过是英语看着比较吃力一点,感兴趣的话可以看看源码。

AQS 原理概览

AQS核心思想是,如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并且将共享资源设置为锁定状态。如果被请求的共享资源被占用,那么就需要一套线程阻塞等待以及被唤醒时锁分配的机制,这个机制AQS是用CLH队列锁实现的,即将暂时获取不到锁的线程加入到队列中。

CLH(Craig,Landin,and Hagersten)队列是一个虚拟的双向队列(虚拟的双向队列即不存在队列实例,仅存在结点之间的关联关系)。AQS是将每条请求共享资源的线程封装成一个CLH锁队列的一个结点(Node)来实现锁的分配。

看个AQS(AbstractQueuedSynchronizer)原理图:

a3

AQS使用一个int成员变量来表示同步状态,通过内置的FIFO队列来完成获取资源线程的排队工作。AQS使用CAS对该同步状态进行原子操作实现对其值的修改。

1
private volatile int state;//共享变量,使用volatile修饰保证线程可见性

状态信息通过protected类型的getState,setState,compareAndSetState进行操作:

1
2
3
4
5
6
7
8
9
10
11
12
//返回同步状态的当前值
protected final int getState() {
return state;
}
// 设置同步状态的值
protected final void setState(int newState) {
state = newState;
}
//原子地(CAS操作)将同步状态值设置为给定值update如果当前同步状态的值等于expect(期望值)
protected final boolean compareAndSetState(int expect, int update) {
return unsafe.compareAndSwapInt(this, stateOffset, expect, update);
}

AQS 对资源的共享方式

AQS定义两种资源共享方式

  • Exclusive(独占):只有一个线程能执行,如ReentrantLock。又可分为公平锁和非公平锁:
    • 公平锁:按照线程在队列中的排队顺序,先到者先拿到锁
    • 非公平锁:当线程要获取锁时,无视队列顺序直接去抢锁,谁抢到就是谁的
  • Share(共享):多个线程可同时执行,如Semaphore/CountDownLatch。Semaphore、CountDownLatCh、 CyclicBarrier、ReadWriteLock 我们都会在后面讲到。

ReentrantReadWriteLock 可以看成是组合式,因为ReentrantReadWriteLock也就是读写锁允许多个线程同时对某一资源进行读。

不同的自定义同步器争用共享资源的方式也不同。自定义同步器在实现时只需要实现共享资源 state 的获取与释放方式即可,至于具体线程等待队列的维护(如获取资源失败入队/唤醒出队等),AQS已经在上层已经帮我们实现好了。

AQS底层使用了模板方法模式

同步器的设计是基于模板方法模式的,如果需要自定义同步器一般的方式是这样(模板方法模式很经典的一个应用):

  1. 使用者继承AbstractQueuedSynchronizer并重写指定的方法。(这些重写方法很简单,无非是对于共享资源state的获取和释放)
  2. 将AQS组合在自定义同步组件的实现中,并调用其模板方法,而这些模板方法会调用使用者重写的方法。

这和我们以往通过实现接口的方式有很大区别,这是模板方法模式很经典的一个运用,下面简单的给大家介绍一下模板方法模式,模板方法模式是一个很容易理解的设计模式之一。

模板方法模式是基于”继承“的,主要是为了在不改变模板结构的前提下在子类中重新定义模板中的内容以实现复用代码。举个很简单的例子假如我们要去一个地方的步骤是:购票buyTicket()->安检securityCheck()->乘坐某某工具回家ride()->到达目的地arrive()。我们可能乘坐不同的交通工具回家比如飞机或者火车,所以除了ride()方法,其他方法的实现几乎相同。我们可以定义一个包含了这些方法的抽象类,然后用户根据自己的需要继承该抽象类然后修改 ride()方法。

AQS使用了模板方法模式,自定义同步器时需要重写下面几个AQS提供的模板方法:

1
2
3
4
5
isHeldExclusively()//该线程是否正在独占资源。只有用到condition才需要去实现它。
tryAcquire(int)//独占方式。尝试获取资源,成功则返回true,失败则返回false。
tryRelease(int)//独占方式。尝试释放资源,成功则返回true,失败则返回false。
tryAcquireShared(int)//共享方式。尝试获取资源。负数表示失败;0表示成功,但没有剩余可用资源;正数表示成功,且有剩余资源。
tryReleaseShared(int)//共享方式。尝试释放资源,成功则返回true,失败则返回false。

默认情况下,每个方法都抛出 UnsupportedOperationException。 这些方法的实现必须是内部线程安全的,并且通常应该简短而不是阻塞。AQS类中的其他方法都是final ,所以无法被其他类使用,只有这几个方法可以被其他类使用。

下面举两个例子:

  1. 以ReentrantLock为例,state初始化为0,表示未锁定状态。A线程lock()时,会调用tryAcquire()独占该锁并将state+1。此后,其他线程再tryAcquire()时就会失败,直到A线程unlock()到state=0(即释放锁)为止,其它线程才有机会获取该锁。当然,释放锁之前,A线程自己是可以重复获取此锁的(state会累加),这就是可重入的概念。但要注意,获取多少次就要释放多么次,这样才能保证state是能回到零态的。
  2. 再以CountDownLatch以例,任务分为N个子线程去执行,state也初始化为N(注意N要与线程个数一致)。这N个子线程是并行执行的,每个子线程执行完后countDown()一次,state会CAS(Compare and Swap)减1。等到所有子线程都执行完后(即state=0),会unpark()主调用线程,然后主调用线程就会从await()函数返回,继续后余动作。

一般来说,自定义同步器要么是独占方法,要么是共享方式,他们也只需实现tryAcquire-tryRelease、tryAcquireShared-tryReleaseShared中的一种即可。但AQS也支持自定义同步器同时实现独占和共享两种方式,如ReentrantReadWriteLock。

推荐两篇 AQS 原理和相关源码分析的文章:

  • http://www.cnblogs.com/waterystone/p/4920797.html
  • https://www.cnblogs.com/chengxiao/archive/2017/07/24/7141160.html

Semaphore(信号量)-允许多个线程同时访问

synchronized 和 ReentrantLock 都是一次只允许一个线程访问某个资源,Semaphore(信号量)可以指定多个线程同时访问某个资源。示例代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
/**
*
* @author Snailclimb
* @date 2018年9月30日
* @Description: 需要一次性拿一个许可的情况
*/
public class SemaphoreExample1 {
// 请求的数量
private static final int threadCount = 550;

public static void main(String[] args) throws InterruptedException {
// 创建一个具有固定线程数量的线程池对象(如果这里线程池的线程数量给太少的话你会发现执行的很慢)
ExecutorService threadPool = Executors.newFixedThreadPool(300);
// 一次只能允许执行的线程数量。
final Semaphore semaphore = new Semaphore(20);

for (int i = 0; i < threadCount; i++) {
final int threadnum = i;
threadPool.execute(() -> {// Lambda 表达式的运用
try {
semaphore.acquire();// 获取一个许可,所以可运行线程数量为20/1=20
test(threadnum);
semaphore.release();// 释放一个许可
} catch (InterruptedException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}

});
}
threadPool.shutdown();
System.out.println("finish");
}

public static void test(int threadnum) throws InterruptedException {
Thread.sleep(1000);// 模拟请求的耗时操作
System.out.println("threadnum:" + threadnum);
Thread.sleep(1000);// 模拟请求的耗时操作
}
}

执行 acquire 方法阻塞,直到有一个许可证可以获得然后拿走一个许可证;每个 release 方法增加一个许可证,这可能会释放一个阻塞的acquire方法。然而,其实并没有实际的许可证这个对象,Semaphore只是维持了一个可获得许可证的数量。 Semaphore经常用于限制获取某种资源的线程数量。

当然一次也可以一次拿取和释放多个许可,不过一般没有必要这样做:

1
2
3
semaphore.acquire(5);// 获取5个许可,所以可运行线程数量为20/5=4
test(threadnum);
semaphore.release(5);// 释放5个许可

除了 acquire方法之外,另一个比较常用的与之对应的方法是tryAcquire方法,该方法如果获取不到许可就立即返回false。

Semaphore 有两种模式,公平模式和非公平模式。

  • 公平模式: 调用acquire的顺序就是获取许可证的顺序,遵循FIFO;
  • 非公平模式: 抢占式的。

Semaphore 对应的两个构造方法如下:

1
2
3
4
5
6
7
public Semaphore(int permits) {
sync = new NonfairSync(permits);
}

public Semaphore(int permits, boolean fair) {
sync = fair ? new FairSync(permits) : new NonfairSync(permits);
}

这两个构造方法,都必须提供许可的数量,第二个构造方法可以指定是公平模式还是非公平模式,默认非公平模式。

由于篇幅问题,如果对 Semaphore 源码感兴趣的朋友可以看下面这篇文章:

  • https://blog.csdn.net/qq_19431333/article/details/70212663

CountDownLatch (倒计时器)

CountDownLatch是一个同步工具类,它允许一个或多个线程一直等待,直到其他线程的操作执行完后再执行。在Java并发中,countdownlatch的概念是一个常见的面试题,所以一定要确保你很好的理解了它。

CountDownLatch 的三种典型用法

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

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

③死锁检测:一个非常方便的使用场景是,你可以使用n个线程访问共享资源,在每次测试阶段的线程数目是不同的,并尝试产生死锁。

CountDownLatch 的使用示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
/**
*
* @author SnailClimb
* @date 2018年10月1日
* @Description: CountDownLatch 使用方法示例
*/
public class CountDownLatchExample1 {
// 请求的数量
private static final int threadCount = 550;

public static void main(String[] args) throws InterruptedException {
// 创建一个具有固定线程数量的线程池对象(如果这里线程池的线程数量给太少的话你会发现执行的很慢)
ExecutorService threadPool = Executors.newFixedThreadPool(300);
final CountDownLatch countDownLatch = new CountDownLatch(threadCount);
for (int i = 0; i < threadCount; i++) {
final int threadnum = i;
threadPool.execute(() -> {// Lambda 表达式的运用
try {
test(threadnum);
} catch (InterruptedException e) {
// TODO Auto-generated catch block
e.printStackTrace();
} finally {
countDownLatch.countDown();// 表示一个请求已经被完成
}

});
}
countDownLatch.await();
threadPool.shutdown();
System.out.println("finish");
}

public static void test(int threadnum) throws InterruptedException {
Thread.sleep(1000);// 模拟请求的耗时操作
System.out.println("threadnum:" + threadnum);
Thread.sleep(1000);// 模拟请求的耗时操作
}
}

上面的代码中,我们定义了请求的数量为550,当这550个请求被处理完成之后,才会执行System.out.println("finish");。

与CountDownLatch的第一次交互是主线程等待其他线程。主线程必须在启动其他线程后立即调用CountDownLatch.await()方法,这样主线程的操作就会在这个方法上阻塞,直到其他线程完成各自的任务。

其他N个线程必须引用闭锁对象,因为他们需要通知CountDownLatch对象,他们已经完成了各自的任务。这种通知机制是通过 CountDownLatch.countDown()方法来完成的;每调用一次这个方法,在构造函数中初始化的count值就减1。所以当N个线程都调用了这个方法,count的值等于0,然后主线程就能通过await()方法,恢复执行自己的任务。

CountDownLatch 的不足

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

CountDownLatch相常见面试题

解释一下CountDownLatch概念?

CountDownLatch 和CyclicBarrier的不同之处?

给出一些CountDownLatch使用的例子?

CountDownLatch 类中主要的方法?

CyclicBarrier(循环栅栏)

CyclicBarrier 和 CountDownLatch 非常类似,它也可以实现线程间的技术等待,但是它的功能比 CountDownLatch 更加复杂和强大。主要应用场景和 CountDownLatch 类似。

CyclicBarrier 的字面意思是可循环使用(Cyclic)的屏障(Barrier)。它要做的事情是,让一组线程到达一个屏障(也可以叫同步点)时被阻塞,直到最后一个线程到达屏障时,屏障才会开门,所有被屏障拦截的线程才会继续干活。CyclicBarrier默认的构造方法是 CyclicBarrier(int parties),其参数表示屏障拦截的线程数量,每个线程调用await方法告诉 CyclicBarrier 我已经到达了屏障,然后当前线程被阻塞。

CyclicBarrier 的应用场景

CyclicBarrier 可以用于多线程计算数据,最后合并计算结果的应用场景。比如我们用一个Excel保存了用户所有银行流水,每个Sheet保存一个帐户近一年的每笔银行流水,现在需要统计用户的日均银行流水,先用多线程处理每个sheet里的银行流水,都执行完之后,得到每个sheet的日均银行流水,最后,再用barrierAction用这些线程的计算结果,计算出整个Excel的日均银行流水。

CyclicBarrier 的使用示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
/**
*
* @author Snailclimb
* @date 2018年10月1日
* @Description: 测试 CyclicBarrier 类中带参数的 await() 方法
*/
public class CyclicBarrierExample2 {
// 请求的数量
private static final int threadCount = 550;
// 需要同步的线程数量
private static final CyclicBarrier cyclicBarrier = new CyclicBarrier(5);

public static void main(String[] args) throws InterruptedException {
// 创建线程池
ExecutorService threadPool = Executors.newFixedThreadPool(10);

for (int i = 0; i < threadCount; i++) {
final int threadNum = i;
Thread.sleep(1000);
threadPool.execute(() -> {
try {
test(threadNum);
} catch (InterruptedException e) {
// TODO Auto-generated catch block
e.printStackTrace();
} catch (BrokenBarrierException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
});
}
threadPool.shutdown();
}

public static void test(int threadnum) throws InterruptedException, BrokenBarrierException {
System.out.println("threadnum:" + threadnum + "is ready");
try {
/**等待60秒,保证子线程完全执行结束*/
cyclicBarrier.await(60, TimeUnit.SECONDS);
} catch (Exception e) {
System.out.println("-----CyclicBarrierException------");
}
System.out.println("threadnum:" + threadnum + "is finish");
}

}

运行结果,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
threadnum:0is ready
threadnum:1is ready
threadnum:2is ready
threadnum:3is ready
threadnum:4is ready
threadnum:4is finish
threadnum:0is finish
threadnum:1is finish
threadnum:2is finish
threadnum:3is finish
threadnum:5is ready
threadnum:6is ready
threadnum:7is ready
threadnum:8is ready
threadnum:9is ready
threadnum:9is finish
threadnum:5is finish
threadnum:8is finish
threadnum:7is finish
threadnum:6is finish
......

可以看到当线程数量也就是请求数量达到我们定义的 5 个的时候, await方法之后的方法才被执行。

另外,CyclicBarrier还提供一个更高级的构造函数CyclicBarrier(int parties, Runnable barrierAction),用于在线程到达屏障时,优先执行barrierAction,方便处理更复杂的业务场景。

示例代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
/**
*
* @author SnailClimb
* @date 2018年10月1日
* @Description: 新建 CyclicBarrier 的时候指定一个 Runnable
*/
public class CyclicBarrierExample3 {
// 请求的数量
private static final int threadCount = 550;
// 需要同步的线程数量
private static final CyclicBarrier cyclicBarrier = new CyclicBarrier(5, () -> {
System.out.println("------当线程数达到之后,优先执行------");
});

public static void main(String[] args) throws InterruptedException {
// 创建线程池
ExecutorService threadPool = Executors.newFixedThreadPool(10);

for (int i = 0; i < threadCount; i++) {
final int threadNum = i;
Thread.sleep(1000);
threadPool.execute(() -> {
try {
test(threadNum);
} catch (InterruptedException e) {
// TODO Auto-generated catch block
e.printStackTrace();
} catch (BrokenBarrierException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
});
}
threadPool.shutdown();
}

public static void test(int threadnum) throws InterruptedException, BrokenBarrierException {
System.out.println("threadnum:" + threadnum + "is ready");
cyclicBarrier.await();
System.out.println("threadnum:" + threadnum + "is finish");
}

}

运行结果,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
threadnum:0is ready
threadnum:1is ready
threadnum:2is ready
threadnum:3is ready
threadnum:4is ready
------当线程数达到之后,优先执行------
threadnum:4is finish
threadnum:0is finish
threadnum:2is finish
threadnum:1is finish
threadnum:3is finish
threadnum:5is ready
threadnum:6is ready
threadnum:7is ready
threadnum:8is ready
threadnum:9is ready
------当线程数达到之后,优先执行------
threadnum:9is finish
threadnum:5is finish
threadnum:6is finish
threadnum:8is finish
threadnum:7is finish
......

CyclicBarrier和CountDownLatch的区别

CountDownLatch是计数器,只能使用一次,而CyclicBarrier的计数器提供reset功能,可以多次使用。但是我不那么认为它们之间的区别仅仅就是这么简单的一点。我们来从jdk作者设计的目的来看,javadoc是这么描述它们的:

CountDownLatch: A synchronization aid that allows one or more threads to wait until a set of operations being performed in other threads completes.(CountDownLatch: 一个或者多个线程,等待其他多个线程完成某件事情之后才能执行;) CyclicBarrier : A synchronization aid that allows a set of threads to all wait for each other to reach a common barrier point.(CyclicBarrier : 多个线程互相等待,直到到达同一个同步点,再继续一起执行。)

对于CountDownLatch来说,重点是“一个线程(多个线程)等待”,而其他的N个线程在完成“某件事情”之后,可以终止,也可以等待。而对于CyclicBarrier,重点是多个线程,在任意一个线程没有完成,所有的线程都必须等待。

CountDownLatch是计数器,线程完成一个记录一个,只不过计数不是递增而是递减,而CyclicBarrier更像是一个阀门,需要所有线程都到达,阀门才能打开,然后继续执行。

a4

CyclicBarrier和CountDownLatch的区别这部分内容参考了如下两篇文章:

  • https://blog.csdn.net/u010185262/article/details/54692886
  • https://blog.csdn.net/tolcf/article/details/50925145?utm_source=blogxgwz0

ReentrantLock 和 ReentrantReadWriteLock

ReentrantLock 和 synchronized 的区别在上面已经讲过了这里就不多做讲解。另外,需要注意的是:读写锁 ReentrantReadWriteLock 可以保证多个线程可以同时读,所以在读操作远大于写操作的时候,读写锁就非常有用了。

参考:

https://github.com/Snailclimb/JavaGuide/blob/master/docs/java/Multithread/AQS.md

JUC中的Atomic原子类

发表于 2019-04-28 | 分类于 java面试准备 , 并发编程 |

个人觉得这一节掌握基本的使用即可!

本节思维导图:

j1

Atomic 原子类介绍

Atomic 翻译成中文是原子的意思。在化学上,我们知道原子是构成一般物质的最小单位,在化学反应中是不可分割的。在我们这里 Atomic 是指一个操作是不可中断的。即使是在多个线程一起执行的时候,一个操作一旦开始,就不会被其他线程干扰。

所以,所谓原子类说简单点就是具有原子/原子操作特征的类。

并发包 java.util.concurrent 的原子类都存放在java.util.concurrent.atomic下,如下图所示:

j2

分类

根据操作的数据类型,可以将JUC包中的原子类分为4类:

基本类型

使用原子的方式更新基本类型

  • AtomicInteger:整型原子类
  • AtomicLong:长整型原子类
  • AtomicBoolean :布尔型原子类

数组类型

使用原子的方式更新数组里的某个元素

  • AtomicIntegerArray:整型数组原子类
  • AtomicLongArray:长整型数组原子类
  • AtomicReferenceArray :引用类型数组原子类

引用类型

  • AtomicReference:引用类型原子类
  • AtomicStampedRerence:原子更新引用类型里的字段原子类
  • AtomicMarkableReference :原子更新带有标记位的引用类型

对象的属性修改类型

  • AtomicIntegerFieldUpdater:原子更新整型字段的更新器
  • AtomicLongFieldUpdater:原子更新长整型字段的更新器
  • AtomicStampedReference :原子更新带有版本号的引用类型。该类将整数值与引用关联起来,可用于解决原子的更新数据和数据的版本号,可以解决使用 CAS 进行原子更新时可能出现的 ABA 问题。

下面我们来详细介绍一下这些原子类。

基本类型原子类

基本类型原子类介绍

使用原子的方式更新基本类型

  • AtomicInteger:整型原子类
  • AtomicLong:长整型原子类
  • AtomicBoolean :布尔型原子类

上面三个类提供的方法几乎相同,所以我们这里以 AtomicInteger 为例子来介绍。

AtomicInteger 类常用方法:

1
2
3
4
5
6
7
public final int get() //获取当前的值
public final int getAndSet(int newValue)//获取当前的值,并设置新的值
public final int getAndIncrement()//获取当前的值,并自增
public final int getAndDecrement() //获取当前的值,并自减
public final int getAndAdd(int delta) //获取当前的值,并加上预期的值
boolean compareAndSet(int expect, int update) //如果输入的数值等于预期值,则以原子方式将该值设置为输入值(update)
public final void lazySet(int newValue)//最终设置为newValue,使用 lazySet 设置之后可能导致其他线程在之后的一小段时间内还是可以读到旧的值。

AtomicInteger 常见方法使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import java.util.concurrent.atomic.AtomicInteger;

public class AtomicIntegerTest {

public static void main(String[] args) {
// TODO Auto-generated method stub
int temvalue = 0;
AtomicInteger i = new AtomicInteger(0);
temvalue = i.getAndSet(3);
System.out.println("temvalue:" + temvalue + "; i:" + i);//temvalue:0; i:3
temvalue = i.getAndIncrement();
System.out.println("temvalue:" + temvalue + "; i:" + i);//temvalue:3; i:4
temvalue = i.getAndAdd(5);
System.out.println("temvalue:" + temvalue + "; i:" + i);//temvalue:4; i:9
}

}

结果如下:

j3

基本数据类型原子类的优势

通过一个简单例子带大家看一下基本数据类型原子类的优势:

①多线程环境不使用原子类保证线程安全(基本数据类型)

1
2
3
4
5
6
7
8
9
10
11
class Test {
private volatile int count = 0;
//若要线程安全执行执行count++,需要加锁
public synchronized void increment() {
count++;
}

public int getCount() {
return count;
}
}

②多线程环境使用原子类保证线程安全(基本数据类型)

1
2
3
4
5
6
7
8
9
10
11
class Test2 {
private AtomicInteger count = new AtomicInteger();

public void increment() {
count.incrementAndGet();
}
//使用AtomicInteger之后,不需要加锁,也可以实现线程安全。
public int getCount() {
return count.get();
}
}

AtomicInteger 线程安全原理简单分析

AtomicInteger 类的部分源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
// setup to use Unsafe.compareAndSwapInt for updates(更新操作时提供“比较并替换”的作用)
private static final Unsafe unsafe = Unsafe.getUnsafe();
private static final long valueOffset;

static {
try {
// 拿到"value"的内存地址
valueOffset = unsafe.objectFieldOffset
(AtomicInteger.class.getDeclaredField("value"));
} catch (Exception ex) { throw new Error(ex); }
}

private volatile int value;

AtomicInteger 类主要利用 CAS (compare and swap) + volatile 和 native 方法来保证原子操作,从而避免 synchronized 的高开销,执行效率大为提升。

CAS的原理是拿期望的值和原本的一个值作比较,如果相同则更新成新的值。UnSafe 类的 objectFieldOffset() 方法是一个本地方法,这个方法是用来拿到“原来的值”的内存地址。另外 value 是一个volatile变量,在内存中可见,因此 JVM 可以保证任何时刻任何线程总能拿到该变量的最新值。

数组类型原子类

数组类型原子类介绍

使用原子的方式更新数组里的某个元素

  • AtomicIntegerArray:整形数组原子类
  • AtomicLongArray:长整形数组原子类
  • AtomicReferenceArray :引用类型数组原子类

上面三个类提供的方法几乎相同,所以我们这里以 AtomicIntegerArray 为例子来介绍:

AtomicIntegerArray 类常用方法:

1
2
3
4
5
6
7
public final int get(int i) //获取 index=i 位置元素的值
public final int getAndSet(int i, int newValue)//返回 index=i 位置的当前的值,并将其设置为新值:newValue
public final int getAndIncrement(int i)//获取 index=i 位置元素的值,并让该位置的元素自增
public final int getAndDecrement(int i) //获取 index=i 位置元素的值,并让该位置的元素自减
public final int getAndAdd(int delta) //获取 index=i 位置元素的值,并加上预期的值
boolean compareAndSet(int expect, int update) //如果输入的数值等于预期值,则以原子方式将 index=i 位置的元素值设置为输入值(update)
public final void lazySet(int i, int newValue)//最终 将index=i 位置的元素设置为newValue,使用 lazySet 设置之后可能导致其他线程在之后的一小段时间内还是可以读到旧的值。

AtomicIntegerArray 常见方法使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import java.util.concurrent.atomic.AtomicIntegerArray;

public class AtomicIntegerArrayTest {

public static void main(String[] args) {
// TODO Auto-generated method stub
int temvalue = 0;
int[] nums = { 1, 2, 3, 4, 5, 6 };
AtomicIntegerArray i = new AtomicIntegerArray(nums);
for (int j = 0; j < nums.length; j++) {
System.out.println(i.get(j));
}
temvalue = i.getAndSet(0, 2);
System.out.println("temvalue:" + temvalue + "; i:" + i);
temvalue = i.getAndIncrement(0);
System.out.println("temvalue:" + temvalue + "; i:" + i);
temvalue = i.getAndAdd(0, 5);
System.out.println("temvalue:" + temvalue + "; i:" + i);
}

}

结果如下:

j4

引用类型原子类

引用类型原子类介绍

基本类型原子类只能更新一个变量,如果需要原子更新多个变量,需要使用 引用类型原子类。

  • AtomicReference:引用类型原子类
  • AtomicStampedRerence:原子更新引用类型里的字段原子类
  • AtomicMarkableReference :原子更新带有标记位的引用类型

上面三个类提供的方法几乎相同,所以我们这里以 AtomicReference 为例子来介绍:

AtomicReference 类使用示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
import java.util.concurrent.atomic.AtomicReference;

public class AtomicReferenceTest {

public static void main(String[] args) {
AtomicReference<Person> ar = new AtomicReference<Person>();
Person person = new Person("SnailClimb", 22);
ar.set(person);
Person updatePerson = new Person("Daisy", 20);
ar.compareAndSet(person, updatePerson);

System.out.println(ar.get().getName());
System.out.println(ar.get().getAge());
}
}

class Person {
private String name;
private int age;

public Person(String name, int age) {
super();
this.name = name;
this.age = age;
}

public String getName() {
return name;
}

public void setName(String name) {
this.name = name;
}

public int getAge() {
return age;
}

public void setAge(int age) {
this.age = age;
}

}

上述代码首先创建了一个 Person 对象,然后把 Person 对象设置进 AtomicReference 对象中,然后调用 compareAndSet 方法,该方法就是通过通过 CAS 操作设置 ar。如果 ar 的值为 person 的话,则将其设置为 updatePerson。实现原理与 AtomicInteger 类中的 compareAndSet 方法相同。

运行上面的代码后的输出结果如下:

j5

对象的属性修改类型原子类

对象的属性修改类型原子类介绍

如果需要原子更新某个类里的某个字段时,需要用到对象的属性修改类型原子类。

  • AtomicIntegerFieldUpdater:原子更新整形字段的更新器
  • AtomicLongFieldUpdater:原子更新长整形字段的更新器
  • AtomicStampedReference :原子更新带有版本号的引用类型。该类将整数值与引用关联起来,可用于解决原子的更新数据和数据的版本号,可以解决使用 CAS 进行原子更新时可能出现的 ABA 问题。

要想原子地更新对象的属性需要两步:

第一步、因为对象的属性修改类型原子类都是抽象类,所以每次使用都必须使用静态方法 newUpdater() 创建一个更新器,并且需要设置想要更新的类和属性。

第二步、更新的对象属性必须使用 public volatile 修饰符。

上面三个类提供的方法几乎相同,所以我们这里以 AtomicIntegerFieldUpdater为例子来介绍:

AtomicIntegerFieldUpdater 类使用示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
import java.util.concurrent.atomic.AtomicIntegerFieldUpdater;

public class AtomicIntegerFieldUpdaterTest {
public static void main(String[] args) {
AtomicIntegerFieldUpdater<User> a = AtomicIntegerFieldUpdater.newUpdater(User.class, "age");

User user = new User("Java", 22);
System.out.println(a.getAndIncrement(user));// 22
System.out.println(a.get(user));// 23
}
}

class User {
private String name;
public volatile int age;

public User(String name, int age) {
super();
this.name = name;
this.age = age;
}

public String getName() {
return name;
}

public void setName(String name) {
this.name = name;
}

public int getAge() {
return age;
}

public void setAge(int age) {
this.age = age;
}

}

结果如下:

j6

参考:

https://github.com/Snailclimb/JavaGuide/blob/master/docs/java/Multithread/Atomic.md#31-%E6%95%B0%E7%BB%84%E7%B1%BB%E5%9E%8B%E5%8E%9F%E5%AD%90%E7%B1%BB%E4%BB%8B%E7%BB%8D

面试必备之乐观锁与悲观锁

发表于 2019-04-27 | 分类于 java面试准备 , 并发编程 |

何谓悲观锁与乐观锁

乐观锁对应于生活中乐观的人总是想着事情往好的方向发展,悲观锁对应于生活中悲观的人总是想着事情往坏的方向发展。这两种人各有优缺点,不能不以场景而定说一种人好于另外一种人。

悲观锁

总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁(共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程)。传统的关系型数据库里边就用到了很多这种锁机制,比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁。Java中synchronized和ReentrantLock等独占锁就是悲观锁思想的实现。

乐观锁

总是假设最好的情况,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,可以使用版本号机制和CAS算法实现。乐观锁适用于多读的应用类型,这样可以提高吞吐量,像数据库提供的类似于write_condition机制,其实都是提供的乐观锁。在Java中java.util.concurrent.atomic包下面的原子变量类就是使用了乐观锁的一种实现方式CAS实现的。

两种锁的使用场景

从上面对两种锁的介绍,我们知道两种锁各有优缺点,不可认为一种好于另一种,像乐观锁适用于写比较少的情况下(多读场景),即冲突真的很少发生的时候,这样可以省去了锁的开销,加大了系统的整个吞吐量。但如果是多写的情况,一般会经常产生冲突,这就会导致上层应用会不断的进行retry,这样反倒是降低了性能,所以一般多写的场景下用悲观锁就比较合适。

乐观锁常见的两种实现方式

乐观锁一般会使用版本号机制或CAS算法实现。

1. 版本号机制

一般是在数据表中加上一个数据版本号version字段,表示数据被修改的次数,当数据被修改时,version值会加一。当线程A要更新数据值时,在读取数据的同时也会读取version值,在提交更新时,若刚才读取到的version值为当前数据库中的version值相等时才更新,否则重试更新操作,直到更新成功。

举一个简单的例子: 假设数据库中帐户信息表中有一个 version 字段,当前值为 1 ;而当前帐户余额字段( balance )为 $100 。

  1. 操作员 A 此时将其读出( version=1 ),并从其帐户余额中扣除 $50( $100-$50 )。
  2. 在操作员 A 操作的过程中,操作员B 也读入此用户信息( version=1 ),并从其帐户余额中扣除 $20 ( $100-$20 )。
  3. 操作员 A 完成了修改工作,将数据版本号加一( version=2 ),连同帐户扣除后余额( balance=$50 ),提交至数据库更新,此时由于提交数据版本大于数据库记录当前版本,数据被更新,数据库记录 version 更新为 2 。
  4. 操作员 B 完成了操作,也将版本号加一( version=2 )试图向数据库提交数据( balance=$80 ),但此时比对数据库记录版本时发现,操作员 B 提交的数据版本号为 2 ,数据库记录当前版本也为 2 ,不满足 “ 提交版本必须大于记录当前版本才能执行更新 “ 的乐观锁策略,因此,操作员 B 的提交被驳回。

这样,就避免了操作员 B 用基于 version=1 的旧数据修改的结果覆盖操作员A 的操作结果的可能。

2. CAS算法

即compare and swap(比较与交换),是一种有名的无锁算法。无锁编程,即不使用锁的情况下实现多线程之间的变量同步,也就是在没有线程被阻塞的情况下实现变量的同步,所以也叫非阻塞同步(Non-blocking Synchronization)。CAS算法涉及到三个操作数

  • 需要读写的内存值 V
  • 进行比较的值 A
  • 拟写入的新值 B

当且仅当 V 的值等于 A时,CAS通过原子方式用新值B来更新V的值,否则不会执行任何操作(比较和替换是一个原子操作)。一般情况下是一个自旋操作,即不断的重试。

关于自旋锁,大家可以看一下这篇文章,非常不错:《 面试必备之深入理解自旋锁》

乐观锁的缺点

ABA 问题是乐观锁一个常见的问题

1. ABA 问题

如果一个变量V初次读取的时候是A值,并且在准备赋值的时候检查到它仍然是A值,那我们就能说明它的值没有被其他线程修改过了吗?很明显是不能的,因为在这段时间它的值可能被改为其他值,然后又改回A,那CAS操作就会误认为它从来没有被修改过。这个问题被称为CAS操作的 “ABA”问题。

JDK 1.5 以后的 AtomicStampedReference 类就提供了此种能力,其中的 compareAndSet 方法就是首先检查当前引用是否等于预期引用,并且当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值。

2. 循环时间长开销大

自旋CAS(也就是不成功就一直循环执行直到成功)如果长时间不成功,会给CPU带来非常大的执行开销。

如果JVM能支持处理器提供的 pause 指令那么效率会有一定的提升,pause 指令有两个作用:

第一、它可以延迟流水线执行指令(de-pipeline),使CPU不会消耗过多的执行资源,延迟的时间取决于具体实现的版本,在一些处理器上延迟时间是零。

第二、它可以避免在退出循环的时候因内存顺序冲突(memory order violation)而引起CPU流水线被清空(CPU pipeline flush),从而提高CPU的执行效率。

3. 只能保证一个共享变量的原子操作

CAS 只对单个共享变量有效,当操作涉及跨多个共享变量时 CAS 无效。但是从 JDK 1.5开始,提供了AtomicReference类来保证引用对象之间的原子性,你可以把多个变量放在一个对象里来进行 CAS 操作。所以我们可以使用锁或者利用AtomicReference类把多个共享变量合并成一个共享变量来操作。

CAS与synchronized的使用情景

简单的来说CAS适用于写比较少的情况下(多读场景,冲突一般较少),synchronized适用于写比较多的情况下(多写场景,冲突一般较多)

  1. 对于资源竞争较少(线程冲突较轻)的情况,使用synchronized同步锁进行线程阻塞和唤醒切换以及用户态内核态间的切换操作额外浪费消耗cpu资源;而CAS基于硬件实现,不需要进入内核,不需要切换线程,操作自旋几率较少,因此可以获得更高的性能。
  2. 对于资源竞争严重(线程冲突严重)的情况,CAS自旋的概率会比较大,从而浪费更多的CPU资源,效率低于synchronized。

补充: Java并发编程这个领域中synchronized关键字一直都是元老级的角色,很久之前很多人都会称它为 “重量级锁” 。但是,在JavaSE 1.6之后进行了主要包括为了减少获得锁和释放锁带来的性能消耗而引入的 偏向锁 和 轻量级锁 以及其它各种优化之后变得在某些情况下并不是那么重了。synchronized的底层实现主要依靠 Lock-Free 的队列,基本思路是 自旋后阻塞,竞争切换后继续竞争锁,稍微牺牲了公平性,但获得了高吞吐量。在线程冲突较少的情况下,可以获得和CAS类似的性能;而线程冲突严重的情况下,性能远高于CAS。

参考:

https://github.com/Snailclimb/JavaGuide/blob/master/docs/essential-content-for-interview/%E9%9D%A2%E8%AF%95%E5%BF%85%E5%A4%87%E4%B9%8B%E4%B9%90%E8%A7%82%E9%94%81%E4%B8%8E%E6%82%B2%E8%A7%82%E9%94%81.md

synchronized关键字

发表于 2019-04-26 | 分类于 java面试准备 , 并发编程 |

s1

synchronized关键字最主要的三种使用方式的总结

  • 修饰实例方法,作用于当前对象实例加锁,进入同步代码前要获得当前对象实例的锁
  • 修饰静态方法,作用于当前类对象,进入同步代码前要获得当前类对象的锁 ,也就是给当前类加锁,会作用于类的所有对象实例,因为静态成员不属于任何一个实例对象,是类成员( static 表明这是该类的一个静态资源,不管new了多少个对象,只有一份,所以对该类的所有对象都加了锁)。所以如果一个线程A调用一个实例对象的非静态 synchronized 方法,而线程B需要调用这个实例对象所属类的静态 synchronized 方法,是允许的,不会发生互斥现象,因为访问静态 synchronized 方法占用的锁是当前类的锁,而访问非静态 synchronized 方法占用的锁是当前实例对象锁。
  • 修饰代码块,指定加锁对象,对给定对象加锁,进入同步代码库前要获得给定对象的锁。 和 synchronized 方法一样,synchronized(this)代码块也是锁定当前对象的。synchronized 关键字加到 static 静态方法和 synchronized(class)代码块上都是是给 Class 类上锁。这里再提一下:synchronized关键字加到非 static 静态方法上是给对象实例上锁。

另外需要注意的是:尽量不要使用 synchronized(String a) ,因为JVM中,字符串常量池具有缓冲功能!

下面我已一个常见的面试题为例讲解一下 synchronized 关键字的具体使用。

面试中面试官经常会说:“单例模式了解吗?来给我手写一下!给我解释一下双重检验锁方式实现单例模式的原理呗!”

双重校验锁实现对象单例(线程安全)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class Singleton {

private volatile static Singleton uniqueInstance;

private Singleton() {
}

public static Singleton getUniqueInstance() {
//先判断对象是否已经实例过,没有实例化过才进入加锁代码
if (uniqueInstance == null) {
//类对象加锁
synchronized (Singleton.class) {
if (uniqueInstance == null) {
uniqueInstance = new Singleton();
}
}
}
return uniqueInstance;
}
}

另外,需要注意 uniqueInstance 采用 volatile 关键字修饰也是很有必要。

uniqueInstance = new Singleton(), 这段代码其实是分为三步执行:

  1. 为 uniqueInstance 分配内存空间
  2. 初始化 uniqueInstance
  3. 将 uniqueInstance 指向分配的内存地址

但是由于 JVM 具有指令重排的特性,执行顺序有可能变成 1->3->2。指令重排在单线程环境下不会出现问题,但是在多线程环境下会导致一个线程获得还没有初始化的实例。例如,线程 T1 执行了 1 和 3,此时 T2 调用 getUniqueInstance() 后发现 uniqueInstance 不为空,因此返回 uniqueInstance,但此时 uniqueInstance 还未被初始化。

使用 volatile 可以禁止 JVM 的指令重排,保证在多线程环境下也能正常运行。

synchronized 关键字底层原理总结

synchronized 关键字底层原理属于 JVM 层面

synchronized 同步语句块的情况

1
2
3
4
5
6
7
public class SynchronizedDemo {
public void method() {
synchronized (this) {
System.out.println("synchronized 代码块");
}
}
}

通过 JDK 自带的 javap 命令查看 SynchronizedDemo 类的相关字节码信息:首先切换到类的对应目录执行 javac SynchronizedDemo.java 命令生成编译后的 .class 文件,然后执行javap -c -s -v -l SynchronizedDemo.class。

s2

从上面我们可以看出:

synchronized 同步语句块的实现使用的是 monitorenter 和 monitorexit 指令,其中 monitorenter 指令指向同步代码块的开始位置,monitorexit 指令则指明同步代码块的结束位置。 当执行 monitorenter 指令时,线程试图获取锁也就是获取 monitor (monitor对象存在于每个Java对象的对象头中,synchronized 锁便是通过这种方式获取锁的,也是为什么Java中任意对象可以作为锁的原因) 的持有权。当计数器为0则可以成功获取,获取后将锁计数器设为1也就是加1。相应的在执行 monitorexit 指令后,将锁计数器设为0,表明锁被释放。如果获取对象锁失败,那当前线程就要阻塞等待,直到锁被另外一个线程释放为止。

synchronized 修饰方法的的情况

1
2
3
4
5
public class SynchronizedDemo2 {
public synchronized void method() {
System.out.println("synchronized 方法");
}
}

s3

synchronized 修饰的方法并没有 monitorenter 指令和 monitorexit 指令,取而代之的确实是 ACC_SYNCHRONIZED 标识,该标识指明了该方法是一个同步方法,JVM 通过该 ACC_SYNCHRONIZED 访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用。

在 Java 早期版本中,synchronized 属于重量级锁,效率低下,因为监视器锁(monitor)是依赖于底层的操作系统的 Mutex Lock 来实现的,Java 的线程是映射到操作系统的原生线程之上的。如果要挂起或者唤醒一个线程,都需要操作系统帮忙完成,而操作系统实现线程之间的切换时需要从用户态转换到内核态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高,这也是为什么早期的 synchronized 效率低的原因。庆幸的是在 Java 6 之后 Java 官方对从 JVM 层面对 synchronized 较大优化,所以现在的 synchronized 锁效率也优化得很不错了。

JDK1.6 之后的底层优化

JDK1.6 对锁的实现引入了大量的优化,如偏向锁、轻量级锁、自旋锁、适应性自旋锁、锁消除、锁粗化等技术来减少锁操作的开销。

锁主要存在四中状态,依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,他们会随着竞争的激烈而逐渐升级。注意锁可以升级不可降级,这种策略是为了提高获得锁和释放锁的效率。

偏向锁

引入偏向锁的目的和引入轻量级锁的目的很像,他们都是为了没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。但是不同是:轻量级锁在无竞争的情况下使用 CAS 操作去代替使用互斥量。而偏向锁在无竞争的情况下会把整个同步都消除掉。

偏向锁的“偏”就是偏心的偏,它的意思是会偏向于第一个获得它的线程,如果在接下来的执行中,该锁没有被其他线程获取,那么持有偏向锁的线程就不需要进行同步!关于偏向锁的原理可以查看《深入理解Java虚拟机:JVM高级特性与最佳实践》第二版的13章第三节锁优化。

但是对于锁竞争比较激烈的场合,偏向锁就失效了,因为这样场合极有可能每次申请锁的线程都是不相同的,因此这种场合下不应该使用偏向锁,否则会得不偿失,需要注意的是,偏向锁失败后,并不会立即膨胀为重量级锁,而是先升级为轻量级锁。

轻量级锁

倘若偏向锁失败,虚拟机并不会立即升级为重量级锁,它还会尝试使用一种称为轻量级锁的优化手段(1.6之后加入的)。轻量级锁不是为了代替重量级锁,它的本意是在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗,因为使用轻量级锁时,不需要申请互斥量。另外,轻量级锁的加锁和解锁都用到了CAS操作。 关于轻量级锁的加锁和解锁的原理可以查看《深入理解Java虚拟机:JVM高级特性与最佳实践》第二版的13章第三节锁优化。(互斥量的意思就是本质上的信号量机制问题)

轻量级锁能够提升程序同步性能的依据是“对于绝大部分锁,在整个同步周期内都是不存在竞争的”,这是一个经验数据。如果没有竞争,轻量级锁使用 CAS 操作避免了使用互斥操作的开销。但如果存在锁竞争,除了互斥量开销外,还会额外发生CAS操作,因此在有锁竞争的情况下,轻量级锁比传统的重量级锁更慢!如果锁竞争激烈,那么轻量级将很快膨胀为重量级锁!

自旋锁和自适应自旋

轻量级锁失败后,虚拟机为了避免线程真实地在操作系统层面挂起,还会进行一项称为自旋锁的优化手段。

互斥同步对性能最大的影响就是阻塞的实现,因为挂起线程/恢复线程的操作都需要转入内核态中完成(用户态转换到内核态会耗费时间)。

一般线程持有锁的时间都不是太长,所以仅仅为了这一点时间去挂起线程/恢复线程是得不偿失的。 所以,虚拟机的开发团队就这样去考虑:“我们能不能让后面来的请求获取锁的线程等待一会而不被挂起呢?看看持有锁的线程是否很快就会释放锁”。为了让一个线程等待,我们只需要让线程执行一个忙循环(自旋),这项技术就叫做自旋。

百度百科对自旋锁的解释:

何谓自旋锁?它是为实现保护共享资源而提出一种锁机制。其实,自旋锁与互斥锁比较类似,它们都是为了解决对某项资源的互斥使用。无论是互斥锁,还是自旋锁,在任何时刻,最多只能有一个保持者,也就说,在任何时刻最多只能有一个执行单元获得锁。但是两者在调度机制上略有不同。对于互斥锁,如果资源已经被占用,资源申请者只能进入睡眠状态。但是自旋锁不会引起调用者睡眠,如果自旋锁已经被别的执行单元保持,调用者就一直循环在那里看是否该自旋锁的保持者已经释放了锁,”自旋”一词就是因此而得名。

自旋锁在 JDK1.6 之前其实就已经引入了,不过是默认关闭的,需要通过--XX:+UseSpinning参数来开启。JDK1.6及1.6之后,就改为默认开启的了。需要注意的是:自旋等待不能完全替代阻塞,因为它还是要占用处理器时间。如果锁被占用的时间短,那么效果当然就很好了!反之,相反!自旋等待的时间必须要有限度。如果自旋超过了限定次数任然没有获得锁,就应该挂起线程。自旋次数的默认值是10次,用户可以修改–XX:PreBlockSpin来更改。

另外,在 JDK1.6 中引入了自适应的自旋锁。自适应的自旋锁带来的改进就是:自旋的时间不在固定了,而是和前一次同一个锁上的自旋时间以及锁的拥有者的状态来决定,虚拟机变得越来越“聪明”了。

锁消除

锁消除理解起来很简单,它指的就是虚拟机即使编译器在运行时,如果检测到那些共享数据不可能存在竞争,那么就执行锁消除。锁消除可以节省毫无意义的请求锁的时间。

锁粗化

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

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

Synchronized 和 ReenTrantLock 的对比

两者都是可重入锁

两者都是可重入锁。“可重入锁”概念是:自己可以再次获取自己的内部锁。比如一个线程获得了某个对象的锁,此时这个对象锁还没有释放,当其再次想要获取这个对象的锁的时候还是可以获取的,如果不可锁重入的话,就会造成死锁。同一个线程每次获取锁,锁的计数器都自增1,所以要等到锁的计数器下降为0时才能释放锁。

synchronized 依赖于 JVM 而 ReenTrantLock 依赖于 API

synchronized 是依赖于 JVM 实现的,前面我们也讲到了 虚拟机团队在 JDK1.6 为 synchronized 关键字进行了很多优化,但是这些优化都是在虚拟机层面实现的,并没有直接暴露给我们。

ReenTrantLock 是 JDK 层面实现的(也就是 API 层面,需要 lock() 和 unlock() 方法配合 try/finally 语句块来完成),所以我们可以通过查看它的源代码,来看它是如何实现的。

ReenTrantLock 比 synchronized 增加了一些高级功能

相比synchronized,ReenTrantLock增加了一些高级功能。

主要来说主要有三点:①等待可中断;②可实现公平锁;③可实现选择性通知(锁可以绑定多个条件)

  • ReenTrantLock提供了一种能够中断等待锁的线程的机制,通过lock.lockInterruptibly()来实现这个机制。也就是说正在等待的线程可以选择放弃等待,改为处理其他事情。
  • ReenTrantLock可以指定是公平锁还是非公平锁。而synchronized只能是非公平锁。所谓的公平锁就是先等待的线程先获得锁。 ReenTrantLock默认情况是非公平的,可以通过 ReenTrantLock类的ReentrantLock(boolean fair)构造方法来制定是否是公平的。
  • synchronized关键字与wait()和notify/notifyAll()方法相结合可以实现等待/通知机制,ReentrantLock类当然也可以实现,但是需要借助于Condition接口与newCondition()方法。Condition是JDK1.5之后才有的,它具有很好的灵活性,比如可以实现多路通知功能也就是在一个Lock对象中可以创建多个Condition实例(即对象监视器),线程对象可以注册在指定的Condition中,从而可以有选择性的进行线程通知,在调度线程上更加灵活。 在使用notify/notifyAll()方法进行通知时,被通知的线程是由 JVM 选择的,用ReentrantLock类结合Condition实例可以实现“选择性通知” ,这个功能非常重要,而且是Condition接口默认提供的。而synchronized关键字就相当于整个Lock对象中只有一个Condition实例,所有的线程都注册在它一个身上。如果执行notifyAll()方法的话就会通知所有处于等待状态的线程这样会造成很大的效率问题,而Condition实例的signalAll()方法只会唤醒注册在该Condition实例中的所有等待线程。

性能已不是选择标准

在JDK1.6之前,synchronized 的性能是比 ReenTrantLock 差很多。

具体表示为:synchronized 关键字吞吐量随线程数的增加,下降得非常严重。而ReenTrantLock 基本保持一个比较稳定的水平。我觉得这也侧面反映了, synchronized 关键字还有非常大的优化余地。后续的技术发展也证明了这一点,我们上面也讲了在 JDK1.6 之后 JVM 团队对 synchronized 关键字做了很多优化。JDK1.6 之后,synchronized 和 ReenTrantLock 的性能基本是持平了。所以网上那些说因为性能才选择 ReenTrantLock 的文章都是错的!JDK1.6之后,性能已经不是选择synchronized和ReenTrantLock的影响因素了!而且虚拟机在未来的性能改进中会更偏向于原生的synchronized,所以还是提倡在synchronized能满足你的需求的情况下,优先考虑使用synchronized关键字来进行同步!优化后的synchronized和ReenTrantLock一样,在很多地方都是用到了CAS操作。

参考

https://github.com/Snailclimb/JavaGuide/blob/master/docs/java/synchronized.md

你真的理解Spring AOP吗

发表于 2019-04-25 | 分类于 java面试准备 , Spring |

概述

为什么会有面向切面编程(AOP)?

我们知道Java是一个面向对象(OOP)的语言,但它有一些弊端,比如当我们需要为多个不具有继承关系的对象引入一个公共行为,例如日志,权限验证,事务等功能时,只能在在每个对象里引用公共行为,这样做不便于维护,而且有大量重复代码。AOP的出现弥补了OOP的这点不足。

为了阐述清楚Spring AOP,我们从将以下方面进行讨论:

  1. 代理模式
  2. 静态代理原理及实践
  3. 动态代理原理及实践
  4. Spring AOP原理及实战

代理模式

代理模式:为其他对象提供一种代理以控制对这个对象的访问。这段话比较官方,但我更倾向于用自己的语言理解:比如A对象要做一件事情,在没有代理前,自己来做;在对 A 代理后,由 A 的代理类 B 来做。

代理其实是在原实例前后加了一层处理,这也是 AOP 的初级轮廓。

静态代理原理及实践

静态代理模式:静态代理说白了,就是在程序运行前就已经存在代理类的字节码文件,代理类和原始类的关系在运行前就已经确定。

源码实例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
package test.staticProxy;

// 接口
public interface IUserDao {
void save();
void find();
}

//目标对象
class UserDao implements IUserDao{
@Override
public void save() {
System.out.println("模拟:保存用户!");
}
@Override
public void find() {
System.out.println("模拟:查询用户");
}
}

/**
* 静态代理
* 特点:
* 1. 目标对象必须要实现接口
* 2. 代理对象,要实现与目标对象一样的接口
*/
class UserDaoProxy implements IUserDao{

// 代理对象,需要维护一个目标对象
private IUserDao target = new UserDao();

@Override
public void save() {
System.out.println("代理操作: 开启事务...");
target.save(); // 执行目标对象的方法
System.out.println("代理操作:提交事务...");
}

@Override
public void find() {
target.find();
}
}

测试结果:

s1

静态代理虽然保证了业务类只需关注逻辑本身,代理对象的一个接口只服务于一种类型的对象。如果要代理的方法很多,势必要为每一种方法都进行代理。再者,如果增加一个方法,除了实现类需要实现这个方法外,所有的代理类也要实现此方法。增加了代码的维护成本。那么要如何解决呢?答案是使用动态代理。

动态代理原理及实践

动态代理模式:动态代理类的源码是在程序运行期间,通过 JVM 反射等机制动态生成。代理类和委托类的关系是运行时才确定的。

源码实例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
package test.dynamicProxy;

import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;

// 接口
public interface IUserDao {
void save();
void find();
}

//目标对象
class UserDao implements IUserDao{

@Override
public void save() {
System.out.println("模拟: 保存用户!");
}

@Override
public void find() {
System.out.println("查询");
}
}

/**
* 动态代理:
* 代理工厂,给多个目标对象生成代理对象!
*
*/
class ProxyFactory {

// 接收一个目标对象
private Object target;

public ProxyFactory(Object target) {
this.target = target;
}

// 返回对目标对象(target)代理后的对象(proxy)
public Object getProxyInstance() {
Object proxy = Proxy.newProxyInstance(
target.getClass().getClassLoader(), // 目标对象使用的类加载器
target.getClass().getInterfaces(), // 目标对象实现的所有接口
new InvocationHandler() { // 执行代理对象方法时候触发

@Override
public Object invoke(Object proxy, Method method, Object[] args)
throws Throwable {

// 获取当前执行的方法的方法名
String methodName = method.getName();
// 方法返回值
Object result = null;
if ("find".equals(methodName)) {
// 直接调用目标对象方法
result = method.invoke(target, args);
} else {
System.out.println("开启事务...");
// 执行目标对象方法
result = method.invoke(target, args);
System.out.println("提交事务...");
}
return result;
}
}
);
return proxy;
}
}

测试结果如下:

s2

1
IUserDao proxy = (IUserDao)new ProxyFactory(target).getProxyInstance();

其实是 JDK 动态生成了一个类去实现接口,隐藏了这个过程:

1
class $jdkProxy implements IUserDao{}

使用 JDK 生成的动态代理的前提是目标类必须有实现的接口。

这里又引入一个问题,如果某个类没有实现接口,就不能使用 JDK 动态代理。所以 CGLIB 代理就是解决这个问题的。

CGLIB 是以动态生成的子类继承目标类的方式实现,在运行期动态的在内存中构建一个子类,如下:

1
2
3
4
public class UserDao{}

// CGLIB是以动态生成的子类继承目标类的方式实现,程序执行时,隐藏了下面的过程。
public class $Cglib_Proxy_class extends UserDao{}

CGLIB 使用的前提是目标类不能为 final 修饰,因为 final 修饰的类不能被继承。

现在,我们可以看看 AOP 的定义:面向切面编程,核心原理是使用动态代理模式在方法执行前后或出现异常时加入相关逻辑。

通过定义和前面代码我们可以发现3点:

  • AOP 是基于动态代理模式。
  • AOP 是方法级别的。
  • AOP 可以分离业务代码和关注点代码(重复代码),在执行业务代码时,动态的注入关注点代码。切面就是关注点代码形成的类。

Spring AOP

前文提到 JDK 代理和 CGLIB 代理两种动态代理。优秀的 Spring 框架把两种方式在底层都集成了进去,我们无需担心自己去实现动态生成代理。那么,Spring是如何生成代理对象的?

  1. 创建容器对象的时候,根据切入点表达式拦截的类,生成代理对象。
  2. 如果目标对象有实现接口,使用 JDK 代理。如果目标对象没有实现接口,则使用 CGLIB 代理。然后从容器获取代理后的对象,在运行期植入“切面”类的方法。

通过查看 Spring 源码,我们在 DefaultAopProxyFactory 类中,找到这样一段话:

s3

简单的从字面意思看出:如果有接口,则使用 JDK 代理,反之使用 CGLIB ,这刚好印证了前文所阐述的内容。

Spring AOP 综合两种代理方式的使用前提有会如下结论:如果目标类没有实现接口,且 class 为 final 修饰的,则不能进行 Spring AOP 编程!

现在我们将自己手动实现 Spring 的 AOP:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
package test.spring_aop_anno;

import org.aspectj.lang.ProceedingJoinPoint;

public interface IUserDao {
void save();
}

// 用于测试 CGLIB 动态代理
class OrderDao {
public void save() {
//int i =1/0; 用于测试异常通知
System.out.println("保存订单...");
}
}

//用于测试 JDK 动态代理
class UserDao implements IUserDao {
public void save() {
//int i =1/0; 用于测试异常通知
System.out.println("保存用户...");
}
}

//切面类
class TransactionAop {

public void beginTransaction() {
System.out.println("[前置通知] 开启事务..");
}

public void commit() {
System.out.println("[后置通知] 提交事务..");
}

public void afterReturing() {
System.out.println("[返回后通知]");
}

public void afterThrowing() {
System.out.println("[异常通知]");
}

public void arroud(ProceedingJoinPoint pjp) throws Throwable {
System.out.println("[环绕前:]");
pjp.proceed(); // 执行目标方法
System.out.println("[环绕后:]");
}
}package test.spring_aop_anno;

import org.aspectj.lang.ProceedingJoinPoint;

public interface IUserDao {
void save();
}

// 用于测试 CGLIB 动态代理
class OrderDao {
public void save() {
//int i =1/0; 用于测试异常通知
System.out.println("保存订单...");
}
}

//用于测试 JDK 动态代理
class UserDao implements IUserDao {
public void save() {
//int i =1/0; 用于测试异常通知
System.out.println("保存用户...");
}
}

//切面类
class TransactionAop {

public void beginTransaction() {
System.out.println("[前置通知] 开启事务..");
}

public void commit() {
System.out.println("[后置通知] 提交事务..");
}

public void afterReturing() {
System.out.println("[返回后通知]");
}

public void afterThrowing() {
System.out.println("[异常通知]");
}

public void arroud(ProceedingJoinPoint pjp) throws Throwable {
System.out.println("[环绕前:]");
pjp.proceed(); // 执行目标方法
System.out.println("[环绕后:]");
}
}

Spring 的 XML 配置文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:context="http://www.springframework.org/schema/context"
xmlns:aop="http://www.springframework.org/schema/aop"
xsi:schemaLocation="

http://www.springframework.org/schema/beans


http://www.springframework.org/schema/beans/spring-beans.xsd


http://www.springframework.org/schema/context


http://www.springframework.org/schema/context/spring-context.xsd


http://www.springframework.org/schema/aop


http://www.springframework.org/schema/aop/spring-aop.xsd">

<!-- dao实例加入容器 -->
<bean id="userDao" class="test.spring_aop_anno.UserDao"></bean>

<!-- dao实例加入容器 -->
<bean id="orderDao" class="test.spring_aop_anno.OrderDao"></bean>

<!-- 实例化切面类 -->
<bean id="transactionAop" class="test.spring_aop_anno.TransactionAop"></bean>

<!-- Aop相关配置 -->
<aop:config>
<!-- 切入点表达式定义 -->
<aop:pointcut expression="execution(* test.spring_aop_anno.*Dao.*(..))" id="transactionPointcut"/>
<!-- 切面配置 -->
<aop:aspect ref="transactionAop">
<!-- 【环绕通知】 -->
<aop:around method="arroud" pointcut-ref="transactionPointcut"/>
<!-- 【前置通知】 在目标方法之前执行 -->
<aop:before method="beginTransaction" pointcut-ref="transactionPointcut" />
<!-- 【后置通知】 -->
<aop:after method="commit" pointcut-ref="transactionPointcut"/>
<!-- 【返回后通知】 -->
<aop:after-returning method="afterReturing" pointcut-ref="transactionPointcut"/>
<!-- 异常通知 -->
<aop:after-throwing method="afterThrowing" pointcut-ref="transactionPointcut"/>
</aop:aspect>
</aop:config>
</beans>

代码的测试结果如下:

s4

最后,我们来聊聊Spring AOP用来做什么?

  1. Spring声明式事务管理配置:请参考博主的另一篇文章:分布式系统架构实战 demo:SSM+Dubbo
  2. Controller层的参数校验:参考 Spring AOP拦截Controller做参数校验
  3. 使用 Spring AOP 实现 MySQL 数据库读写分离案例分析
  4. 在执行方法前,判断是否具有权限
  5. 对部分函数的调用进行日志记录:监控部分重要函数,若抛出指定的异常,可以以短信或邮件方式通知相关人员。
  6. 信息过滤,页面转发等等功能

参考:

http://www.importnew.com/31318.html#comment-765192

你真的会写单例模式吗

发表于 2019-04-25 | 分类于 java面试准备 , 设计模式 |

概述

“你知道茴香豆的‘茴’字有几种写法吗?”

纠结单例模式有几种写法有用吗?有点用,面试中经常选择其中一种或几种写法作为话头,考查设计模式和coding style的同时,还很容易扩展到其他问题。这里讲解几种猴子常用的写法,但切忌生搬硬套,去记“茴香豆的写法”。编程最大的乐趣在于“know everything, control everything”。

面试中单例模式有几种写法?

大体可分为4类,下面分别介绍他们的基本形式、变种及特点。

饱汉模式

基础的饱汉(单线程写法)

饱汉,即已经吃饱,不着急再吃,饿的时候再吃。所以他就先不初始化单例,等第一次使用的时候再初始化,即“懒加载”。

写法:由私有构造器和一个公有静态工厂方法构成,在工厂方法中对singleton进行null判断,如果是null就new一个出来,最后返回singleton对象。

注意:这种方法可以实现延时加载,但是有一个致命弱点:线程不安全。如果有两条线程同时调用getInstance()方法,就有很大可能导致重复创建对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 饱汉
// UnThreadSafe
public class Singleton1 {

private static Singleton1 singleton = null;

private Singleton1() {}

public static Singleton1 getInstance() {
if (singleton == null) {
singleton = new Singleton1();
}
return singleton;
}
}

饱汉模式的核心就是懒加载。好处是启动速度快、节省资源,一直到实例被第一次访问,才需要初始化单例;小坏处是写起来麻烦,大坏处是线程不安全,if语句存在竞态条件。

这种写法写起来麻烦不是大问题,但是可读性好啊。因此,单线程环境下,基础饱汉是我们最喜欢的写法。但多线程环境下,基础饱汉就彻底不可用了。下面的几种变种都在试图解决基础饱汉线程不安全的问题。

饱汉 - 变种 1

最粗暴的犯法是用synchronized关键字修饰getInstance()方法,这样能达到绝对的线程安全。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// 饱汉
// ThreadSafe
public class Singleton1_1 {

private static Singleton1_1 singleton = null;

private Singleton1_1() {}

public synchronized static Singleton1_1 getInstance() {
if (singleton == null) {
singleton = new Singleton1_1();
}
return singleton;
}
}

或

public class Singleton {
private static volatile Singleton singleton = null;

private Singleton() {}

public static Singleton getSingleton(){
synchronized (Singleton.class){
if(singleton == null){
singleton = new Singleton();
}
}
return singleton;
}
}

变种1的好处是写起来简单,且绝对线程安全;坏处是并发性能极差,事实上完全退化到了串行。单例只需要初始化一次,但就算初始化以后,synchronized的锁也无法避开,从而getInstance()完全变成了串行操作。性能不敏感的场景建议使用。

饱汉 - 变种 2

变种2是“臭名昭著”的DCL 1.0。

针对变种1中单例初始化后锁仍然无法避开的问题,变种2在变种1的外层又套了一层check,加上synchronized内层的check,即所谓“双重检查锁”(Double Check Lock,简称DCL)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 饱汉
// UnThreadSafe
public class Singleton1_2 {
private static Singleton1_2 singleton = null;

public int f1 = 1;// 触发部分初始化问题
public int f2 = 2;

private Singleton1_2() {}

public static Singleton1_2 getInstance() {
// may get half object
if (singleton == null) {
synchronized (Singleton1_2.class) {
if (singleton == null) {
singleton = new Singleton1_2();
}
}
}
return singleton;
}
}

变种2的核心是DCL,看起来变种2似乎已经达到了理想的效果:懒加载+线程安全。可惜的是,正如注释中所说,DCL仍然是线程不安全的,由于指令重排序,你可能会得到“半个对象”,即”部分初始化“问题。

饱汉 - 变种 3(兼顾线程安全和效率的写法)

变种3专门针对变种2,可谓DCL 2.0。

针对变种3的“半个对象”问题,变种3在实例上增加了volatile关键字。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 饱汉
// ThreadSafe
public class Singleton1_3 {
private static volatile Singleton1_3 singleton = null;

public int f1 = 1; // 触发部分初始化问题
public int f2 = 2;

private Singleton1_3() {}
public static Singleton1_3 getInstance() {
if (singleton == null) {
synchronized (Singleton1_3.class) {
// must be a complete instance
if (singleton == null) {
singleton = new Singleton1_3();
}
}
}
return singleton;
}
}

这种写法被称为”双重检查锁”,顾名思义,就是在getInstance()方法中,进行两次null检查。

看似多此一举,但实际上却极大提升了并发度,进而提升了性能。

为什么可以提高并发度呢?就像上文说的,在单例中new的情况非常少,绝大多数都是可以并行的读操作。因此在加锁前多进行一次null检查就可以减少绝大多数的加锁操作,执行效率提高的目的也就达到了。

多线程环境下,变种3更适用于性能敏感的场景。但后面我们将了解到,就算是线程安全的,还有一些办法能破坏单例。

注意:那么,这种写法是不是绝对安全呢?前面说了,从语义角度来看,并没有什么问题。但是其实还是有坑,说这个坑之前我们要先来看看volatile这个关键字。其实这个关键字有两层语义。第一层语义相信大家都比较熟悉,就是可见性。可见性指的是在一个线程中对该变量的修改会马上由工作内存(Work Memory)写回主内存(Main Memory),所以会马上反应在其它线程的读取操作中。顺便一提,工作内存和主内存可以近似理解为实际电脑中的高速缓存和主存,工作内存是线程独享的,主存是线程共享的。volatile的第二层语义是禁止指令重排序优化。大家知道我们写的代码(尤其是多线程代码),由于编译器优化,在实际执行的时候可能与我们编写的顺序不同。编译器只保证程序执行结果与源代码相同,却不保证实际指令的顺序与源代码相同。这在单线程看起来没什么问题,然而一旦引入多线程,这种乱序就可能导致严重问题。

volatile关键字就可以从语义上解决这个问题。虽然从语义上讲是没有问题的,但是很不幸,禁止指令重排优化这条语义直到jdk1.5以后才能正确工作。此前的JDK中即使将变量声明为volatile也无法完全避免重排序所导致的问题。所以,在jdk1.5版本前,双重检查锁形式的单例模式是无法保证线程安全的。

饿汉模式

顾名思义,饿汉法就是在第一次引用该类的时候就创建对象实例,而不管实际是否需要创建。

与饱汉相对,饿汉很饿,只想着尽早吃到。所以他就在最早的时机,即类加载时初始化单例,以后访问时直接返回即可。

1
2
3
4
5
6
7
8
9
10
11
// 饿汉
// ThreadSafe
public class Singleton2 {

private static final Singleton2 singleton = new Singleton2();
private Singleton2() {}

public static Singleton2 getInstance() {
return singleton;
}
}

饿汉的好处是天生的线程安全(得益于类加载机制),写起来超级简单,使用时没有延迟;坏处是有可能造成资源浪费(如果类加载后就一直不使用单例的话)。

值得注意的时,单线程环境下,饿汉与饱汉在性能上没什么差别;但多线程环境下,由于饱汉需要加锁,饿汉的性能反而更优。

Holder模式(静态内部类法)

我们既希望利用饿汉模式中静态变量的方便和线程安全,又希望通过懒加载规避资源浪费。

Holder模式满足了这两点要求:

核心仍然是静态变量,足够方便和线程安全;通过静态的Holder类持有真正实例,间接实现了懒加载。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Holder模式
// ThreadSafe
public class Singleton3 {

private static class SingletonHolder {
private static final Singleton3 singleton = new Singleton3();
private SingletonHolder() {}
}

private Singleton3() {}

public static Singleton3 getInstance() {
return SingletonHolder.singleton;
}
}

相对于饿汉模式,Holder模式仅增加了一个静态内部类的成本,与饱汉的变种3效果相当(略优),都是比较受欢迎的实现方式,同样建议考虑。

注意

上面提到的所有实现方式都有两个共同的缺点:

  • 都需要额外的工作(Serializable、transient、readResolve())来实现序列化,否则每次反序列化一个序列化的对象实例时都会创建一个新的实例。
  • 可能会有人使用反射强行调用我们的私有构造器(如果要避免这种情况,可以修改构造器,让它在创建第二个实例的时候抛异常)。

枚举模式

还有一种更加优雅的方法来实现单例模式,那就是枚举写法。

通过反射或序列化,我们仍然能够访问到私有构造器,创建新的实例破坏单例模式。此时,只有枚举模式能天然防范这一问题。

用枚举实现单例模式,相当好用,但可读性是不存在的。

基础的枚举

将枚举的静态成员变量作为单例的实例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 枚举
// ThreadSafe
public enum Singleton4 {

SINGLETON;
private String name;

public String getName(){
return name;
}
public void setName(String name){
this.name = name;
}
}

代码量比饿汉模式更少,但用户只能直接访问实例Singleton4.SINGLETON——事实上,这样的访问方式作为单例使用也是恰当的,只是牺牲了静态工厂方法的优点,如无法实现懒加载。

使用枚举除了线程安全和防止反射强行调用构造器之外,还提供了自动序列化机制,防止反序列化的时候创建新的对象。

丑陋但好用的语法糖

Java的枚举是一个“丑陋但好用的语法糖”。

通过反编译打开语法糖,就看到了枚举类型的本质,简化如下:

1
2
3
4
5
6
7
// 枚举
// ThreadSafe
public class Singleton4 extends Enum<Singleton4> {
...
public static final Singleton4 SINGLETON = new Singleton4();
...
}

本质上和饿汉模式相同,区别仅在于公有的静态成员变量。

用枚举实现一些trick

这一部分与单例没什么关系,可以跳过。如果选择阅读也请认清这样的事实:虽然枚举相当灵活,但如何恰当的使用枚举有一定难度。一个足够简单的典型例子是TimeUnit类,建议有时间耐心阅读。

上面已经看到,枚举型单例的本质仍然是一个普通的类。实际上,我们可以在枚举型型单例上增加任何普通类可以完成的功能。要点在于枚举实例的初始化,可以理解为实例化了一个匿名内部类。为了更明显,我们在Singleton4_1中定义一个普通的私有成员变量,一个普通的公有成员方法,和一个公有的抽象成员方法,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// 枚举
// ThreadSafe
public enum Singleton4_1 {

SINGLETON("enum is the easiest singleton pattern, but not the most readable") {
public void testAbsMethod() {
print();
System.out.println("enum is ugly, but so flexible to make lots of trick");
}
};

private String comment = null;

Singleton4_1(String comment) {
this.comment = comment;
}

public void print() {
System.out.println("comment=" + comment);
}

abstract public void testAbsMethod();

public static Singleton4_1 getInstance() {
return SINGLETON;
}
}

这样,枚举类Singleton4_1中的每一个枚举实例不仅继承了父类Singleton4_1的成员方法print(),还必须实现父类Singleton4_1的抽象成员方法testAbsMethod()。

总结

实现方式 关键点 资源浪费 线程安全 多线程环境的性能足够优化
基础饱汉 懒加载 否 否 -
饱汉变种1 懒加载、同步 否 是 否
饱汉变种2 懒加载、DCL 否 否 -
饱汉变种3 懒加载、DCL、volatile 否 是 是
饿汉 静态变量初始化 是 是 是
Holder 静态变量初始化、holder 否 是 是
枚举 枚举本质、静态变量初始化 否 是 是

最后,不管采取何种方案,请时刻牢记单例的三大要点:

  • 线程安全
  • 延迟加载
  • 序列化与反序列化安全

参考:

http://www.importnew.com/27056.html

http://www.tekbroaden.com/singleton-java.html

12…4
FoBoHuang

FoBoHuang

乌云后面依然是灿烂的晴天。——朗弗罗

40 日志
12 分类
11 标签
GitHub
© 2019 FoBoHuang
由 Hexo 强力驱动
|
主题 — NexT.Mist v5.1.4