线程基础、线程之间的共享与协作(1)

毫不例外,进程与线程会时常伴随着我们在在我们的日常开发中。为了加深对线程与进程理论知识的学习,本文特做记录。

进程与线程

进程

我们都知道计算机的核心是CPU,它承担了所有的计算任务,而操作系统是计算机的管理者,它负责任务的调度,资源的分配和管理,统领整个计算机硬件;应用程序是具有某种功能的程序,程序是运行于操作系统之上的。

进程是一个具有一定独立功能的程序在一个数据集上的一次动态执行的过程,是操作系统进行资源分配和调度的一个独立单位,是应用程序运行的载体。

进程是一种抽象的概念,从来没有统一的标准定义。进程一般由程序,数据集合和进程控制块三部分组成。程序用于描述进程要完成的功能,是控制进程执行的指令集;数据集合是程序在执行时所需要的数据和工作区;程序控制块包含进程的描述信息和控制信息是进程存在的唯一标志

进程具有的特征:

  • 动态性:进程是程序的一次执行过程,是临时的,有生命期的,是动态产生,动态消亡的;
  • 并发性:任何进程都可以同其他进行一起并发执行;
  • 独立性:进程是系统进行资源分配和调度的一个独立单位;
  • 结构性:进程由程序,数据和进程控制块三部分组成

线程

在早期的操作系统中并没有线程的概念,进程是拥有资源和独立运行的最小单位,也是程序执行的最小单位。任务调度采用的是时间片轮转的抢占式调度方式,而进程是任务调度的最小单位,每个进程有各自独立的一块内存,使得各个进程之间内存地址相互隔离。

后来,随着计算机的发展,对CPU的要求越来越高,进程之间的切换开销较大,已经无法满足越来越复杂的程序的要求了。于是就发明了线程,线程是程序执行中一个单一的顺序控制流程,是程序执行流的最小单元,是处理器调度和分派的基本单位。一个进程可以有一个或多个线程,各个线程之间共享程序的内存空间(也就是所在进程的内存空间)。一个标准的线程由线程ID,当前指令指针PC,寄存器和堆栈组成,依赖于进程而存在。而进程由内存空间(代码,数据,进程空间,打开的文件)和一个或多个线程组成。

进程与线程的区别

  1. 线程是程序执行的最小单位,而进程是操作系统分配资源的最小单位;
  2. 一个进程由一个或多个线程组成,线程是一个进程中代码的不同执行路线
  3. 进程之间相互独立,但同一进程下的各个线程之间共享程序的内存空间(包括代码段,数据集,堆等)及一些进程级的资源(如打开文件和信号等),某进程内的线程在其他进程不可见;
  4. 调度和切换:线程上下文切换比进程上下文切换要快得多

CPU核心数和线程数的关系

我们现在使用的手机、笔记本,基本都是多核心的。那么什么是多核心?

在《编程之美》提到,在早期计算机里,一个芯片上只能放一个物理核心,为了提高计算的速度,半导体制程不断发展,使得芯片体积越来越小,这带来许多好处:除了制造成本更低之外,芯片性能也得到提升。芯片不断缩小,就要受到量子物理的约束,受到量子隧穿的影响,再提高晶体管的密度就不行了。于是大家就想到,把多个物理核心集成到一个芯片上。

那么CPU多核的核心数和线程有是么关系呢?

打开Windows电脑的任务管理器,打开性能界面可以看到笔记本的处理器参数。比如我的电脑,Inter(R) Core(TM) i7-4710H @ 2.50GHz 处理器,有4个内核,但是逻辑处理器有8个。一般情况下,内核数和线程数是一对一。前面我们说过,真正执行任务的是线程,但是英特尔使用了超线程技术,物理核心数和逻辑核心数是1:2的关系,意味着计算机上可以同时跑8个线程。但是,我们平时开发的时候,并没有感觉受到线程数的限制,我们想开线程就开。这是因为计算机为我们提供了一种CPU时间片轮转机制,这是一种最古老,最公平,最广泛的调度方法,也称为RR调度。

CPU时间片轮转机制

该机制会对CPU时间进行切片,每个进程被分配一时间段,称作它的时间片,即该进程允许运行的时间。

