Java 并发编程 常见面试总结

Posted ESOO

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Java 并发编程 常见面试总结相关的知识,希望对你有一定的参考价值。

目录

一. Socket流阻塞

二.  wait和notify

三. 线程实现的两种方式

四. synchronized同步代码块示例

五. ReentrantLock的方法示例

六. Lock和synchronized的一些区别和选择考虑的因素

七. Java并发包中的线程池种类及其特性介绍

八. 线程池&Future

九. BlockingQueue

十. volatile

十一. 并发编程总结

十二. 扩展.JMS->ActiveMQ

十三. 扩展.Java的反射实现API

十四. 扩展.动态代理的工作机制


一. Socket流阻塞

 

传统方式下,client和server之间是通过socket连接的,当client连接上server的时候,会创建一个线程,server是不知道client什么时候发消息的,所以一直等待,而且线程一直保持连接,这叫同步阻塞IO,是非常消耗性能的,慢速连接攻击大概是这个意思吧,长期占用着资源,却发送很少消息,这种对资源的不释放,最终结果就是server端不堪重负,最终挂掉。

为了解决上面的问题,就需要异步非阻塞IO,简称NIO。

demo:

标准io socket:
    服务端使用多线程处理的结构示意图:
       
服务器端代码:   
      主线程负责不断地请求echoServer.accept(),如果没有客户端请求主线程会阻塞,当有客户端请求服务器端时,主线程会用线程池新创建一个线程执行。也就是说一个线程负责一个客户端socket,当一个客户端socket因为网络延迟时,服务器端负责这个客户端的线程就会等待,浪费资源。

    import java.io.BufferedReader;
    import java.io.IOException;
    import java.io.InputStreamReader;
    import java.io.PrintWriter;
    import java.net.ServerSocket;
    import java.net.Socket;
    import java.nio.Buffer;
    import java.util.concurrent.ExecutorService;
    import java.util.concurrent.Executors;
     
    /**
     * 常规的socket服务端,服务器端采用一个线程接受一个客户端来处理。
     * Created by chenyang on 2017/3/26.
     */
    public class MultiThreadEchoServer
        private static ExecutorService tp= Executors.newCachedThreadPool();
        static class HandleMsg implements Runnable
            Socket clientSocket;
     
            public HandleMsg(Socket clientSocket)
                this.clientSocket = clientSocket;
           
     
            @Override
            public void run()
                BufferedReader is=null;
                PrintWriter os=null;
                try
                    is=new BufferedReader(new InputStreamReader(clientSocket.getInputStream()));
                    os=new PrintWriter(clientSocket.getOutputStream(),true);
                    //从InputStream当中读取客户端所发送的数据
                    String inputLine=null;
                    long b=System.currentTimeMillis();
                    while ((inputLine=is.readLine())!=null)
                        os.println(inputLine);
                   
                    long e=System.currentTimeMillis();
                    System.out.println("spend:"+(e-b)+"ms");
                catch (IOException e)
                    e.printStackTrace();
                finally
                    try
                        if(is!=null) is.close();
                        if(os!=null) os.close();
                        clientSocket.close();
                    catch (IOException ex)
                        ex.printStackTrace();
                   
               
           
       
     
        public static void main(String[] args)
            ServerSocket echoServer=null;
            Socket clientSocket=null;
            try
                echoServer=new ServerSocket(8000);
            catch (IOException e)
                System.out.println(e);
           
            while (true)
                try
                    clientSocket =echoServer.accept();//阻塞
                    System.out.println(clientSocket.getRemoteSocketAddress()+" connect!"+System.currentTimeMillis());

            //子线程负责执行与client socket 交互的操作。
                    tp.execute(new HandleMsg(clientSocket));
                catch (IOException e)
                    System.out.println(e);
               
           
       
   


客户端代码:

主线程创建10个子线程去请求server:这是个模拟网络拥堵时的客户端socket,每打一个字符就会停1秒。这样服务端的线程也要等待,这样服务器端的资源浪费的就很多。

    import java.io.BufferedReader;
    import java.io.IOException;
    import java.io.InputStreamReader;
    import java.io.PrintWriter;
    import java.net.InetSocketAddress;
    import java.net.Socket;
    import java.net.UnknownHostException;
    import java.util.concurrent.ExecutorService;
    import java.util.concurrent.Executors;
    import java.util.concurrent.locks.LockSupport;
     
    /**
     * 传统IO下,模拟10个网络不好的客户端同时访问server.
     * Created by chenyang on 2017/4/8.
     */
    public class HeavyThreadEchoClient
        static ExecutorService es= Executors.newCachedThreadPool();
        static Long sleep_time=1000*1000*1000L;
        public static class EchoClient implements Runnable
            @Override
            public void run()
     
                Socket client=null;
                PrintWriter writer=null;
                BufferedReader reader=null;
                try
                    client=new Socket();
                    client.connect(new InetSocketAddress("localhost",8000));
                    writer=new PrintWriter(client.getOutputStream(),true);
                    writer.print("h");
                    LockSupport.parkNanos(sleep_time);
                    writer.print("e");
                    LockSupport.parkNanos(sleep_time);
     
                    writer.print("l");
                    LockSupport.parkNanos(sleep_time);
     
                    writer.print("l");
                    LockSupport.parkNanos(sleep_time);
     
                    writer.print("o");
                    LockSupport.parkNanos(sleep_time);
     
                    writer.print("!");
                    LockSupport.parkNanos(sleep_time);
     
                    writer.println();
                    writer.flush();
                    reader=new BufferedReader(new InputStreamReader(client.getInputStream()));
                    System.out.println("from server:"+reader.readLine());
                catch (UnknownHostException ex)
                    ex.printStackTrace();
                catch (IOException e)
                    e.printStackTrace();
                finally
                    if(writer!=null)
                        writer.close();
                   
                    if(reader!=null)
                        try
                            reader.close();
                        catch (IOException ex)
                            ex.printStackTrace();
                       
     
                   
                    if(client!=null)
                        try
                            client.close();
                        catch (IOException ex)
                            ex.printStackTrace();
                       
                   
               
           
       
     
        public static void main(String[] args)
            EchoClient ec=new EchoClient();
            for(int i=0;i<10;i++)
                es.execute(ec);
           
       
   

