并发编程(JUC)系列(1)--Thread和synchronized

140 阅读34分钟

写在前面:

文章内容是通过个人整理以及参考相关资料总结而出,难免会出现部分错误

如果出现错误,烦请在评论中指出!谢谢


1 概述

1.1 异步和同步

从方法调用的角度:

  • 需要等待结果返回,才能继续运行就是同步
  • 不需要等待结果返回,就能继续运行就是异步

注意:同步在多线程中还有一层意思,就是让多个线程步调一致


测试同步和异步:

同步操作Async

public class Sync {
    public static final String FILE_NAME = "D:\\OneDrive\\文档\\Java笔记\\JUC.md";

    public static void main(String[] args) throws IOException {
        FileInputStream inputStream = new FileInputStream(FILE_NAME);
        byte[] bytes = new byte[1024];
        int len = 0;
        while ((len = inputStream.read(bytes)) != -1) {
            // System.out.println(new String(bytes,0,len));
        }
        log.info("读取完成...");
        log.info("do some things");
    }
}

测试结果

image-20210302005300778

可以看出程序只能按照顺序进行执行,只有当IO读取完成才能执行其他操作

异步操作Async

@Slf4j
public class Async {
    public static final String FILE_NAME = "D:\\OneDrive\\文档\\Java笔记\\JUC.md";

    public static void main(String[] args) throws IOException {
        new Thread(() -> {
            FileInputStream inputStream = null;
            try {
                inputStream = new FileInputStream(FILE_NAME);
            } catch (FileNotFoundException e) {
                e.printStackTrace();
            }
            byte[] bytes = new byte[1024];
            int len = 0;
            while (true) {
                try {
                    if (!((len = inputStream.read(bytes)) != -1)) {
                        break;
                    }
                } catch (IOException e) {
                    e.printStackTrace();
                }
                // System.out.println(new String(bytes,0,len));
            }
            log.info("读取完成...");
        },"t1").start();
        log.info("do some things");
    }
}

这里通过新起线程的方法进行异步操作

测试结果

image-20210302005612331

可以看出这里并没有等待IO完成就直接执行了其他操作


小结论:

  • 在项目中,如果有些操作比较耗时,或者可以在之后的某段时间进行执行,就可以通过新起线程进行异步操作
  • Tomcat中的异步Serlvet也是同样的目的,当用户线程处理耗时较长的操作时,就会通过异步操作避免阻塞Tomcat的工作线程

2 Thread

2.1 线程创建

这里需要先进行说明:不管通过哪种方式创建线程,让线程从创建态进入就绪态只有唯一一种方式,那就是通过Thread#start()方法


通过直接创建Thread对象创建线程

public class ThreadDemo {
    public static void main(String[] args) {
        Thread thread1 = new Thread() {
            @Override
            public void run() {
                System.out.println("新建线程...");
            }
        };
    }
}

创建一个Thread对象并重写run()方法


创建Thread对象并传入Runnable接口实现类对象

public class ThreadDemo {
    public static void main(String[] args) {
   
        Thread thread2 = new Thread(() -> {
            System.out.println("新建Runnable线程");
        });
    }
}

对Thread类传入Runnable接口实现类对象作为参数分析

public Thread(Runnable target) {
    init(null, target, "Thread-" + nextThreadNum(), 0);
}

private void init(ThreadGroup g, Runnable target, String name,
                  long stackSize) {
    init(g, target, name, stackSize, null, true);
}

private void init(ThreadGroup g, Runnable target, String name,
                  long stackSize, AccessControlContext acc,
                  boolean inheritThreadLocals) {
    if (name == null) {
        throw new NullPointerException("name cannot be null");
    }

    this.name = name;

    Thread parent = currentThread();
    SecurityManager security = System.getSecurityManager();
    if (g == null) {
        /* Determine if it's an applet or not */

        /* If there is a security manager, ask the security manager
           what to do. */
        if (security != null) {
            g = security.getThreadGroup();
        }

        /* If the security doesn't have a strong opinion of the matter
           use the parent thread group. */
        if (g == null) {
            g = parent.getThreadGroup();
        }
    }

    /* checkAccess regardless of whether or not threadgroup is
       explicitly passed in. */
    g.checkAccess();

    /*
     * Do we have the required permissions?
     */
    if (security != null) {
        if (isCCLOverridden(getClass())) {
            security.checkPermission(SUBCLASS_IMPLEMENTATION_PERMISSION);
        }
    }

    g.addUnstarted();

    this.group = g;
    this.daemon = parent.isDaemon();
    this.priority = parent.getPriority();
    if (security == null || isCCLOverridden(parent.getClass()))
        this.contextClassLoader = parent.getContextClassLoader();
    else
        this.contextClassLoader = parent.contextClassLoader;
    this.inheritedAccessControlContext =
            acc != null ? acc : AccessController.getContext();
    // 我们知道target就是传入的Runnable接口对象,这里赋值给target属性
    this.target = target;
    setPriority(priority);
    if (inheritThreadLocals && parent.inheritableThreadLocals != null)
        this.inheritableThreadLocals =
            ThreadLocal.createInheritedMap(parent.inheritableThreadLocals);
    /* Stash the specified stack size in case the VM cares */
    this.stackSize = stackSize;

    /* Set thread ID */
    tid = nextThreadID();
}

通过构造器调用init()方法,然后经过层层调用,我们发现其实将Runnable接口对象赋值给了Thread类的target属性

image-20210302011233899

那么接下来看下Thread#run方法

@Override
public void run() {
    if (target != null) {
        target.run();
    }
}