系统会维护一张就绪进程列表,其实就是一个先进先出的队列,新来的进程就会被加到队列的末尾,然后每次执行进程调度的时候,都会选择队列的队首进程,让它在CPU上运行一个时间片的时间,不过如果分配的时间片已经消耗光了而进程还在运行,调度程序就会停止该进程的运行,同时把它移到队列的末尾,CPU会被剥夺并分配给队首进程,而如果进程在时间片结束前阻塞或者结束了,则CPU就会进行切换。

假如我们在键盘敲下asdf这样一行字母,其实就是一瞬间的事。按照查找到的资料,人的反应速度是0.1秒。但是一个1.6GHz CPU执行一条指令的速度是0.6纳秒。一秒钟包含了10亿纳秒。我们按下a的时候,CPU完全可以将这这段时间划分成很多块,然后用一小段时间去完成这个操作,而其他时间都去做其他事,我们人能反应过来吗。

时间片轮转调度中唯一有趣的一点是时间片的长度。从一个进程切换到另一个进程是需要定时间的,包括保存和装入寄存器值及内存映像,更新各种表格和队列等。假如进程切(processwitch),有时称为上下文切换( context switch),需要5ms,再假设时间片设为20ms,则在做完20ms 有用的工作之后,CPU 将花费5ms 来进行进程切换。CPU 时间的20%被浪费在了管理开销上了。为了提高CPU 效率,我们可以将时间片设为5000ms。这时浪费的时间只有0.1%。但考虑到在一个分时系统中,如果有10 个交互用户几乎同时按下回车键,将发生什么情况?假设所有其他进程都用足它们的时间片的话,最后一个不幸的进程不得不等待5s 才获得运行机会。多数用户无法忍受一条简短命令要5 才能做出响应,同样的问题在一台支持多道程序的个人计算机上也会发结论可以归结如下:时间片设得太短会导致过多的进程切换,降低了CPU 效率:而设得太长又可能引起对短的交互请求的响应变差。将时间片设为100ms通常是一个比较合理的折衷。在CPU死机的情况下,其实大家不难发现当运行一个程序的时候把CPU 给弄到了100%再不重启电脑的情况下,其实我们还是有机会把它kill掉的,我想也正是因为这种机制的缘故。

我们观察到,现在笔记本上一共存在了133个进程,2599个线程,也就是说远远超过了逻辑处理器的数量。那么CPU是如何调度的呢?

并行和并发

来看一个咖啡机与队列的例子。假如我们有两个队列,他们都想使用咖啡机。

并行:有两台咖啡机,那么就可以让两个队列同时使用。在同一时间可以同时执行不同的任务。

并发:只有一台咖啡机,两个队列只能交替使用咖啡机。时间片轮转机制就是一种并发的实现。

两者区别:一个是交替执行,一个是同时执行。

那么我们为什么要使用高并发编程呢?首先第一个来讲,我们现在的机器都是多核心,我们使用高并发可以充分利用CPU的资源。假如我们现在有8个核心,我们的代码是单线程的,那么只能使用一个核心,不能充分使用处理器的资源假如原来代码执行速度是5秒,我们通过多线程改造,提高到1秒,且不需要做任何服务器的增加。多线程还可以加速用户响应时间,比如我们使用迅雷的时候,通常都会开启多个下载任务,这样速度才更快。

它还能让我们的代码模块化,异步化,比如我们在开发电商应用的时候,用户进行下单后,会进行减库存->通知用户->通知快递->等等一系列操作,如果我们使用串行开发,那么总时间是不是就是所有时间相加起来。其实通知用户这一步,完全跟用户下单的其他操作,是不相关的,完全可以拿出来用另一个线程去完成,这样就可以实现模块化,异步化。

但是高并发编程也存在安全性问题。在一个进程里面,线程是共享进程的资源的,那么这样一来,就会存在资源竞争的问题。为了解决这个问题,我们就不可避免的引入了锁的机制,但是使用锁必然会带来性能的下降,因此,不正确的使用锁,可能会适得其反,甚至不如单线程执行的速度。在OS中,会对线程数进行限制,在Linux中,1个进程限制了1000个线程,在Windows中,1个进程限制了线程上限为2000。为什么要这样呢?我们知道,新建线程的时候,会给它单独分配栈空间,在Java中缺省是1M的大小,因此打开线程会消耗大量的资源,因此不限制线程的数量,分分钟就会导致崩溃,所以我们通常会使用线程池。