当服务器端和客户端代码执行后的结果:

spend:6023ms
spend:6023ms
spend:6024ms
spend:6024ms
spend:6025ms
spend:6025ms
spend:6026ms
spend:6027ms
spend:6027ms
spend:6028ms

都有6秒的延迟,这都是网络io等待时间造成的。

nio socket:

    通过事件通知的机制,当数据准备好了才会通知服务器端线程进行读写,避免了网络io等待。

    服务端多线程的结构示意图:

         

    一个线程控制一个selector,一个selector可以轮询多个客户端的channel,这样服务器端线程不用等待网络io,只会处理准备好的数据。

服务器端代码:

    import java.io.IOException;
    import java.net.InetAddress;
    import java.net.InetSocketAddress;
    import java.net.Socket;
    import java.nio.ByteBuffer;
    import java.nio.channels.SelectionKey;
    import java.nio.channels.Selector;
    import java.nio.channels.ServerSocketChannel;
    import java.nio.channels.SocketChannel;
    import java.nio.channels.spi.SelectorProvider;
    import java.util.*;
    import java.util.concurrent.ExecutorService;
    import java.util.concurrent.Executors;
     
    /**
     * Created by chenyang on 2017/4/8.
     */
    public class MultiThreadNIOEchoServer
     
     
        public static Map<Socket,Long> geym_time_stat=new HashMap<Socket,Long>(10240);
        class EchoClient
            private LinkedList<ByteBuffer> outq;
            EchoClient()
                outq=new LinkedList<ByteBuffer>();
           
            //return the output queue
            public LinkedList<ByteBuffer> getOutputQueue()
                return outq;
           
            //enqueue a ByteBuffer on the output queue.
            public void enqueue(ByteBuffer bb)
                outq.addFirst(bb);
           
       
     
     
     
        class HandleMsg implements Runnable
            SelectionKey sk;
            ByteBuffer bb;
     
            public HandleMsg(SelectionKey sk, ByteBuffer bb)
                this.sk = sk;
                this.bb = bb;
           
     
            @Override
            public void run()
                EchoClient echoClient=(EchoClient)sk.attachment();
                echoClient.enqueue(bb);
     
                //we've enqueued data to be written to the client,we must
                //not set interest in OP_WRITE
                sk.interestOps(SelectionKey.OP_READ|SelectionKey.OP_WRITE);
                selector.wakeup();
           
       
     
        private Selector selector;
        private ExecutorService tp= Executors.newCachedThreadPool();
     
        /*
          accept a new client and set it up for reading
         */
        private void doAccept(SelectionKey sk)
            ServerSocketChannel server=(ServerSocketChannel)sk.channel();
            SocketChannel clientChannel;
            try
                //获取客户端的channel
                clientChannel = server.accept();
                clientChannel.configureBlocking(false);
     
                //register the channel for reading
                SelectionKey clientKey=clientChannel.register(selector,SelectionKey.OP_READ);
                //Allocate an EchoClient instance and attach it to this selection key.
                EchoClient echoClient=new EchoClient();
                clientKey.attach(echoClient);
     
                InetAddress clientAddress=clientChannel.socket().getInetAddress();
                System.out.println("Accepted connetion from "+clientAddress.getHostAddress()+".");
            catch (Exception e)
                System.out.println("Failed to accept new client");
                e.printStackTrace();
           
       
     
        private void doRead(SelectionKey sk)
            SocketChannel channel=(SocketChannel)sk.channel();
            ByteBuffer bb=ByteBuffer.allocate(8192);
            int len;
     
            try
                len=channel.read(bb);
                if(len<0)
                    disconnect(sk);
                    return;
               
            catch (Exception e)
                System.out.println("Fail to read from client");
                e.printStackTrace();
                disconnect(sk);
                return;
           
            bb.flip();
            tp.execute(new HandleMsg(sk,bb));
       
     
        private void doWrite(SelectionKey sk)
            SocketChannel channel=(SocketChannel)sk.channel();
            EchoClient echoClient=(EchoClient)sk.attachment();
            LinkedList<ByteBuffer> outq=echoClient.getOutputQueue();
     
            ByteBuffer bb=outq.getLast();
            try
                int len=channel.write(bb);
                if(len==-1)
                    disconnect(sk);
                    return;
               
                if(bb.remaining()==0)
                    outq.removeLast();
               
            catch (Exception e)
                e.printStackTrace();
                System.out.println("fail to write to client");
                disconnect(sk);
           
     
            if(outq.size()==0)
                sk.interestOps(SelectionKey.OP_READ);
           
     
       
        private void disconnect(SelectionKey sk)
            SocketChannel sc=(SocketChannel)sk.channel();
            try
                sc.finishConnect();
            catch (IOException e)
     
           
       
     
     
     
        private void startServer() throws Exception
            //声明一个selector
            selector= SelectorProvider.provider().openSelector();
     
            //声明一个server socket channel,而且是非阻塞的。
            ServerSocketChannel ssc=ServerSocketChannel.open();
            ssc.configureBlocking(false);
     
    //        InetSocketAddress isa=new InetSocketAddress(InetAddress.getLocalHost(),8000);
            //声明服务器端的端口
            InetSocketAddress isa=new InetSocketAddress(8000);
            //服务器端的socket channel绑定在这个端口。
            ssc.socket().bind(isa);
            //把一个socketchannel注册到一个selector上,同时选择监听的事件,SelectionKey.OP_ACCEPT表示对selector如果
            //监听到注册在它上面的server socket channel准备去接受一个连接,或 有个错误挂起,selector将把OP_ACCEPT加到
            //key ready set 并把key加到selected-key set.
            SelectionKey acceptKey=ssc.register(selector,SelectionKey.OP_ACCEPT);
     
            for(;;)
                selector.select();
                Set readyKeys=selector.selectedKeys();
                Iterator i=readyKeys.iterator();
                long e=0;
                while (i.hasNext())
                    SelectionKey sk=(SelectionKey)i.next();
                    i.remove();
     
                    if(sk.isAcceptable())
                        doAccept(sk);
                    else if(sk.isValid()&&sk.isReadable())
                        if(!geym_time_stat.containsKey(((SocketChannel)sk.channel()).socket()))
                            geym_time_stat.put(((SocketChannel)sk.channel()).socket(),System.currentTimeMillis());
                            doRead(sk);
                       
                    else if(sk.isValid()&&sk.isWritable())
                        doWrite(sk);
                        e=System.currentTimeMillis();
                        long b=geym_time_stat.remove(((SocketChannel)sk.channel()).socket());
                        System.out.println("spend"+(e-b)+"ms");
                   
               
           
       
     
        public static void main(String[] args)
            MultiThreadNIOEchoServer echoServer=new MultiThreadNIOEchoServer();
            try
                echoServer.startServer();
            catch (Exception e)
                e.printStackTrace();
           
       
   

 