可以看出这里如果没有重写run()方法时,先判断target属性是否为空,如果不为空,就调用target属性的

run()方法,而我们知道target属性就是我们传入的Runnable接口对象


FutureTask配合Thread类创建线程原理

我们知道Thread类可以传入Runnable接口对象作为参数,但是Runable接口的run()不能抛出异常且没有返回值

那么这个时候就需要引入Callable接口

/**
 * A task that returns a result and may throw an exception.
 * Implementors define a single method with no arguments called
 * {@code call}
 */
@FunctionalInterface
public interface Callable<V> {
    /**
     * Computes a result, or throws an exception if unable to do so.
     *
     * @return computed result
     * @throws Exception if unable to compute a result
     */
    V call() throws Exception;
}

可以看出Callable接口执行异步任务并返回结果,也可能抛出异常

我们知道创建Thread对象当传入参数时只能传入Callable接口对象,那么这里如何引入Callable接口对象呢?

这里就需要引入一个中间介质FutureTask

/**
 * A cancellable asynchronous computation.  This class provides a base
 * implementation of {@link Future}, with methods to start and cancel
 * a computation, query to see if the computation is complete, and
 * retrieve the result of the computation
 */
public class FutureTask<V> implements RunnableFuture<V> {
	/** The underlying callable; nulled out after running */
    private Callable<V> callable;
    
    /**
     * Creates a {@code FutureTask} that will, upon running, execute the
     * given {@code Callable}.
     */
    public FutureTask(Callable<V> callable) {
        if (callable == null)
            throw new NullPointerException();
        this.callable = callable;
        this.state = NEW;       // ensure visibility of callable
    }
    
    public void run() {
        if (state != NEW ||
            !UNSAFE.compareAndSwapObject(this, runnerOffset,
                                         null, Thread.currentThread()))
            return;
        try {
            Callable<V> c = callable;
            if (c != null && state == NEW) {
                V result;
                boolean ran;
                try {
                    result = c.call();
                    ran = true;
                } catch (Throwable ex) {
                    result = null;
                    ran = false;
                    setException(ex);
                }
                if (ran)
                    set(result);
            }
        } finally {
            // runner must be non-null until state is settled to
            // prevent concurrent calls to run()
            runner = null;
            // state must be re-read after nulling runner to prevent
            // leaked interrupts
            int s = state;
            if (s >= INTERRUPTING)
                handlePossibleCancellationInterrupt(s);
        }
    }

1、从注释中可以这个类用来进行异步计算,并且是Future接口的基本实现

2、我们知道FutureTask类实现了RunnableFuture接口,而RunnableFuture接口又实现了RunnableFuture接口

image-20210302231100409

3、从构造方法中可以看出需要传入一个Callable接口对象,而从run()方法中可以看出实际调用的是Callable接口对象的run()方法并且获取到了该方法执行之后的结果

那么我们现在就可以得到结论:

使用Callable接口对象构造FutureTask对象,因为FutureTask类实现了Runnable接口,故将FutureTask对象构造Thread对象


FutureTask对象新建线程

@Slf4j
public class ThreadDemo {
    public static void main(String[] args) throws ExecutionException, InterruptedException {
    
        Thread thread3 = new Thread(futureTask);
        thread3.start();

        Integer res = futureTask.get();

        log.info(String.valueOf(res));
    }
}

这里当调用FutureTask#get实际上就是获取异步执行的结果,当调用该方法时如果异步执行并未结束,则main线程会阻塞在该步骤,故调用get()方法时最好在main线程的结尾

测试结果

image-20210302232246950

2.2 操作线程

2.2.1 run vs start

当我们将一个线程从创建态转换为就绪态时就需要调用start()方法

@Slf4j
public class Demo03 {
    public static void main(String[] args) throws InterruptedException {
        Thread thread = new Thread(() -> {
            log.info("test...");
        });
        
        thread.start();
        Thread.sleep(1000);
        log.info(thread.getState().toString());
    }
}

测试结果

image-20210303201015069


run()是操作系统进行调用的,操作系统会将Java中的一个线程映射到核心态的一个线程

当我们自己调用run()方法时只会当做普通方法来调用

@Slf4j
public class Demo03 {
    public static void main(String[] args) throws InterruptedException {
        Thread thread = new Thread(() -> {
            log.info("test...");
        });

        thread.run();
        Thread.sleep(1000);
        log.info(thread.getState().toString());
    }
}

测试结果

image-20210303201323434

可以看出这里run()方法被当做普通方法来执行,而操作的线程依然是main()线程,而thread线程依然处于创建态

2.2.2 sleep

  • 调用sleep()会让当前线程从Running状态进入TIMED_WAITING状态
  • 其他线程可以使用interupt方法打断正在睡眠的线程
  • 睡眠结束之后线程从阻塞态进入就绪态,需要等待CPU的重新分配

测试调用sleep()方法线程状态

@Slf4j
public class Demo03 {
    public static void main(String[] args) throws InterruptedException {
        Thread thread = new Thread(() -> {
            try {
                TimeUnit.SECONDS.sleep(5);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        });

        thread.start();
        Thread.sleep(1000);
        log.info(thread.getState().toString());
    }
}

测试结果


测试休眠线程被打断

@Slf4j
public class Demo03 {
    public static void main(String[] args) throws InterruptedException {
        Thread thread = new Thread(() -> {
            try {
                TimeUnit.SECONDS.sleep(5);
            } catch (InterruptedException e) {
                log.debug("线程被打断...");
                e.printStackTrace();
            }
        });

        thread.start();
        Thread.sleep(1000);
        thread.interrupt();
    }
}

测试结果

当休眠的线程被打断时就会抛出InterruptedException异常

image-20210303202443408


yield vs sleep