Java中的多线程

在Java中,天生就是多线程的。我们通过下面代码来观察Java虚拟机中的线程信息。

public class OnlyMain {
    public static void main(String[] args) {
        //Java 虚拟机线程系统的管理接口
        ThreadMXBean threadMXBean = ManagementFactory.getThreadMXBean();
        // 不需要获取同步的monitor和synchronizer信息,仅仅获取线程和线程堆栈信息
        ThreadInfo[] threadInfos =
                threadMXBean.dumpAllThreads(false, false);
        // 遍历线程信息,仅打印线程ID和线程名称信息
        for (ThreadInfo threadInfo : threadInfos) {
            System.out.println("[" + threadInfo.getThreadId() + "] "
                    + threadInfo.getThreadName());
        }
    }
}

短短几行代码,Java虚拟机竟然为我们启动了那么多线程。

[6] Monitor Ctrl-Break
[5] Attach Listener
[4] Signal Dispatcher
[3] Finalizer
[2] Reference Handler
[1] main

main就是主线程,Finalizer是所谓的守护线程,我们知道所有类的父类都是Object,在Object中有一个finalize()方法,而我们在学习Java语言时提到过如果需要对资源进行回收,就可以写在这个方法里面。但是即便是写在finalize()里面,代码也有可能是不执行的。因为finalize()方法正是在Finalizer线程中执行的,随着主线程一结束,Finalizer线程也跟着结束了,就有可能导致资源回收代码还没跑完。所以不推荐资源回收代码写在finalize()中。

Java新启线程的方式

那么在Java中,有几种新启线程的方式呢?一般都会说三种:

  • 类Thread
  • 接口Runable
  • 接口Callable

但是我们看一下Thread源码中的注释,它写到:

 /**
 * There are two ways to create a new thread of execution. One is to
 * declare a class to be a subclass of Thread. This
 * subclass should override the run method of class
 * Thread.
 *
 * The other way to create a thread is to declare a class that
 * implements the Runnable interface. That class then
 * implements the run method. An instance of the class can
 * then be allocated, passed as an argument when creating
 * Thread, and started.
 */

也就是说,有两种启动新线程的方式。一种就是扩展自Thread类,然后重写run()方法

private static class UseThread extends Thread{
        @Override
        public void run() {
            super.run();
            // do my work;
            System.out.println("I am extendec Thread");
        }
    }

另一种就是实现Runnable接口,实现run()方法

private static class UseRunnable implements Runnable{

        @Override
        public void run() {
            // do my work;
            System.out.println("I am implements Runnable");
        }
    }

当我们使用的时候,对于前者,我们创建一个该类的实例,然后调用start()方法。对于后者,我们将runnable作为构造方法参数,创建一个Thread的实例,然后调用start()

public static void main(String[] args) throws InterruptedException, ExecutionException {
        UseThread useThread = new UseThread();
        useThread.start();

        UseRunnable useRunnable = new UseRunnable();
        new Thread(useRunnable).start();
    }

Thread和RUnnable的区别

Thread是Java里真正意义上对线程的抽象,而Runnable是对任务的抽象。

比如在餐厅里面,A要做接待、要做送餐、要做饭菜、要做收银,那么对于A来说,他就是一个线程(Thread),他所要做的事就是任务(Runnable)。当餐厅人一多,A一个人忙不过来了,就必须再请多一个B来做饭菜,C来收银。就相当于多新启了几个线程,将任务分配了出去。

Java里线程的中止

既然有开始就有结束,那么怎么样才能让Java里的线程安全停止工作呢?我们查找Thread源码,可以找到stop()destroy(),suspend()等方法,但是在上面都打上了@Deprecated注解,JDK并不推荐我们使用。因为这些方法带有很强的强制性。拿suspend()挂起方法来说,它会强行让一个线程发生上下文切换,从运行状态变成休眠状态,但是相关线程是不会释放资源的。stop()会野蛮的把线程直接杀死,它不管当前线程是否正常释放了资源。比如有那么一个写入文件的线程,一共10K大小,写入到4K的时候,stop()方法直接杀死了这个线程。正常来说一个完整的文件会有文件终止符,那么后面再去读取这个文件的时候,就会出现异常。