同样的客户端代码测试nio的服务器端结果:

spend8ms
spend10ms
spend11ms
spend15ms
spend7ms
spend7ms
spend6ms
spend6ms
spend6ms
spend8ms
几乎没有多少延迟。


总结:nio在数据准备好后,再交由应用进行处理,数据的读写过程仍在应用线程中。也就是说应用线程不用再等待网络io了,准备好了读写还是要处理的。


二.  wait和notify

    wait(),notify(),notifyAll()方法是Object的本地final方法,无法被重写。
    wait()使当前线程阻塞,notify()和notifyAll()使线程唤醒,这三个方法都要写在synchronized代码块里面,因为它们要拿到锁才能执行。
    当线程执行wait()方法的时候,释放当前锁,让出CPU,进入等待状态。
    当线程执行notify()方法和notifyAll()方法的时候,会唤醒一个或多个正在等待的线程,然后继续向下执行,直到执行完synchronized代码块或者再次遇到wait()方法,再次释放锁。
    wait()方法需要被try catch包裹,中断也可以使wait()等待的线程唤醒。
    notify和wait的顺序不能错,如果A线程先执行了notify方法,B线程后执行wait方法,B线程是无法被唤醒的。
    notify和notifyAll的区别就是notify只会唤醒一个线程,notifyAll会唤醒所有等待的线程,至于哪个线程第一个处理取决于操作系统。

三. 线程实现的两种方式

进程:操作系统会为进程在内存中分配一段独立的内存空间,彼此之间不会相互影响,可以负责当前应用程序的运行。当前这个进程负责调度当前程序中的所有运行细节。

线程:程序内部一个相对独立的空间,在进程的内部再次细分独立的空间,一个进程中至少有一个线程。

多线程:就是在一个进程里面同时开启多个线程,让多个线程同时去完成某些任务,目的是提高程序的运行效率。

多线程运行的原理:cpu在线程中做时间片的切换,其实不是同时运行的,只是我们感觉是同时运行的,cpu快速的在这些线程之间做切换,因为cpu的速度是很快的,所以我们感觉不到。

实现线程的两种方式:继承Thread类和实现Runnable接口,本质都是重写run()方法,要调用start()方法,而不是直接调用run()方法。如果调用了run()方法,只是一个普通的方法调用,不会开启新的线程。

    public class MyThreadWithExtends extends Thread
        @Override
        public void run()
            System.out.println("线程的run方法被调用……");
       
     
        public static void main(String[] args)
            Thread thread = new MyThreadWithExtends();
            thread.start();
       
   

    public class MyThreadWithImpliment implements Runnable
        @Override
        public void run()
            System.out.println("线程的run方法被调用……");
       
     
        public static void main(String[] args)
            Thread thread = new Thread(new MyThreadWithImpliment());
            thread.start();
       
   