  • 调用yield()方法会让当前线程从Running状态进入Runnable状态(也就是礼让线程)
  • 但是具体的实现依赖于操作系统的任务调度器,比如某个线程调用了yield()方法进入了就绪态,但是CPU依然有可能让该线程上处理机

2.2.3 线程优先级

  • 线程优先级会提示调度器优先调用优先级较高的线程,但它仅仅只是一个提示,调度器可以忽略它
  • 如果CPU比较忙,那么优先级较高的线程会获得更多的时间片;但CPU空闲时,优先级几乎没有任何作用

从Thread类上分析线程优先级

image-20210303203236473

首先Thread类本身就定义了三个常量用来表示优先级大小,int值越大则表示优先级越高

image-20210303203330399

通过setPriority()方法可以设置线程优先级,但是请注意:设置优先级必须要在线程调用start()方法之前

2.2.4 join

我们首先来看一个例子:

@Slf4j
public class Demo3 {
    private static int r;
    public static void main(String[] args) {
        Thread thread = new Thread(() -> {
            log.info("开始");
            try {
                TimeUnit.SECONDS.sleep(1);
                r = 10;
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            log.info("结束");
        });
        thread.start();
        log.info("最终结果: " + r);
    }
}

对于这个案例来说,打印的最终结果应该是什么呢?

这里应该说大多数情况下为0,某些情况为10;因为thread线程休眠了一秒,而在这一秒内足够main线程执行完所有操作

测试结果

image-20210303204342805


那么如何才能让结果一定是10呢?这就意味着必须thread线程执行完之后main线程才能继续执行

这时就可以使用join()方法,join()方法意味着加入线程,就是当前线程必须要等待调用join()方法的线程执行之后才能继续执行

注意:join()方法必须要在start()方法之后执行,也就是线程处于就绪态之后

@Slf4j
public class Demo3 {
    private static int r;
    public static void main(String[] args) {
        Thread thread = new Thread(() -> {
            log.info("开始");
            try {
                TimeUnit.SECONDS.sleep(1);
                r = 10;
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            log.info("结束");
        });
        thread.start();
        try {
            thread.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        log.info("最终结果: " + r);
    }
}

测试结果

image-20210303204753384


有时效的join()方法

join()方法可以传入long类型参数来指定强行加入的时间限制,一旦到达时间限制,被强制等待的线程就不会再强行等待

@Slf4j
public class Demo3 {
    private static int r;
    public static void main(String[] args) {
        Thread thread = new Thread(() -> {
            log.info("开始");
            try {
                TimeUnit.SECONDS.sleep(10);
                r = 10;
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            log.info("结束");
        });
        thread.start();
        try {
            thread.join(2);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        log.info("最终结果: " + r);
    }
}

这里thread的线程需要阻塞10s,而main线程强制被加入的时间限制仅为1s,所以大概率打印结果为0

测试结果

image-20210303205122149

2.2.5 interrupt

之前我们已经知道interrupt()方法可以打断正在阻塞的线程,实际上interrupt()方法还可以作用于正在运行的线程,只是机制不同而已

不同点就在于打断标记

image-20210303212255335

可以看出打断标记对应的方法是一个本地方法,用来记录当前线程是否被打断的状态

它们之间的区别:

  • 当打断阻塞状态的线程时,线程的打断标记就会被清空,也就是打断标记为false
  • 当打断运行状态的线程时,线程的打断标记就会被修改为true

其实这部分内容从源码的注释中也可以得到:

image-20210305105613434

可以看到如果interrupt()方法作用于一个阻塞或等待线程,那么线程的打断标记就会被清空,然后抛出一个InterruptedException


测试打断阻塞线程的打断标记

@Slf4j
public class Demo03 {
    public static void main(String[] args) throws InterruptedException {
        Thread thread = new Thread(() -> {
            try {
                TimeUnit.SECONDS.sleep(5);
            } catch (InterruptedException e) {
                log.info(String.valueOf(Thread.currentThread().isInterrupted()));
                e.printStackTrace();
            }
        });

        thread.start();
        Thread.sleep(1000);
        thread.interrupt();
    }
}

测试结果

image-20210303212914416

可见当前线程的打断标记被清空,默认为false


测试打断正在运行的线程

@Slf4j
public class Demo03 {
    public static void main(String[] args) throws InterruptedException {
        Thread thread = new Thread(() -> {
            while (true) {

            }
        });

        thread.start();
        Thread.sleep(1000);
        thread.interrupt();
    }
}

测试结果

image-20210303213237974

程序一直在运行并未停止,可以看出打断正在运行的线程并不是真的打断,而是修改线程的打断标记

修改之前的程序

@Slf4j
public class Demo03 {
    public static void main(String[] args) throws InterruptedException {
        Thread thread = new Thread(() -> {
            while (true) {
                if (Thread.currentThread().isInterrupted()) {
                    log.info("打断标记已修改...");
                    break;
                }
            }
        });

        thread.start();
        Thread.sleep(1000);
        thread.interrupt();
    }
}

这次的思路是判断打断标记来决定是否退出循环

测试结果

image-20210303213443033

可以证明仅仅是修改了打断标记

2.2.6 守护线程

默认情况下,Java进程需要等待所有的线程都运行结束之后才结束运行

但如果所有的非守护线程已经结束,而守护线程并未结束,那么程序也会停止运行

测试守护线程

@Slf4j
public class Demo03 {
    public static void main(String[] args) throws InterruptedException {
        Thread thread = new Thread(() -> {
            while (true) {

            }
        });
        thread.setDaemon(true);
        thread.start();
        Thread.sleep(1000);
    }
}

通过setDaemon()方法设置守护线程,且守护线程设置必须要在start()方法之前

测试结果

image-20210303213831211

可以看出thread线程虽然是无限循环,但由于thread线程为守护线程,一旦main线程运行结束则程序结束

2.2.7 线程状态

2.2.7.1 五状态模型

image-20210303214244698

  • 初始状态

    仅在语言层面创建了线程,还未与操作系统关联

  • 可运行状态

    该线程已经被创建,且和内核级线程是一一映射的关系,可以由CPU执行调度

  • 休眠状态

    运行状态的线程调用了一个阻塞的API或者等待某个事件,就会进入阻塞态,同时释放CPU执行权,当等待的事件发生时,线程就会由休眠状态转为可运行状态

2.2.7.2 六状态模型

image-20210303214804810

六状态模型对应于Java中的Thread.State内部类

public enum State {
    /**
     * Thread state for a thread which has not yet started.
     */
    NEW,

    /**
     * Thread state for a runnable thread.  A thread in the runnable
     * state is executing in the Java virtual machine but it may
     * be waiting for other resources from the operating system
     * such as processor.
     */
    RUNNABLE,

    /**
     * Thread state for a thread blocked waiting for a monitor lock.
     * A thread in the blocked state is waiting for a monitor lock
     * to enter a synchronized block/method or
     * reenter a synchronized block/method after calling
     * {@link Object#wait() Object.wait}.
     */
    BLOCKED,

    /**
     * Thread state for a waiting thread.
     * A thread is in the waiting state due to calling one of the
     * following methods:
     * <ul>
     *   <li>{@link Object#wait() Object.wait} with no timeout</li>
     *   <li>{@link #join() Thread.join} with no timeout</li>
     *   <li>{@link LockSupport#park() LockSupport.park}</li>
     * </ul>
     *
     * <p>A thread in the waiting state is waiting for another thread to
     * perform a particular action.
     *
     * For example, a thread that has called <tt>Object.wait()</tt>
     * on an object is waiting for another thread to call
     * <tt>Object.notify()</tt> or <tt>Object.notifyAll()</tt> on
     * that object. A thread that has called <tt>Thread.join()</tt>
     * is waiting for a specified thread to terminate.
     */
    WAITING,

    /**
     * Thread state for a waiting thread with a specified waiting time.
     * A thread is in the timed waiting state due to calling one of
     * the following methods with a specified positive waiting time:
     * <ul>
     *   <li>{@link #sleep Thread.sleep}</li>
     *   <li>{@link Object#wait(long) Object.wait} with timeout</li>
     *   <li>{@link #join(long) Thread.join} with timeout</li>
     *   <li>{@link LockSupport#parkNanos LockSupport.parkNanos}</li>
     *   <li>{@link LockSupport#parkUntil LockSupport.parkUntil}</li>
     * </ul>
     */
    TIMED_WAITING,

    /**
     * Thread state for a terminated thread.
     * The thread has completed execution.
     */
    TERMINATED;
}

在API的注释中可以看到当调用哪些方法时线程处于哪种状态

BLOCKED状态的线程从注释中可以看出是等待锁的释放

3 synchronized

3.1 上下文切换

我们先考虑一种情况,两个线程对初始值为0的静态变量一个做自增,一个做自减,最后的结果是0嘛?

其实这里我们已经知道大部分情况下不为0

@Slf4j
public class Demo04 {
    private static int num;

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 5000; i++) {
                num++;
            }
        });

        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 5000; i++) {
                num--;
            }
        });