在Thread中还提供了interrupt的方法,用于进程的中断。但是查看代码可以看到,有关interrupt的方法有三个:

  • public void interrupt()
  • public static boolean interrupted()
  • public boolean isInterrupted()

虽然interrupt的方法是用于进程的中断,但是调用其方法的时候,并不是马上中止线程,而是更改它的中断标志位,通俗地讲,就是跟线程打个招呼,告诉它你要中止了。但是线程完全可以不理会,可以继续进行它的工作,是否停止,完全由线程来做主。因为在JDK中,线程是协作式的,而不是抢占式的。

来看以下代码,我们来观察interrupt方法调用,对子线程产生的影响:

/**
 *类说明:如何安全中断线程
 */
public class EndThread {

    private static class UseThread extends Thread{

        public UseThread(String name) {
            super(name);
        }

        @Override
        public void run() {
            String threadName = Thread.currentThread().getName();
            System.out.println(threadName+" interrrupt flag ="+isInterrupted());
            // while(!isInterrupted()){
                //while(!Thread.interrupted()){
            while(true){
                System.out.println(threadName+" is running");
                System.out.println(threadName+"inner interrrupt flag ="
                        +isInterrupted());
            }
            System.out.println(threadName+" interrrupt flag ="+isInterrupted());
        }
    }

    public static void main(String[] args) throws InterruptedException {
        Thread endThread = new UseThread("endThread");
        endThread.start();
        Thread.sleep(20);
        endThread.interrupt();//中断线程,其实设置线程的标识位true
    }
}

当我们循环条件为while(true)时,执行endThread.interrupt();代码,子线程并没有收到影响,仍旧在继续运行着,但是从log中可以看到interrrupt flag已经被更改为true了。

我们再把循环条件改为while(!isInterrupted())并在此运行代码,调用endThread.interrupt();后,子线程会终止,interrrupt flag修改为true。我们再看 while(!Thread.interrupted()),这是Thread类提供的静态方法,它同样可以判断线程的中断标志位,只不过,它会将interrrupt flag修改为false。我们观察源码比较这两个方法的区别。

public static boolean interrupted() {
    return currentThread().isInterrupted(true);
}

public boolean isInterrupted() {
    return isInterrupted(false);
}
/**
  * Tests if some Thread has been interrupted.  The interrupted state
  * is reset or not based on the value of ClearInterrupted that is
  * passed.
  */
private native boolean isInterrupted(boolean ClearInterrupted);

Thread.interrupted()isInterrupted()方法最后都是调用了nativeisInterrupted方法,我们可以看到该参数的命名为清除中断标志位,也就是说基于传入的boolean变量,决定是否要重置中断标志位。而如果我们使用Runnable来启动一个线程的话,由于它没有isInterrupted()方法,便可以使用Thread.interrupted()来判断中断标志位。

在我们的有的同学日常开发中,可能会采用这样的中断方式,自定义一个isCanceled的boolean型变量,再定义一个setCanceled()的方法来使线程中断。但是并不建议这么做,当在线程中调用了sleep()或者wait()方法被挂起后,线程根本不会去判断isCanceled。但是使用Thread的中断标志位,即便线程被挂起了,也是可以捕捉到的。

/**
*if any thread has interrupted the current thread. The
*interrupted status of the current thread is
* cleared when this exception is thrown.
*/
public static native void sleep(long millis) throws InterruptedException;

因为这两个方法都会抛出InterruptedException的异常。我们再来观察以下代码:

/**
 *类说明:阻塞方法中抛出InterruptedException异常后,如果需要继续中断,需要手动再中断一次
 */
public class HasInterrputException {

    private static class UseThread extends Thread{

        public UseThread(String name) {
            super(name);
        }

        @Override
        public void run() {
            while(!isInterrupted()) {
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    System.out.println(Thread.currentThread().getName()
                            +" in InterruptedException interrupt flag is "+isInterrupted());
                    //资源释放
                    // interrupt();
                    e.printStackTrace();
                }
                System.out.println(Thread.currentThread().getName() + " I am extends Thread.");
            }
            System.out.println(Thread.currentThread().getName() +" interrupt flag is "+isInterrupted());
        }
    }

    public static void main(String[] args) throws InterruptedException {
        Thread endThread = new UseThread("HasInterrputEx");
        endThread.start();
        Thread.sleep(500);
        endThread.interrupt();
    }
}