四. synchronized同步代码块示例

被包裹在synchronized代码块中的代码,同一时间,只能有一个线程执行这段代码,synchronized后面跟的参数代表把谁锁住。下面的例子可以比作上厕所,必须拿到厕所的门才能上厕所,具体上厕所的方式可能不同,因为是同一把锁,所以synchronized中只能有一个线程在执行。

    public class MySynchronized
        public static void main(String[] args)
            final MySynchronized mySynchronized = new MySynchronized();
            new Thread()
                public void run()
                    synchronized (mySynchronized)
                        try
                            Thread.sleep(1000);
                        catch (InterruptedException e)
                            e.printStackTrace();
                       
                        System.out.println("thread1,start");
                   
               
            .start();
            new Thread()
                public void run()
                    synchronized (mySynchronized)
                        System.out.println("thread2,start");
                   
               
            .start();
       
   

synchronized还可以修饰在方法上,当两个线程都调用这个方法的时候,同一时间只能有一个线程执行这个方法,另一个线程只能等待。

synchronized的缺陷:当一个线程获取了锁,其他线程只能等待线程释放锁,有两种情况:当前线程执行完成自动释放锁,另外一个是当前线程发生了异常,JVM会让线程释放锁。

深入(:

  • synchronized 是 Java 中的关键字,是一种同步锁。
    • 用来修饰一个代码块,被修饰的代码块称为 同步语句块,其作用的范围是 大括号 括起来的代码,作用的对象是 大括号中的对象。一次只有一个线程进入该代码块,此时,线程获得的是 成员锁
    • 用来修饰一个方法,被修饰的方法称为 同步方法,锁是当前 实例对象。线程获得的是 成员锁,即一次只能有一个线程进入该方法,其他线程要想在此时调用该方法,只能排队等候。
    • 用来修饰一个静态的方法,其作用的范围是整个 静态方法,锁是当前 Class 对象。线程获得的是 对象锁,即一次只能有一个线程进入该方法(该类的所有实例),其他线程要想在此时调用该方法,只能排队等候。
  • 当 synchronized 锁住一个对象后,别的线程如果也想拿到这个对象的锁,就必须等待这个线程执行完成释放锁,才能再次给对象加锁,这样才达到线程同步的目的。
  • 在使用 synchronized 关键字的时候,能缩小代码段的范围就尽量缩小,能在 代码段 上加同步就不要再整个方法上加同步。
  • 无论 synchronized 关键字加在方法上还是对象上,它取得的锁都是对象,而不是把一段代码或函数当作锁。而且同步方法很可能还会被其他线程的对象访问。
  • 实现同步是要很大的系统开销作为代价的,甚至可能造成死锁,所以尽量避免无谓的同步控制。

1.1 双重检查锁实现单例

  • 使用 volatile 变量,保证先行发生关系(happens-before relationship)。对于 volatile 变量 singleton,所有的写(write)都将先行发生于读(read),在 Java 5 之前使用双重检查锁是有问题的。
  • 第一次校验不是线程安全的,也就是说可能有多个线程同时得到 singleton 为 null 的结果,接下来的同步代码块保证了同一时间只有一个线程进入,而第一个进入的线程会创建对象,等其他线程再进入时对象已创建就不会继续创建。
class LockSingleton
    private volatile static LockSingleton singleton;
    private LockSingleton()
    public static LockSingleton getInstance()
        if(singleton==null)
            synchronized(LockSingleton.class)
                if(singleton==null)
                    singleton=new LockSingleton();
                
            
        
        return singleton;
    

1.2 枚举实现单例

  • Java 中的枚举和其它语言不同,它是一个对象。早期的 Java 是没有枚举类型的,用类似于单例的方式来实现枚举,简单的说就是让构造 private 化,在 static 块中产生多个 final 的对象实例,通过比较引用(或 equals)来进行比较,这种模式跟单例模式相似。

  • 早期用类的方式实现的枚举

public class MyEnum 
    public static MyEnum NumberZero;
    public static MyEnum NumberOne;
    public static MyEnum NumberTwo;
    public static MyEnum NumberThree;

    static 
        NumberZero = new MyEnum(0);
        NumberOne = new MyEnum(1);
        NumberTwo = new MyEnum(2);
        NumberThree = new MyEnum(3);
    

    private final int value;

    private MyEnum(int value) 
        this.value = value;
    

    public int getValue() 
        return value;
    

  • 从 Java 5 开始有枚举类型之后,类似的实现
public enum MyEnum 
    NumberZero(0),
    NumberOne(1),
    NumberTwo(2),
    NumberThree(3);

    private final int value;

    MyEnum(int value) 
        this.value = value;
    

    public int getValue() 
        return value;
    

  • 更简单的实现方式
public enum MyEnum 
    NumberZero,
    NumberOne,
    NumberTwo,
    NumberThree;

    public int getValue() 
        return ordinal();
    

  • 枚举的单例实现
enum EnumSingleton
    INSTANCE;
    public void doSomeThing()
    

1.3 synchronized 的实现原理

  • JVM 中的同步(synchronized )基于进入和退出管程(Monitor)对象实现,无论是显式同步(有明确的 monitorentermonitorexit 指令,即同步代码块)还是隐式同步都是如此。

    • 在 Java 语言中,同步用的最多的地方是被 synchronized 修饰的同步方法。
      • 但是同步方法并不是由 monitorenter 和 monitorexit 指令来实现同步的,而是由方法调用指令读取运行时常量池中方法的 ACC_SYNCHRONIZED 标志来隐式实现。
  • Java 对象保存在内存中时,由以下三部分组成。

    • Java 对象头。
    • 实例数据
    • 对齐填充字节。
  • Java 对象头Monitor 是实现 synchronized 的基础。

1.3.1 Java 对象头

  • Java 对象头主要包括两部分数据:Mark Word(标记字段)、Klass Pointer(类型指针)。
    • Klass Pointer 是对象指向它的类(Class)元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。
    • Mark Word 用于存储对象自身的运行时数据。
      • 如哈希码(HashCode)、GC 分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等。
  • 对象头一般占有 两个机器码(在 32 位虚拟机中,1 个机器码等于 4 字节,也就是 32 bit),但是如果对象是数组类型,则需要 三个机器码,因为 JVM 可以通过 Java 对象的元数据信息确定 Java 对象的大小,但是无法从数组的元数据来确认数组的大小,所以用一块来记录数组长度
  • 对象头信息是与对象自身定义的数据无关的额外存储成本,但是考虑到虚拟机的空间效率,Mark Word 被设计成一个非固定的数据结构以便在极小的空间内存存储尽量多的数据,它会根据对象的状态复用自己的存储空间,也就是说,Mark Word 会随着程序的运行发生变化,变化状态如下(32 位虚拟机)。

对象头信息

  • synchronized 是重量级锁,保存了指向 Monitor 的指针。

1.3.2 Monitor(管程或监视器锁)

  • Monitor 的重要特点是,同一个时刻,只有一个进程/线程能进入 Monitor 中定义的 临界区,这使得 Monitor 能够达到 互斥 的效果。
    • 但仅仅有互斥的作用是不够的,无法进入 Monitor 临界区的进程/线程,它们应该被阻塞,并且在必要的时候会被唤醒。
    • Monitor 作为一个同步工具,也提供了这样的管理进程/线程状态的机制。
  • Monitor 机制需要几个元素来配合。
    • 临界区。
    • Monitor 对象及锁。
    • 条件变量以及定义在 Monitor 对象上的 wait,signal 操作。

临界区

  • 被 synchronized 关键字修饰的方法、代码块,就是 Monitor 机制的临界区。

Monitor 对象 / 锁 / 条件变量等

  • synchronized 关键字在使用的时候,往往需要指定一个对象与之关联。这个对象就是 Monitor 对象。
    • Monitor 的机制中,Monitor 对象充当着维护 mutex 以及定义 wait/signal API 来管理线程的阻塞和唤醒的角色。
    • Java 语言中的 java.lang.Object 类,便是满足这个要求的对象,任何一个 Java 对象都可以作为 Monitor 机制的 Monitor 对象。
  • java.lang.Object 类定义的 wait(),notify(),notifyAll() 方法,这些方法的具体实现,依赖于一个叫 ObjectMonitor(内置锁) 模式的实现,这是 JVM 内部基于 C++ 实现的一套机制,基本原理如下。

ObjectMonitor

  • 当一个线程需要获取 Object 的锁时,会被放入 EntrySet 中进行等待,如果该线程获取到了锁,成为当前锁的 owner。如果根据程序逻辑,一个已经获得了锁的线程缺少某些外部条件,而无法继续进行下去(例如生产者发现队列已满或者消费者发现队列为空),那么该线程可以通过调用 wait 方法将锁释放,进入 wait set 中阻塞进行等待,其它线程在这个时候有机会获得锁,去干其它的事情,从而使得之前不成立的外部条件成立,这样先前被阻塞的线程就可以重新进入 EntrySet 去竞争锁。这个外部条件在 Monitor 机制中称为条件变量。

1.3.2.1 Monitor 与 Java对象及线程的关联

  • 如果一个 Java 对象被某个线程锁住,则该 Java 对象的 Mark Word 字段中 LockWord 指向 Monitor 的起始地址。
  • Monitor 的 owner 字段存放拥有相关联对象锁的线程 ID。

1.3.2.2 ObjectMonitor(内置锁) 的具体实现

  • 在 Java 虚拟机(HotSpot)中,ObjectMonitor 的主要数据结构如下(位于 HotSpot 虚拟机源码 ObjectMonitor.hpp 文件,C++ 实现)
ObjectMonitor() 
    _header       = NULL;
    _count        = 0; //记录个数
    _waiters      = 0,
    _recursions   = 0;
    _object       = NULL;
    _owner        = NULL;
    _WaitSet      = NULL; //处于wait状态的线程,会被加入到_WaitSet
    _WaitSetLock  = 0 ;
    _Responsible  = NULL ;
    _succ         = NULL ;
    _cxq          = NULL ;
    FreeNext      = NULL ;
    _EntryList    = NULL ; //处于等待锁block状态的线程,会被加入到该列表
    _SpinFreq     = 0 ;
    _SpinClock    = 0 ;
    OwnerIsThread = 0 ;

  • ObjectMonitor 中有两个队列,_WaitSet 和 _EntryList,用来保存 ObjectWaiter 对象列表( 每个等待锁的线程都会被封装成 ObjectWaiter 对象),_owner 指向持有 ObjectMonitor 对象的线程,当多个线程同时访问一段同步代码时,首先会进入 _EntryList 集合,当线程获取到对象的 Monitor 后进入 _owner 区域并把 Monitor 中的 owner 变量设置为当前线程,同时 Monitor 中的计数器 count 加 1。若线程调用 wait() 方法,将释放当前持有的 Monitor,owner 变量恢复为 null,count 自减 1,同时该线程进入 WaitSet 集合中等待被唤醒。若当前线程执行完毕也将释放 Monitor(锁)并复位变量的值,以便其他线程进入获取 Monitor(锁)。
ObjectMonitor 方法说明
enter方法获取锁。
exit 方法释放锁。
wait 方法为 Java 的 Object 的 wait 方法提供支持。
notify 方法为 Java 的 Object 的 notify 方法提供支持。
notifyAll 方法为 Java 的 Object 的 notifyAll 方法提供支持。

显式同步

  • 从字节码中可知同步语句块的实现使用的是 monitorenter 和 monitorexit 指令,执行同步代码块后首先要先执行 monitorenter 指令,退出的时候执行 monitorexit 指令。
  • 值得注意的是编译器将会确保无论方法通过何种方式完成,方法中调用过的每条 monitorenter 指令都有执行其对应 monitorexit 指令,而无论这个方法是正常结束还是异常结束。
    • 为了保证在方法异常完成时 monitorenter 和 monitorexit 指令依然可以正确配对执行,编译器会自动产生一个异常处理器,这个异常处理器声明可处理所有的异常,它的目的就是用来执行 monitorexit 指令。
  • 编写一个简单 Java 类。
public class Test 
    public static void main(String[] args) 
        synchronized (Test.class) 
        
    

  • 通过 javap -v 查看编译字节码。
......
public static void main(java.lang.String[]);
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=3, args_size=1
         0: ldc_w         #2                  
         3: dup           
         4: astore_1      
         5: monitorenter     // 同步模块开始   
         6: aload_1       
         7: monitorexit        // 同步模块结束
         8: goto          16
        11: astore_2      
        12: aload_1       
......

隐式同步

  • 方法级的同步是隐式,即无需通过字节码指令来控制的,它实现在方法调用和返回操作之中。
  • JVM 可以从方法常量池中的方法表结构(method_info Structure)中的 ACC_SYNCHRONIZED 访问标志区分一个方法是否同步方法。
    • 当方法调用时,调用指令将会检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先持有 Monitor, 然后再执行方法,最后在方法完成(无论是正常完成还是异常完成)时释放 Monitor。
    • 方法执行期间,执行线程持有了 Monitor,其他任何线程都无法再获得同一个 Monitor。
    • 如果一个同步方法执行期间抛出异常,并且在方法内部无法处理此异常,那这个同步方法所持有的 Monitor 将在异常抛到同步方法之外时自动释放。
  • 编写一个简单 Java 类。
public class Test 
    public static void main(String[] args) 
        test();
    

    public synchronized static void test() 
    

  • 通过 javap -v 查看编译字节码。
......
public static synchronized void test();
    flags: ACC_PUBLIC, ACC_STATIC, ACC_SYNCHRONIZED     // 检查访问标志
    Code:
      stack=0, locals=0, args_size=0
         0: return        
      LineNumberTable:
        line 13: 0
......

1.3.3 类型指针

  • 对象头的另外一部分是类型指针,即对象指向它的类元数据的指针,如果对象访问定位方式是句柄访问,那么该部分没有,如果是直接访问,该部分保留。

句柄访问方式

句柄访问方式

直接访问方式

直接访问方式

1.3.4 synchronized 的语义

  • synchronized 同时保证了线程在同步块之前或者期间写入动作,对于后续进入该代码块的线程是可见的(对同一个 Monitor 对象而言)。在一个线程退出同步块时,线程释放 Monitor 对象,它的作用是把 CPU 缓存数据(本地缓存数据)刷新到主内存中,从而实现该线程的行为可以被其它线程看到。在其它线程进入到该代码块时,需要获得 Monitor 对象,它在作用是使 CPU 缓存失效,从而使变量从主内存中重新加载,然后就可以看到之前线程对该变量的修改。
  • synchronized 还有一个语义是禁止指令的重排序(不改变程序的语义的情况下,编译器和执行器可以为了性能优化代码执行顺序),对于编译器来说,同步块中的代码不会移动到获取和释放 Monitor 的外面。
  • 对于多个线程,同步块中的对象,必须是同一个对象,在相同的 Monitor 对象上同步才能够正确的设置 happens-before 关系。

1.4 synchronized 的优化

  • 通过 synchronzied 实现同步用到了对象的内置锁(ObjectMonitor),而在 ObjectMonitor 的函数调用中会涉及到 mutex lock 等特权指令,那么这个时候就存在操作系统用户态和核心态的转换,这种切换会消耗大量的系统资源,因为用户态与内核态都有各自专用的内存空间,专用的寄存器等,用户态切换至内核态需要传递给许多变量、参数给内核,内核也需要保护好用户态在切换时的一些寄存器值、变量等,这也是早期 synchronized 效率低的原因。在 JDK 1.6 之后,从 JVM 层面做了很大的优化。

1.4.1 CAS

  • CAS(Compare and Swap),即比较并替换,是非阻塞算法 (nonblocking algorithms,一个线程的失败或者挂起不应该影响其他线程的失败或挂起的算法)。
  • CAS 有 3 个操作数,内存值(假设为 V),预期值(假设为 A),修改的新值(假设为 B)。当且仅当预期值 A 和内存值 V 相同时,将内存值 V 修改为 B,否则什么都不做。
  • Unsafe,是 CAS 的核心类,通过调用 JNI 的代码实现,通过本地(native)方法来访问,Unsafe 可以直接操作特定内存的数据。
  • 利用 CPU 的 CAS 指令,同时借助 JNI 来完成 Java 的非阻塞算法。其它原子操作都是利用类似的特性完成的。而整个 J.U.C 都是建立在 CAS 之上的,因此对于 synchronized 阻塞算法,J.U.C 在性能上有了很大的提升。
/**
     * Atomically update Java variable to <tt>x</tt> if it is currently
     * holding <tt>expected</tt>.
     * @return <tt>true</tt> if successful
     */
    public final native boolean compareAndSwapInt(Object o, long offset,
                                                  int expected,
                                                  int x);
  • Unsafe 类中的 compareAndSwapInt,是一个本地方法,该方法的实现位于 unsafe.cpp 中。
UNSAFE_ENTRY(jboolean, Unsafe_CompareAndSwapInt(JNIEnv *env, jobject unsafe, jobject obj, jlong offset, jint e, jint x))
  UnsafeWrapper("Unsafe_CompareAndSwapInt");
  oop p = JNIHandles::resolve(obj);
  jint* addr = (jint *) index_oop_from_field_offset_long(p, offset);
  return (jint)(Atomic::cmpxchg(x, addr, e)) == e;
UNSAFE_END

1.4.1.1 CAS 存在的问题

  • ABA 问题。
    • 因为 CAS 需要在操作值的时候检查下值有没有发生变化,如果没有发生变化则更新,但是如果一个值原来是 A,变成了 B,又变成了 A,那么使用 CAS 进行检查时会发现它的值没有发生变化,但是实际上却变化了。
    • ABA 问题的解决思路就是使用版本号。在变量前面追加上版本号,每次变量更新的时候把版本号加一,那么 A->B->A 就会变成 1A->2B->3A。
    • 从 Java1.5 开始 JDK 的 atomic 包里提供了一个类 AtomicStampedReference 来解决 ABA 问题。这个类的 compareAndSet 方法作用是首先检查当前引用是否等于预期引用,并且当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值。
  • 循环时间长开销大。
    • 自旋 CAS 如果长时间不成功,会给 CPU 带来非常大的执行开销。
    • 如果 JVM 能支持处理器提供的 pause 指令那么效率会有一定的提升,pause 指令有两个作用。
      • 第一它可以延迟流水线执行指令(de-pipeline),使 CPU 不会消耗过多的执行资源,延迟的时间取决于具体实现的版本,在一些处理器上延迟时间是零。
      • 第二它可以避免在退出循环的时候因内存顺序冲突(memory order violation)而引起 CPU 流水线被清空(CPU pipeline flush),从而提高 CPU 的执行效率。
  • 只能保证一个共享变量的原子操作。
    • 当对一个共享变量执行操作时,我们可以使用循环 CAS 的方式来保证原子操作,但是对多个共享变量操作时,循环 CAS 就无法保证操作的原子性,这个时候就可以用锁,或者有一个取巧的办法,就是把多个共享变量合并成一个共享变量来操作。
    • 比如有两个共享变量 i=2,j=a,合并一下 ij=2a,然后用 CAS 来操作 ij。从 Java1.5 开始
      JDK 提供了 AtomicReference 类来保证引用对象之间的原子性,可以把多个变量放在一个对象里来进行 CAS 操作。

1.4.2 偏向锁

  • Java 偏向锁(Biased Locking)是 Java 6 引入的一项多线程优化。
    • 它会偏向于第一个访问锁的线程,如果在运行过程中,同步锁只有一个线程访问,不存在多线程争用的情况,则线程不需要触发同步,这种情况下,会给线程加一个偏向锁。
    • 在运行过程中,遇到了其他线程抢占锁,则持有偏向锁的线程被挂起,JVM 会消除它身上的偏向锁,将锁升级到标准的轻量级锁。
    • 它通过消除资源无竞争情况下的同步,进一步提高了程序的运行性能。
    • 撤销偏向锁的时候会导致 stop the world 操作,高并发的应用应禁用掉偏向锁
  • 开启偏向锁 -XX:+UseBiasedLocking -XX:BiasedLockingStartupDelay=0
  • 关闭偏向锁 -XX:-UseBiasedLocking

1.4.2.1 偏向锁的获取

偏向锁的获取

  • 步骤 1. 访问 Mark Word 中偏向锁的标识是否设置成 1,锁标志位是否为 01,确认为可偏向状态。
  • 步骤 2. 如果为可偏向状态,则测试线程 ID 是否指向当前线程。
    • 如果是,进入步骤 5,
    • 否则进入步骤 3。
  • 步骤 3. 如果线程 ID 并未指向当前线程,则通过 CAS 操作竞争锁。
    • 如果竞争成功,则将 Mark Word 中线程 ID 设置为当前线程 ID,然后执行步骤 5。
    • 如果竞争失败,执行步骤 4。
  • 步骤 4. 如果 CAS 获取偏向锁失败,则表示有竞争。
    • 当到达全局安全点(safepoint)时获得偏向锁的线程被挂起,偏向锁升级为轻量级锁,然后被阻塞在安全点的线程继续往下执行同步代码。(撤销偏向锁的时候会导致 stop the world,时间很短)
  • 步骤 5. 执行同步代码。

1.4.2.2 偏向锁的释放

偏向锁的释放

  • 偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁(等待竞争出现才释放锁的机制),线程不会主动去释放偏向锁。
    • 偏向锁的撤销,需要等待全局安全点(这个时间点上没有字节码正在执行),暂停拥有偏向锁的线程,判断锁对象是否处于被锁定状态,撤销偏向锁后恢复到未锁定(标志位为 " 01 ")或轻量级锁(标志位为 " 00 ")的状态。

1.4.2.3 安全点停顿日志

  • 要查看安全点停顿,可以打开安全点日志,通过设置 JVM 参数。

    • -XX:+PrintGCApplicationStoppedTime,打印出系统停止的时间。
    • -XX:+PrintSafepointStatistics -XX:PrintSafepointStatisticsCount=1,打印出详细信息,可以查看到使用偏向锁导致的停顿,时间非常短暂,但是争用严重的情况下,停顿次数也会非常多。
    • 在生产系统上还需要增加四个参数。
      • -XX:+UnlockDiagnosticVMOptions
      • -XX: -DisplayVMOutput
      • -XX:+LogVMOutput
      • -XX:LogFile=/dev/shm/vm.log
  • 两个运行的线程同时执行同步代码块,就能出现偏向锁撤销操作,造成安全点停顿。

    • 默认是偏向锁是关闭的,需要开启偏向锁才能看到日志。
         vmop                    [threads: total initially_running wait_to_block]    [time: spin block sync cleanup vmop] page_trap_count
0.036: EnableBiasedLocking              [       7          0              1    ]      [     0     0     0     0     0    ]  0   
Total time for which application threads were stopped: 0.0000860 seconds, Stopping threads took: 0.0000180 seconds
         vmop                    [threads: total initially_running wait_to_block]    [time: spin block sync cleanup vmop] page_trap_count
0.071: RevokeBias                       [       9          0              1    ]      [     0     0     0     0     0    ]  0   
Total time for which application threads were stopped: 0.0000810 seconds, Stopping threads took: 0.0000220 seconds
         vmop                    [threads: total initially_running wait_to_block]    [time: spin block sync cleanup vmop] page_trap_count
0.071: RevokeBias                       [       9          0              1    ]      [     0     0     0     0     0    ]  0   
Total time for which application threads were stopped: 0.0001330 seconds, Stopping threads took: 0.0001090 seconds
         vmop                    [threads: total initially_running wait_to_block]    [time: spin block sync cleanup vmop] page_trap_count
0.071: no vm operation                  [       7          1              1    ]      [     0     0     0     0    10    ]  0   
  • RevokeBias 就是撤销偏向锁造成的安全点停顿。
参数说明
vmopJava 虚拟机操作类型(时间戳:操作类型)。
threads线程概况(安全点里的总线程数(total) ;安全点开始时正在运行状态的线程数(initially_running) ;在 Java 虚拟机操作开始前需要等待其暂停的线程数(wait_to_block))。
time执行操作时间(等待线程响应 safepoint 号召的时间(spin);暂停所有线程所用的时间(block);等于 spin + block,这是从开始到进入安全点所耗的时间,可用于判断进入安全点耗时(sync);清理所用时间(cleanup);真正执行 Java 虚拟机操作的时间(vmop))。

1.4.2.4 偏向锁小结

  • 一个对象刚开始实例化的时候,没有任何线程来访问它的时候,它是可偏向的,当第一个
    线程来访问它的时候,它会偏向这个线程,此时,对象持有偏向锁。线程在修改对象头成为偏向锁的时候使用 CAS 操作,并将对象头中的 ThreadID 改成自己的 ID,之后再次访问这个对象时,只需要对比 ID,不需要再使用 CAS 在进行操作。
  • 一旦有第二个线程访问这个对象,因为偏向锁不会主动释放,所以第二个线程可以看到对象是偏向状态,这时表明在这个对象上已经存在竞争了,检查原来持有该对象锁的线程是否依然存活,如果挂了,则将对象变为无锁状态,然后重新偏向新的线程。
  • 如果原来的线程依然存活,则马上执行那个线程的操作栈,检查该对象的使用情况,如果仍然需要持有偏向锁,则偏向锁升级为轻量级锁,(偏向锁就是这个时候升级为轻量级锁的)。如果不存在使用了,则可以将对象回复成无锁状态,然后重新偏向。
以上是关于Java 并发编程 常见面试总结的主要内容,如果未能解决你的问题,请参考以下文章

Java 并发编程 常见面试总结

最常见的Java面试题及答案汇总

Java并发编程面试题(五万字总结)——快来打怪升级吧

Java并发编程面试题(五万字总结)——快来打怪升级吧

Java开发社招面试总结!深入理解java高并发编程

并发编程总结