        t1.start();
        t2.start();
        t1.join();
        t2.join();
        log.info(String.valueOf(num));
    }
}

测试结果

image-20210303220111197


结果不为0其实我们早就知道了,但是现在我们从字节码的角度去思考为什么不为0

我们知道i++和i--都不是原子操作,那么这里来看下自增和自减的字节码

image-20210303220446156

可以看出自增和自减都是先获取静态变量num,然后从局部变量表中取出该变量,放入操作数栈,接着进行自增或自减,最后将结果写入局部变量表


那么根据JMM内存模型,可能存在这种情况

image-20210303220957704

也就是当线程2准备将结果写入主存时,CPU调度开始执行线程1,当线程执行操作之后将结果写入主存,这个时候线程2继续执行将结果写入主存,那么线程1的操作等于白做,这样就会导致最后的结果为负值

3.2 临界区

当多个线程操作共享资源时:

  • 多个线程共享资源其实没什么问题
  • 在多个线程对共享资源读写写写指令发生交错时就会出现问题

那么一段代码如果存在对共享资源的多线程读写进行操作,则这块区域被称为临界区


为了避免临界区对共享资源竞争发生,有两种思路可以解决这种问题:

  • 阻塞式方案:synchronized,Lock
  • 非阻塞式方案:原子变量,CAS

3.3 synchronized概述

其实我们之前对于synchronized已经有了基本的了解,这里只是做一个汇总

  • synchronized本质上就是持有对象锁,这个对象锁在静态方法下可能是类的class变量;在非静态方法下可能是this变量;也可能是在类中自定义的静态变量,例如private static Object monitor = new Object()
  • 当某个线程持有对象锁时,并不意味着该线程会一直持续执行,而是当该线程的时间片结束之后,该线程交出CPU执行权,CPU重新调度;然而此时其他线程因为没有对象锁而处于BLOCKED状态,因此CPU这次会继续选择该线程
  • 同时还要注意一个关键点:synchronized可保证一个线程的变化(主要是共享数据的变化,当写回主存时)可以被其他线程所看到(保证可见性,完全可以代替volatile功能)