当线程调用Thread.sleep()的时候,调用interrupt()方法,线程会捕捉到中断异常并抛出,但是我们观察Logcat,此时中断标志位却是false,并且线程并没有被停止。sleep()方法的注释中写的很明白了,抛出异常的时候,会将当前中断标志位清除。因此,如果我们要真正将线程中止,需要在catch{...}中再调用一次interrupt()方法。这是中断异常中需要注意的一点。

这就跟前述stop()方法一致,如果在挂起状态直接把线程中止的话,相关资源就没有来得及释放。所以我们可以在catch{...}中先完成资源释放,再决定要不要进行中断。

线程的真正启动

我们new Thread的时候,只是创建了一个线程的实例,并没有真正跟虚拟机中的线程绑定起来,只有当调用了Thread.start()之后,然后去调用了nativestart0()方法,让一个线程进入就绪队列等待分配cpu,分到cpu后才调用实现的run()方法。如果我们调用两次Thread.start()会发生什么呢。会抛出IllegalThreadStateException()错误,因为threadStatus状态在线程启动时就会被修改,默认为0的时候才认为是新线程。

public synchronized void start() {
        //判断threadStatus
        if (threadStatus != 0)
            throw new IllegalThreadStateException();
        //将线程添加到ThreadGroup中
        group.add(this);
        boolean started = false;
        try {
            start0();
            started = true;
        } finally {
            ......
        }
    }

Thread.run()方法是业务逻辑实现的地方,可以脱离线程来使用,本质上和任意一个类的任意一个成员方法并没有任何区别,可以重复执行,也可以被单独调用。

线程的生命周期

我们通过new Thread()就可以创建出一个线程实例,然后调用start()方法,线程就进入了就绪状态等待分配CPU,随后进入运行状态,如果线程run()方法走完了,或者调用了stop()方法,线程就会进入死亡状态,回收相应的资源。

在运行状态,调用sleep()方法会进入阻塞状态,当睡眠时间到了,又会回到就绪状态,等待分配CPU。如果在睡眠期间调用interrupt()会进入就绪状态。调用wait()方法也会进入阻塞状态,需要等待调用notify()/notifyAll()重新唤醒,进入就绪状态。

在线程运行期间,调用yield()方法会使当前线程让出CPU占有权,但让出的时间是不可设定的,操作系统就会对线程让出的时间在线程之间进行重新分配。假设有ABC三个线程,此时B线程占用CPU,这时候调用了yield(),操作系统重新分配CPU的时候,既可能分配给A和C,B有还有可能得到这个时间片。yield()也不会释放锁资源,并不是每个线程都需要这个锁的,而且执行yield( )的线程不一定就会持有锁,我们完全可以在释放锁后再调用yield()方法。

所谓的join()方法,就是把指定的线程加入到当前线程,可以将两个交替执行的线程合并为串行执行。比如在线程B中调用了线程A 的Join()方法,直到线程A执行完毕后,才会继续执行线程B。用以下例子进行演示。

/**
 * 类说明:演示Join()方法的使用
 */
public class UseJoin {

    static class Ming implements Runnable {
        private Thread thread;

        public Ming(Thread thread) {
            this.thread = thread;
        }

        public Ming() {
        }

        public void run() {
            System.out.println(Thread.currentThread().getName()+"小明开始排队打饭.....");
            try {
                if (thread != null) thread.join();
            } catch (InterruptedException e) {
            }
            SleepTools.second(2);//休眠2秒
            System.out.println(Thread.currentThread().getName() + " 小明打饭完成.");
        }
    }

    static class Fang implements Runnable {

        public void run() {
            System.out.println(Thread.currentThread().getName()+"小芳开始排队打饭.....");
            SleepTools.second(2);//休眠2秒
            System.out.println(Thread.currentThread().getName() + " 小芳打饭完成.");
        }
    }