我们知道synchronized就是利用对象锁保证了临界区代码的原子性,那么这里提出两个问题来加深理解

  • 如果t1 synchronized(obj1)t2 synchronized(obj2)会发生什么?

    因为两个线程持有的锁不同,因此谁也不会阻塞谁

  • 如果t1 synchronized(obj1)而t2线程执行的方法并没有加锁会发生什么?

    同样谁都不会影响谁,因为t2线程在执行时并不需要获取锁

这两个问题其实在理解了"八锁现象"之后都可以轻松理解

3.4 synchronize底层原理

Java虚拟机中的同步(Synchronization)基于进入和退出管程(Monitor)对象实现,无论是显式同步(有明确的monitorenter和monitorexit指令,即同步代码块)还是隐式同步都是如此;在Java语言中同步用的最多的地方可能是被synchronized修饰的同步方法,同步方法并不是由monitorenter和monitorexit指令来实现同步的,而是由方法调用指令读取运行时常量池中方法的ACC_SYNCHRONIZED标志来隐式实现,关于这点会在之后进行分析,下面先来了解下Java对象头

3.4.1 Java对象头

在Java中,对象在内存中的布局分为三块区域:对象头、实例数据和对齐填充

image-20210324114342300

  • 实例变量:存放类的属性数据信息,包括父类的属性信息,如果是数组的实例部分还包括数组的长度,这部分内存按4字节对齐
  • 填充数据:由于虚拟机要求对象起始地址必须是8字节的整数倍,填充数据不是必须存在的,仅仅是为了字节对齐,这点了解即可

而对于顶部则是Java对象头,它是实现synchronized锁对象的基础,一般而言synchronized使用的锁对象是存储在Java对象头中的,JVM中采用2个字来存储对象头(如果是数组则分配3个字,多出来的一个字记录数组长度),其主要结构是由Mark Word和Class Metadata Address组成,其结构说明如下:

虚拟机位数头对象结构说明
32/64bitMark Word存储对象的hashCode、锁信息或分代年龄或GC标志等信息
32/64bitClass Metadata Address类型指针指向对象的类元数据,JVM通过这个指针确定该对象是哪个类的实例

其中Mark Word在默认情况下存储着对象的HashCode、分代年龄、锁标记位等,以下是32位JVM的Mark Word默认存储结构

锁状态25bit4bit1bit是否是偏向锁2bit 锁标志位
无锁状态对象HashCode对象分代年龄001

由于对象头的信息是与自身定义的数据没有关系的额外存储成本,因此考虑到JVM的空间效率,Mark Word被设为一个非固定的数据结构,以便存储更多有效的数据,它会根据对象本身的状态复用自己的存储空间,例如32为JVM中除了上述列出的Mark Word默认存储结构外,还有如下可能变化的结构:

image-20210304002807611

其中轻量级锁和偏向锁是Java6对synchronized锁进行优化后新增的,稍后我们会进行分析,这里我们主要分析下重量级锁也就是常说synchronized的对象锁,锁标识位为10,其中指针指向的是monitor对象(也称为管程或监视器锁)的起始地址,每个对象都存在着一个monitor与之关联,对象和其monitor之间的关系存在着多种实现方式,如monitor可以与对象一起创建销毁或当线程视图获取对象锁时自动生成,但当一个monitor被某个线程持有后,它便处于锁定状态

3.4.2 Monitor

正如上面所说,每个Java对象都可以关联一个Monitor对象,如果使用synchronized关键字给对象上锁(重量级锁)之后,该对象的Mark Word就被设置为指向Monitor对象的指针(从而和上图中的锁类型相对应)

我们首先用图解的方式看下Monitor的结构:

image-20210304003516039

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


下面就用图解的方式来描述下Monitor对于对象的加锁和去锁

image-20210304004409074

当Thread-2执行synchronized(obj)尝试去获取obj对象锁时,obj对象就会尝试和操作系统提供的一个monitor对象相关联,将obj对象的Mark Word从无锁的结构转化为monitor的对象指针和当前的锁标识位(10)

然后将monitor对象的owner变量设置为Thread-2

image-20210304004645214

此时Thread-1也尝试获取obj对象锁,但是发现obj对象已经关联了一个monitor,紧接着就去检查monitor中的owner变量是否为null,这时发现并不为null

接着Thread-1就会进入到EntryList变量中(阻塞队列),然后处于BLOCKED状态

image-20210304005013075

此时阻塞队列中已经存在Thread-1和Thread-3,内部结构为链表

当Thread-2执行完毕之后,Thread-2就会释放锁,这时monitor对象的owner变量就变成null

然后Thread-1和Thread-3就开始公平竞争锁,当某个线程抢到锁之后,那么owner变量就会指向新的线程


在Java虚拟机中,monitor是由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 ;
}

3.4.3 从字节码角度分析synchronized

这里首先假设一种场景:

public class Demo4 {
    static final Object lock = new Object();
    static int counter;

    public static void main(String[] args) {
        synchronized (lock) {
            counter++;
        }
    }
}

那么main方法对应的字节码指令:

 0 getstatic #2 <org/jiang/threads/demo4/Demo4.lock>
 3 dup
 4 astore_1
 5 monitorenter
 6 getstatic #3 <org/jiang/threads/demo4/Demo4.counter>
 9 iconst_1
10 iadd
11 putstatic #3 <org/jiang/threads/demo4/Demo4.counter>
14 aload_1
15 monitorexit
16 goto 24 (+8)
19 astore_2
20 aload_1
21 monitorexit
22 aload_2
23 athrow
24 return

这里首先引用lock锁对象,然后将lock锁存入局部变量表中

执行monitorenter指令上锁,本质上就是将lock对象Mark Word置为Monitor指针,此时monitor的进入计数器从0增加到1(这是为了分析锁的可重入性)

引用counter对象,然后对counter执行递加操作,然后将变量存储到局部变量表中

然后读取局部变量表中位置为1的变量也就是lock锁,再执行monitorexit指令解锁,本质上就是将lock锁对象Mark Word重置,然后唤醒monitor对象的EntryList,此时monitor的进入计数器从1减少到0

然后跳转到24行return

这里有一个问题,那么19-23行的字节码有什么用呢?

实际上这部分是当程序发生异常时,需要释放锁对象所操作的步骤

我们都知道字节码指令中有一部分是异常表,那么main方法对应的异常表:

image-20210304010546981

可以看出这里当6-16行发生错误时就跳转到19行,从第19行开始是怎么执行的呢?

首先将异常信息存储到局部变量表中slot为2的位置上

然后读取局部变量表中位置为1的变量也就是lock锁,然后执行monitorexit指令解锁

接着读取局部变量表中位置为2的变量也就是异常信息,然后抛出该异常

3.4.4 轻量锁

如果一个对象虽然有多线程访问,但多线程访问的时间是错开的(也就是没有竞争),那么就可以使用轻量级锁来优化

轻量级锁对使用者是透明的,即语法仍然是synchronized

那么下面我们就来假设一个场景来解释轻量级锁:

public class Demo004 {
    static final Object lock = new Object();

    public static void method01() {
        synchronized (lock) {
            // 同步块

        }
    }

    public static void method02() {
        synchronized (lock) {
            // 同步块
        }
    }
}

那么下面就来分析下整体的加锁流程:

image-20210304203829062

首先我们要知道每个线程的每个栈帧都会包含一个记录锁记录的结构,被称为锁记录(Lock Record)对象,那么当前线程的当前方法就会包含一个锁记录对象,对象中包含锁对象的引用地址和(当前锁记录对象的引用地址和锁标识位00)


image-20210304204457348

当Thread-0执行到synchronized(lock)语句并尝试获取对象锁时,那么锁记录对象中的锁记录对象引入地址和对象锁的Mark Word就会尝试通过CAS发生比较并交换


image-20210304204849311

如果CAS交换成功,那么锁对象头的Mark Word就会转化为锁记录对象的引用地址和锁标识位00,从而表示当前线程对锁对象加锁(这里刚好和上面介绍的轻量锁的Mark Word结构对应)


image-20210304205226751

如果CAS交换失败,这时就存在两种情况:

  • 如果是其他线程已经持有了锁对象的轻量级锁,这就表明线程之间存在竞争,进入锁膨胀(锁膨胀会在下面进行介绍)
  • 如果是自己执行了synchronized锁重入,那么就在添加一条Lock Record(这里的锁记录对象中记录值为null)作为重入的计数(这里实际上就是从method01中调入了method02,而且两个方法是同一个锁对象,且目前是同一个线程在操作,那么就会出现锁重入,这时肯定需要一个变量来记录重入的次数)

image-20210304205833794

当退出synchronized代码块(解锁)时发现有取值为null的锁记录,那么就表示此时存在锁重入,这时就需要重置锁记录,表示重入计数减1


image-20210304210020366

当退出synchronized代码块(解锁)时发现锁记录取值不为null,这时就使用CAS将锁记录对象中的Mark Word的值恢复给锁对象,那么此时锁对象的锁标识位又变成了01

3.4.5 锁膨胀

我们刚才已经提到过,当某个线程在尝试加轻量级锁的过程中,CAS执行失败,那么有种可能就是已经有其他线程为此锁对象加上了轻量级锁(有竞争),这时就需要锁膨胀,将轻量级锁变化为重量级锁

image-20210304210437139

当Thread-1尝试加轻量级锁时,发现此时锁对象的锁标识位已经变成了00,且锁记录对象的引用地址不是自己线程内部的对象


image-20210304210923807

这时Thread-1加轻量级锁失败,进入锁膨胀流程:

  1. 为锁对象申请Monitor锁,将锁对象指向重量级锁地址,且锁标志位改为10
  2. 然后自己进入Monitor的EntryLsit处于BLOCKED状态

当Thread-0退出同步块解锁时,使用CAS将Mark Word的值恢复给对象头时,发现这时锁对象的Mark Word已经不是Lcok Record的引用地址,因此就会失败

接着就会进入重量级锁的解锁流程,也就是Thread-0按照monitor地址找到Monitor对象,设置owner为null,唤醒EntryList中的BLOCKED线程

3.4.6 自旋优化

重量级锁竞争时,还可以使用自选来进行优化,如果当前线程自旋成功(即这时持有锁对象的线程已经退出了同步代码块,释放了锁),这时当前线程就可以避免阻塞

image-20210304211639855

类似于这样,当线程2发现已经有线程持有对象锁时,并不会直接进入对象锁的EntryList,而是先通过循环(也就是自旋)在一定时间内检查当前锁对象是否被释放

  • 在Java6之后自旋锁时自适应的,比如对象刚刚的一次自旋操作成功过,那么认为此次自旋成功的可能性就比较高,那么就多自旋几次
  • 自旋会消耗CPU的性能(不断循环肯定的),多核CPU才能发挥其优势

3.4.7 偏向锁