    public static void main(String[] args) throws Exception {

        Fang fang = new Fang();
        Thread ft = new Thread(fang);
        Ming ming = new Ming(ft);
        //Goddess goddess = new Goddess();
        Thread mt = new Thread(ming);
        mt.start();
        ft.start();
    }
}

观察代码运行结果,可以发现,小明先开始打的饭,会被小芳抢占了,等到小芳打完饭,才会轮到小明打饭

Thread-1小明开始排队打饭.....
Thread-0小芳开始排队打饭.....
Thread-0 小芳打饭完成.
Thread-1 小明打饭完成.

Process finished with exit code 0

线程的优先级

在Java 线程中,通过一个整型成员变量priority 来控制优先级,优先级的范围从1~10,在线程构建的时候可以通过setPriority(int)方法来修改优先级,默认优先级是5,优先级高的线程分配时间片的数量要多于优先级低的线程。

设置线程优先级时,针对频繁阻塞(休眠或者I/O 操作)的线程需要设置较高优先级,而偏重计算(需要较多CPU 时间或者偏运算)的线程则设置较低的优先级,确保处理器不会被独占。在不同的JVM 以及操作系统上,线程规划会存在差异,有些操作系统甚至会忽略对线程优先级的设定。

守护线程

Daemon(守护)线程是一种支持型线程,因为它主要被用作程序中后台调度以及支持性工作。这意味着,当一个Java虚拟机中不存在非Daemon 线程的时候,Java虚拟机将会退出。可以通过调用Thread.setDaemon(true)将线程设置为Daemon线程。垃圾回收线程(GC)就是Daemon线程。

Daemon 线程被用作完成支持性工作,但是在Java 虚拟机退出时Daemon 线程中的finally{...} 块并不一定会执行。在构建Daemon 线程时,不能依靠finally 块中的内容来确保执行关闭或清理资源的逻辑。

线程间的共享

线程开始运行,拥有自己的栈空间,就如同一个脚本一样,按照既定的代码一步一步地执行,直到终止。但是,每个运行中的线程,如果仅仅是孤立地运行,那么没有一点儿价值,或者说价值很少,如果多个线程能够相互配合完成工作,包括数据之间的共享,协同处理事情。这将会带来巨大的价值。前面我们说过,进程是系统进行资源分配的一个最小单位,进程中的线程会共享进程中的资源。如果在访问的时候不加以限制,那么线程之间对资源的读写便会发生冲突,这就产生了线程同步的问题。

synchronized内置锁

Java 支持多个线程同时访问一个对象或者对象的成员变量,关键字synchronized可以修饰方法或者以同步块的形式来进行使用,它主要确保多个线程在同一个时刻,只能有一个线程处于方法或者同步块中,它保证了线程对变量访问的可见性和排他性,又称为内置锁机制。我们用以下代码来观察synchronized使用时的效果。

/**
 *类说明:synchronized关键字的使用方法
 */
public class SynTest {

    private long count =0;
    private Object obj = new Object();//作为一个锁

    public long getCount() {
        return count;
    }

    public void setCount(long count) {
        this.count = count;
    }

    /*用在同步块上*/
    public void incCount(){
        // synchronized (obj){
            count++;
        // }
    }

    /*用在方法上*/
    public synchronized void incCount2(){
            count++;
    }

    /*用在同步块上,但是锁的是当前类的对象实例*/
    public void incCount3(){
        synchronized (this){
            count++;
        }
    }

    //线程
    private static class Count extends Thread{

        private SynTest simplOper;

        public Count(SynTest simplOper) {
            this.simplOper = simplOper;
        }

        @Override
        public void run() {
            for(int i=0;i<10000;i++){
                simplOper.incCount();//count = count+10000
            }
        }
    }

    public static void main(String[] args) throws InterruptedException {
        SynTest simplOper = new SynTest();
        //启动两个线程
        Count count1 = new Count(simplOper);
        Count count2 = new Count(simplOper);
        count1.start();
        count2.start();
        Thread.sleep(50);
        System.out.println(simplOper.count);//20000
    }
}

两个线程分别对count变量进行累加,我们期望最后的结果是20000,当没有使用synchronized关键字的时候,由于线程对资源的共享,结果并不会是20000。而使用synchronized既可以使用在同步块中,以synchronized (obj){...}的方式使用,或者synchronized (this){...},也可以用在方法上进行修饰。