轻量级锁在没有竞争时(就自己一个线程),每次重入时依然需要执行CAS操作

那么Java6引入了偏向锁来做进一步优化:只有第一次使用CAS将线程ID设置到对象的Mark Word头,之后发现这个线程ID时自己的就表示没有竞争,不用重新CAS;以后只要不发生竞争,这个对象就归该线程所有


假设一种情况:

public class Demo004 {
    static final Object lock = new Object();

    public static void method01() {
        synchronized (lock) {
            // 同步块

        }
    }

    public static void method02() {
        synchronized (lock) {
            // 同步块
        }
    }

    public static void method03() {
        synchronized (lock) {
            // 同步块
        }
    }
}

这里轻量级锁是如果加锁和判别的呢?

image-20210304212835150


未完待续

3.4.8 synchronized同步方法底层原理

我们上面实际上在使用synchronized时实际上都是同步代码块,然而方法级的同步是隐式的,即无需通过字节码指令来控制,它实现在方法调用和返回操作之中,JVM可以从方法常量池中的方法表结构(method_info_Structure)中的ACC_SYNCHRONIZED访问标志区分一个方法是否为同步方法,当方法调用时,调用指令会检查方法的ACC_SYNCHRONIZED访问标志是否被设置,如果设置了,执行线程将先持有monitor(管程),然后再执行方法,最后在方法完成(无论是正常完成还是非正常完成)时释放monitor

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


下面就以一个例子来看下字节码层面如何实现:

public class Demo0004 {
    private static int i;

    public synchronized static void main(String[] args) {
        i++;
    }
}

对应的字节码文件:

Classfile /Users/zejian/Downloads/Java8_Action/src/main/java/com/zejian/concurrencys/SyncMethod.class
  Last modified 2017-6-2; size 308 bytes
  MD5 checksum f34075a8c059ea65e4cc2fa610e0cd94
  Compiled from "SyncMethod.java"
public class com.zejian.concurrencys.SyncMethod
  minor version: 0
  major version: 52
  flags: ACC_PUBLIC, ACC_SUPER
Constant pool;

   //省略没必要的字节码
  //==================syncTask方法======================
  public synchronized void syncTask();
    descriptor: ()V
    //方法标识ACC_PUBLIC代表public修饰,ACC_SYNCHRONIZED指明该方法为同步方法
    flags: ACC_PUBLIC, ACC_SYNCHRONIZED
    Code:
      stack=3, locals=1, args_size=1
         0: aload_0
         1: dup
         2: getfield      #2                  // Field i:I
         5: iconst_1
         6: iadd
         7: putfield      #2                  // Field i:I
        10: return
      LineNumberTable:
        line 12: 0
        line 13: 10
}
SourceFile: "SyncMethod.java"

从字节码中可以看出,synchronized修饰的方法并没有monitorenter指令和monitorexit指令

取得代之的确实是ACC_SYNCHRONIZED标识,该标识指明了该方法是一个同步方法,JVM通过该ACC_SYNCHRONIZED访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用

synchronized属于重量级锁,效率低下,因为监视器锁(monitor)是依赖于底层的操作系统的Mutex Lock来实现的,而操作系统实现线程之间的切换时需要从用户态转换到核心态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高

这就是为什么Java 6之后,为了减少获得锁和释放锁所带来的性能消耗引入了轻量级锁和偏向锁

3.4.9 wait()和notify()

3.4.9.1 wait()和notify()底层原理

image-20210304215238223

当某个线程原本是占有锁对象的,但是发现自己的其他条件不满足,那么监视器对象Object就可以调用wait()方法,进入WaitSet队列且状态变为WAITING

  • BLOCKED和RWAITING的线程都处于阻塞状态,不占用CPU时间片
  • BLOCKED线程会在owner变为null时被唤醒并竞争锁资源
  • 而WAITING线程只有当监视器对象Object调用notify()notifyAll()方法时被唤醒,但是唤醒之后并不意味着立刻获得锁,仍需进入EntrySet重新竞争
3.4.9.2 相关API
  • obj.wait():让获取监视器锁的线程进入waitSet等待
  • obj.notify():随机唤醒waitSet中等待的一个线程
  • obj.notifyAll():唤醒waitSet中等待的所有线程

注意:它们都是线程之间协作的手段,但是调用API的对象都是监视器(也就意味着这些方法都属于Object类),但是必须获得此对象的锁,才能调用这些方法


下面就对相关API进行测试

@Slf4j
public class Demo5 {
    private static Object lock = new Object();

    public static void main(String[] args) {
        new Thread(() -> {
            synchronized (lock) {
                log.info("开始运行...");
                try {
                    lock.wait();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                log.info("其他逻辑...");
            }
        },"t1").start();

        new Thread(() -> {
            synchronized (lock) {
                log.info("开始运行...");
                try {
                    lock.wait();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                log.info("其他逻辑...");
            }
        },"t1").start();

        try {
            TimeUnit.SECONDS.sleep(2);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        log.debug("唤醒其他线程...");
        synchronized (lock) {
            lock.notifyAll();
        }
    }
}

1、首先main线程需要休眠2s(注意sleep()方法是不会释放锁的),然而这时main线程也没有获取锁

2、所以这时t1或者t2线程拥有锁,然后都会调用wait()等待(注意wait()方法会释放锁),故此时当t1和t2都处于waitSet集合

3、main线程获取锁并且唤醒waitSet集合中的所有线程,然后t1和t2都进入EntrySet开始竞争资源,然后执行之后的代码

测试结果

image-20210304221341587

可以看出执行的逻辑和我们预想的结果基本一致


这里还有一个API就是:obj.wait(long timeout)表示有时间限制的等待,如果到达时间限制依然没有被唤醒,就自动进入EntrySet

image-20210304221654659

这里可以看到不带参数的wait()方法传入的参数为0,也就是表示无限制等待

3.4.9.3 sleep()和wait()

sleep()wait()之间的区别:

  • sleep()是Thread类的静态方法,而wait()是Object类的成员方法
  • sleep()不需要强制和synchronized配合使用,而wait()必须要配合synchronized一起使用,且只有当获得锁之后才有权利调用
  • sleep()在睡眠的同时不会释放锁,而wait()在等待的同时会释放锁
3.4.9.4 保护性暂停模式

Guarded Suspension,用在一个线程等待另一个线程的执行结果

核心点就是:

  • 如果有一个结果需要从一个线程传递到另外一个线程,就让他们关联同一个GuardObject(也就是一个生产,一个消费)
  • 如果有结果不断地从一个线程到另外一个线程那么就可以使用消息队列(一方生产消息,另一方才能消费消息)
  • 在JDK中,join()方法和Future接口的实现就是采用保护性暂停模式
  • 因为一方要等待另一方的结果,因此就属于同步模式

image-20210304225806302


join()方法的底层实现

我们知道调用join()方法的线程强行加入到当前线程,并且只有等到调用join()方法的线程执行结束之后,当前线程才能继续执行,那么这是如何实现的呢?

public final synchronized void join(long millis)
throws InterruptedException {
    long base = System.currentTimeMillis();
    long now = 0;

    if (millis < 0) {
        throw new IllegalArgumentException("timeout value is negative");
    }

    if (millis == 0) {
        while (isAlive()) {
            wait(0);
        }
    } else {
        while (isAlive()) {
            long delay = millis - now;
            if (delay <= 0) {
                break;
            }
            wait(delay);
            now = System.currentTimeMillis() - base;
        }
    }
}

join方法原理

3.4.9.5 一般生产者消费者模型

核心:多线程之间通信,主要步骤:

  • 判断条件是否成立,成立就等待
  • 不成立则执行操作
  • 执行完操作唤醒其他线程

资源类

@Slf4j
public class Resource {
    private int num = 0;

    public synchronized void increment() throws InterruptedException {
        if (num != 0) {
            this.wait();
        }
        num++;
        log.info(Thread.currentThread().getName() + num);
        this.notifyAll();
    }

    public synchronized void decrement() throws InterruptedException {
        if (num == 0) {
            this.wait();
        }
        num--;
        log.info(Thread.currentThread().getName() + num);
        this.notifyAll();
    }
}

通过synchronized关键字对整个方法加锁,通过if判断是否需要阻塞该线程,如果线程没有阻塞,在执行完操作之后唤醒其他线程


线程

public class Processor {
    public static void main(String[] args) {
        Resource resource = new Resource();

        new Thread(() -> {
            try {
                for (int i = 0; i < 100; i++) {
                    resource.increment();
                }
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        },"生产者").start();

        new Thread(() -> {
            try {
                for (int i = 0; i < 100; i++) {
                    resource.decrement();
                }
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        },"消费者").start();
    }
}

通过结果可以看出传统方式可以实现生产消费模型

但是思考一个问题,如果现在存在多个生产者和消费者会出现什么情况呢?

多线程通信过程中如果在对条件进行判断时使用的是if条件,就可能出现虚假唤醒

考虑一种情况,最初资源数量为0,那么两个消费者线程都会处于阻塞状态,假设生产者A先抢到CPU,则判断通过之后操作资源数量为1,这时消费者A和B实际上都是从阻塞中被唤醒,再假设消费者A先抢到CPU,则执行操作另资源数量变为0,之后消费者B抢到CPU,由于这里使用的是if判断,故对于消费者B已经经过了判断,直接执行操作,令资源数量变为-1

3.4.9.6 优化生产者消费者模型-避免虚假唤醒

我们从上面一般生产者消费者模型已经知道使用if进行判断可能造成虚假唤醒,因此需要使用while进行优化

这里线程不发生任何改变,只优化资源类

资源类

@Slf4j
public class Resource {
    private int num = 0;

    public synchronized void increment() throws InterruptedException {
        while (num != 0) {
            this.wait();
        }
        num++;
        log.info(Thread.currentThread().getName() + "\t" + num);
        this.notifyAll();
    }

    public synchronized void decrement() throws InterruptedException {
        while (num == 0) {
            this.wait();
        }
        num--;
        log.info(Thread.currentThread().getName() + "\t" + num);
        this.notifyAll();
    }
}

这里我们也得出对于多线程进行操作时的结论,那就是"线程 操作 资源类"

  • 对于资源类的操作应该保存在资源内部
  • 线程只是对资源已经封装好的操作进行调用

3.4.10 线程状态转换

NEW->RUNNABLE

当线程调用start()方法时

RUNNABLE->WAITING

当t线程使用synchronized(obj)获取对象锁之后:

  • 调用obj.wait()方法时,t线程转换为WAITING
  • 调用obj.notify()、obj.notifyAll()、t.interrupt()方法时(实际上就是判断是否从waitSet转换到EntrySet)
    • 如果竞争锁成功,t线程从WAITING->RUNNABLE
    • 如果竞争锁失败,t线程从WAITING->BLOCKED

当前线程调用t.join()方法时,当前线程从RUNNABLE->WAITING(这点从jon()源码中就可以分析出)


个人公众号目前正初步建设中,如果喜欢可以关注我的公众号,谢谢!

二维码