对象锁和类锁
我们上面对synchronized的使用,其实都是对对象的加锁,称之为对象锁,是用于对象实例方法,或者一个对象实例上的。同样的,synchronized关键字还可以用于类的静态方法上,其实锁的是类的class对象上的。我们知道,.class文件会被加载到虚拟机上,这时候静态方法会随着类定义的时候已经被装载和分配。类的对象实例可以有很多个,但是每个类只有一个class对象,所以不同对象实例的对象锁是互不干扰的,但是每个类只有一个类锁。

private static synchronized void synClass(){...}

但是有一点必须注意的是,其实类锁只是一个概念上的东西,并不是真实存在的,类锁其实锁的是每个类的对应的class 对象。类锁和对象锁之间也是互不干扰的。

错误的加锁和原因分析

什么叫错误的加锁呢,有时候我们使用了synchronized关键字,但是代码运行的效果跟我们期望的不一样。

/**
 * 类说明:错误的加锁和原因分析
 */
public class TestIntegerSyn {

    public static void main(String[] args) throws InterruptedException {
        Worker worker=new Worker(1);
        //Thread.sleep(50);
        for(int i=0;i<5;i++) {
            new Thread(worker).start();
        }
    }
    private static class Worker implements Runnable{

        private Integer i;
//        private Object o = new Object();

        public Worker(Integer i) {
            this.i=i;
        }

        @Override
        public void run() {
            synchronized (i) {
                Thread thread=Thread.currentThread();
                i++;
                System.out.println(thread.getName()+"-------"+i+"-@"
                        +System.identityHashCode(i));

            }
        }
    }
}

在这个例子里面,我们启动5个线程,对i进行i++,并且使用synchronized来锁住Integer对象i,按照我们的设想,得到的结果,应该是打印出1、2、3、4、5、6。但实际上并不是。

我们使用System.identityHashCode(i)打印出i的hashcode。

Thread-0-------2-@1856605884
Thread-2-------4-@868536663
Thread-4-------6-@1263691029
Thread-3-------5-@188323308
Thread-1-------3-@87690262

i这个对象,在地址上似乎发生了改变。按道理来说,i++了以后,传递给下一个线程的时候,地址不应该会发生变化。我们来探究这个问题,使用JDGUI反编译工具来查看.class文件,i++在编译之后究竟变成了什么。

Integer localInteger2= this.i = Integer.valueOf(this.i.intValue() + 1);

然后我们再看看Integer.valueOf()方法:

public static Integer valueOf(int i) {
      if (i >= IntegerCache.low && i <= IntegerCache.high)
          return IntegerCache.cache[i + (-IntegerCache.low)];
      return new Integer(i);
  }

它会new出一个新的Integer,虽然我们在编写的时候是i++,但是在实现的时候,其实都会创建一个新的Integer对象。这就是为什么我们进行了加锁,结果还是不对的原因。因为加锁的对象已经发生了改变,每个对象加锁的对象都不一样。

volatile关键字

除了使用synchronized关键字实现同步之外,我们还可以使用volatile关键字,这是Java中最轻量的同步机制,它保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的。

/**
 * 类说明:演示Volatile的提供的可见性
 */
public class VolatileCase {
    // private volatile static boolean ready;
    private  static boolean ready;
    private static int number;

    //
    private static class PrintThread extends Thread{
        @Override
        public void run() {
            System.out.println("PrintThread is running.......");
            while(!ready);//无限循环
            System.out.println("number = "+number);
        }
    }

    public static void main(String[] args) {
        new PrintThread().start();
        SleepTools.second(1);
        number = 51;
        ready = true;
        SleepTools.second(5);
        System.out.println("main is ended!");
    }
}

对于上面的代码来说,当boolean型变量ready没有加上volatile关键字的时候,子线程仍旧会一直执行,并没有观察到ready变量的修改而中止。

但是volatile不具备原子性,如果在多线程情况下使用不当,也会发生线程不安全的问题,不能保证数据在多个线程下同时写时的线程安全。所以在一写多读的场景下比较适合使用volatile

你可能感兴趣的:(线程基础、线程之间的共享与协作(1))