多线程之join方法引发的一系列疑问

吴云  2018-06-19 13:26:26   0评论  537浏览

写在前边

图片来源于网络

之前一个大佬为我的上一篇博客提出了几点宝贵意见。但是,那些天又是练车又是期末考试,我就一直没有更新那篇博客。这几天我开始静下心思考,怎么去补充那部分知识的时候才发现我根本无从下手,所以,我开始开始拿起我扔下很久的多线程……写这篇博客是为了把那些知识点再复习一遍,所有的问题都起源于一个问题。join方法到底是怎么实现的???这件事说来话长……我有故事你有酒吗?


正文

看看join()方法源码


1.public final synchronized void join(long millis) throws InterruptedException{
2.         long base = System.currentTimeMillis();
3.         long now = 0;
4.         if (millis < 0) {
5.            throw new IllegalArgumentException("timeout value is negative");
6.         }
7.         if (millis == 0) {
8.             while (isAlive()) {
9.                 wait(0);
10.            }
11.        } else {
12.            while (isAlive()) {
13.                long delay = millis - now;
14.                if (delay <= 0) {
15.                    break;
16.                }
17.                wait(delay);
18.                now = System.currentTimeMillis() - base;
19.            }
20.        }
21.    }

注意:调用无参的join方法相当于调用了join(0)所以贴出了join(long  millis)的源码

我们看到join方法是用synchronized 修饰的,说明调用join 方法后当前线程先获取到了this 的锁,获取到this的锁意味着如果线程中不释放这把锁,那么其他争夺这把的线程都无法得到执行。方法开始就计算当前时间,并判断我们传入的参数,如果millis小于零就抛出异常,如果millis等于0,就判断this线程是否存活,存活就执行this.wait(0);否则就判断当前线程是否存活,如果存活就判断时间是否超时,并调用this.wait(millis)等待。其实当我看到了第8行和第12行的循环的时候我就在想啊,为什么不是if呢?带着疑问我接着看了第9行wait(0)?这是又是什么明意思。不懂啊,这时候我发现这段代码,我看不下去了于是我就决定放弃继续看看wait的源码,很遗憾没这个wait是个native方法我看不到源码。

关于wait()方法的5个疑问

但是,不能这么算了,就开始疯狂的查资料,问群里的大佬。

关于wait(long misllis)方法,它的参数是一个long值,当这个参数为0时,相当于调用wait()。参数表示等待的毫秒数,当时间到了,就会自动醒过来。

那么问题来了

1.wait方法有什么用?

2.wait的参数为零,线程还会醒过来吗?

3.wait方法调用的过程中会发生什么事呢?

4.wait方法是让谁等待?

5.wait方法与sleep有啥不一样?

虽然这些问题看起很简单,但是想真正的想清楚对我来说还是有点费劲。

查资料发现与wait方法相对应的还有notify()/notifyAll(),这两个方法可以唤醒休眠中的线程

于是我试探性的使用wait和notify方法写了个程序

T1类继承了Thread并重写run方法:

class T1 extends Thread{
	private String name;
	@Override
	public void run() {
		System.out.println(name + " is running......");
		try {
			wait();
		} catch (InterruptedException e) {
			e.printStackTrace();//有检查型异常需要try-catch
		}
		System.out.println(name + " over.......");
	}
	public T1(String name) {
		this.name=name;
	}
}

T2类继承了Thread并重写run方法

class T2 extends Thread{
	private String name;
	@Override
	public void run() {
		System.out.println(name + " is running......");
		notifyAll();
		System.out.println(name + " over......");
	}
	public T2(String name) {
		this.name=name;
	}
}
主方法如下:
public class Main { 
	public static void main(String[] args) {
		new T1("t1").start();
		new T2("t2").start();
	}
}

我兴高采烈的去运行这段代码,没想到报了IllegalMonitorStateException异常,这是啥玩意?没办法,又去查资料。查完资料,我才知道为什么会有这个异常,原来这个waitnotify方法必须在synchronized里才行。于是我就再上边的基础上加上了synchronized块。然后它们的run方法就成了这样:

T1的run方法:

@Override
	public void run() {
		System.out.println(name + " is running......");
		synchronized(this){
			try {
				wait();
			} catch (InterruptedException e) {
				e.printStackTrace();// 有检查型异常需要try-catch
			}
		}	
		System.out.println(name + " over.......");
	}

T2的run方法:

@Override
	public void run() {
		System.out.println(name + " is running......");
		synchronized (this) {
			notifyAll();
		}
		System.out.println(name + " over......");
	}

现在倒是不报异常,可是,程序永远不会运行结束啊 ?效果图:

我发现t1线程永远得不到唤醒,我当时就不太明白了,然后我继续看资料。

1.  wait和notify/notifyAll方法的执行前提是必须获得一把锁

2.  而notify/notifyAll方法只能唤醒想要获取同一把锁的线程。

3.  当一个对象调用wait方法时,当前线程就会释放这个对象的锁,并进入等待状态

4.  notify/notifyAll方法不会释放该方法调用者的对象锁,只会唤醒想要这个对象的锁的线程,当执行notify的线程运行完同步块的代码后才会释放锁。

5.  notify方法只会随机唤醒等待这把锁的一个线程,notifyAll方法会唤醒等待这把锁的所有线程,但是只有其中一个会抢到这把锁

6.  wait(0)方法一旦调用,没有notify/notifyAll方法调用一般都不会醒(后续篇幅会讲解没有调用notify/notifyAll唤醒线程,线程是怎样醒的。)

就是说如果想使用wait,notify/notifyAll方法就必须在synchronized 里使用,获取的锁对象必须和调用wait,notify/notifyAll的对象是同一个对象,否则都会报异常。调用waitnotify/notifyAll的对象必须一致,比如s.notify只能唤醒想要获取s这把锁的线程,对不需要这把锁的线程没有用。

所以上边的那段代码T1T2run方法只是获取this的锁,但是这两个this分别是T1的对象和T2的对象,这两把锁是不一样的。this.notifyAll();只能唤醒想要获取this这个对象的锁的线程,而这个this就是new T2(“t2”);这个匿名对象,但是另一个类的this.wait();this却是new T1(“t1”)这个匿名对象,这绝对不是同一个对象,所以不能唤醒t1线程。

所以正确的写法应该是这样:

T1重写的run方法:

@Override
	public void run() {
		System.out.println(name + " is running......");
		synchronized ("lock") {
			try {
				"lock".wait();
			} catch (InterruptedException e) {
				e.printStackTrace();// 有检查型异常需要try-catch
			}
		}
		System.out.println(name + " over.......");
	}

T2重写的run方法:

@Override
	public void run() {
		System.out.println(name + " is running......");
		synchronized ("lock") {
			"lock".notify();
		}
		System.out.println(name + " over......");
	}

t1先抢到锁时,程序是可以正常结束了。但是如果t2先抢到锁,那t1就没有线程唤醒了,运行到wait方法那一行时就会停在那个位置并让出锁和CPU的控制权,而且无法被唤醒 ,程序就会一直停在那一行了。


现在我们回答一下刚才提出的那五个问题:

“lock”.wait(0);

1.  wait方法有什么用?

 “lock”.wait(0);会让执行这一行代码的线程进入等待状态,并释放持有的”lock”对象锁与CPU控制权。直到被其他获取到同一把锁的线程唤醒,才有获得锁的权力。wait的参数大于0时可以不用手动写notify/notifyAll唤醒,时间到了会被虚拟机自动唤醒,该参数表示等待的毫秒数。

2.  wait的参数为零时,线程还会醒过来吗?

当然可以了,notify/notifyAll可以唤醒获取同一把的线程,当这把对象锁本身是个线程对象时,那这个线程对象启动的线程运行完毕会自动唤醒等待它自己这把锁的其他线程,(其实就是调用notifyAll方法),这个我们一会儿再说。

3.wait方法调用的过程中会发生什么事呢?

调用wait方法后会让当前线程进入等待状态,并释放刚刚持有的“lock”对象锁与CPU控制权。就是说wait方法后边的代码在没有被唤醒前,永远不会执行了。线程一直在等着这把”lock”的锁。

4.wait方法是让谁等待?

让执行“wait方法的线程进入等待状态。

5.wait方法与sleep有啥不一样?

调用wait方法和调用sleep方法都会让线程进入等待状态,但是wait方法会释放锁,sleep方法却不会释放锁,wait方法是object的方法,sleep方法是Thread类的静态方法。

看起来wait方法的问题好像是解决了

join方法是咋执行的?

那我们接着看join的源代码,我在文章的开始提出了一个疑问,为什么要用while判断线程是否存活,而不是if。不是说wait方法执行后,只要不醒过来,就不会执行接下来的代码吗?只能说join方法写的太严谨了,while可以防止线程被意外唤醒,万一谁在程序里写了一个唤醒的代码呢?用if岂不是就结束了吗?

但是我发现join方法里并没有唤醒的代码,当我们调用join方法的时候更没有自己写唤醒的代码,那这个线程是谁唤醒的呢?

有点乱了,先捋一下,应该是这样的:

当我们执行join方法时应该是这样的,代码如下:

main方法代码:

public static void main(String[] args) {
		T2 t2 = new T2("t2");
		T1 t1 = new T1("t1",t2);
		t1.start();
		t2.start();
}

T1类的代码如下:

class T1 extends Thread {

	private String name;
	private T2 t2;

	@Override
	public void run() {
		int j = 0;
		while (j <= 5) {
			System.out.println(this.name + " is running and j = " + j);
			if (j++ == 2) {
				try {
					System.out.println("use t2.join() ");
					t2.join();
				} catch (InterruptedException e) {
					e.printStackTrace();
				}
			}
		}
	}
	public T1(String name, T2 t2) {
		this.name = name;
		this.t2 = t2;
	}
}

T2类的代码如下:

class T2 extends Thread {

	private String name;
	@Override
	public void run() {
		int j=0;
		while(true) {
			System.out.println(this.name + " is running and j = " + j);
			if(j++ == 5) {
				System.out.println("t2 game over");
				break;
			}
		}
	}
	public T2(String name) {
		this.name=name;
	}
}

运行结果,分为两种情况

1.t1线程先抢到时间片,先执行join方法



2.t2线程先抢到时间片并且运行结束



因为t1t2并没有争夺一把锁,在调用join方法之前t1t2是交替运行的。

1种情况t1线程在t2线程结束之前先执行join方法:

t1run方法中执行t2.join()的意思是t1线程获取到了t2线程的锁,并且执行t2.wait()方法,让当前线程放弃时间片和t2对象锁进入等待状态。这时候其他线程会得到运行的机会,虽然不一定是t2线程一定可以先运行,但是t1必须要等待t2线程运行结束之后才可以得到运行的机会。就是说t2线程结束之后会自动唤醒t1线程。那么这个自动唤醒是怎么实现的呢?(join方法是个同步方法会让当前线程获取this对象锁,因为是t2调用的join方法所以this就是t2

看了C++代码发现,当线程退出的时候会调用ensure_join(JavaThread* thread)方法,同时会调用notifyAll唤醒所有想要获取该线程对象锁的线程。它把这个唤醒任务交给了调用join方法的线程其中ensure_join(JavaThread* thread)方法代码如下:

static void ensure_join(JavaThread* thread) {
      Handle threadObj(thread, thread->threadObj());
      assert(threadObj.not_null(), "java thread object must exist");
      ObjectLocker lock(threadObj, thread);//获得该线程对象锁
      thread->clear_pending_exception();
      java_lang_Thread::set_stillborn(threadObj());
      java_lang_Thread::set_thread_status(threadObj(), java_lang_Thread::TERMINATED);//设置线程的状态
      java_lang_Thread::set_thread(threadObj(), NULL);
      lock.notify_all(thread);//唤醒等待该对象锁的所有线程
      thread->clear_pending_exception();
      }

2种情况t2线程在t1调用join方法之前运行结束了:

实际上,在t2.join()方法在t2结束之后或者启动之前执行都没有什么用,所以第二种情况没有起什么作用。


什么是原子操作

百度百科:所谓的原子操作是指不会被线程调度机制打断的操作,这种操作一旦开始,就一直运行到结束,中间不会有任何context switch(切换到另一个线程)。

我把它理解为一个不能打断的机器码指令。但是并不是指一行Java代码,比如i++;就不是一个原子操作,它会分三步运行的:

1.把变量i的值放入寄存器

2.把寄存器的值+1

3.把寄存器的值存入变量i

这三步任何一步都是一个原子操作,但是三步合起来就不是原子操作了,如果这个i是个共享变量,线程1执行完i++的第2步时候,线程2抢到了时间片执行第1步,就会发生覆盖。

例子;假如i的初始值为1

线程1执行第二步的时候寄存器的值+1,还没等存到i中,就被线程2i的值取到了,并执行+1操作存入i中此时i=2,这时候线程1抢到时间片把之前加完的寄存器的值放入i中,此时i=2,经过两次自增i应该等于3,但是我们看到i的值被线程1覆盖了。导致最终运算结果有误。

赋值操作一定是原子操作么?

基本数据类型的赋值除long,double外都是原子操作,引用数据类型赋值操作都是原子操作,因为longdouble8个字节,需要两次操作才可以赋值完成,所以不是原子操作,在多线程并发时有可能会出现不可预料的后果。引用数据类型的引用都是4个字节所以是原子操作,不过在多线程并发时候,我们并不考虑long,double是不是原子操作,冷知识,了解一下就好。

小结:

多线程中非原子操作共享变量时,想要把它变成原子操作,需要通过加锁来解决,加锁可以保证线程安全。

单例模式中的双重检测锁的问题

单例模式中如果考虑并发问题,就要加锁了,所以可以这样写:

public static class Single {
	private Single single;
	private Single() { }
	public Single getSingle() {
		synchronized("lock") {
			if(single == null) {
				single = new Single();
			}
		}
		return single;
	}
}

我发现这种方法太慢了,我们只是第一次创建对象需要进入同步块,但是这样就会让线程每次都要先获取一遍锁,效率太低了。然后我就改成了这样:

public class Single {
	private static Single single;
	private Single() { }
	public Single getSingle() {
		if(single == null){
			synchronized("lock") {
				if(single == null) {
					single = new Single();
				}
			}
		}
		return single;
	}
}

这看起来好像是解决了问题,但是并没有,这样写就构成双重检测锁了,程序在运行的时候并不是按顺序执行的,new这个操作也不是一个原子操作,是会分为三步执行的,经过重排序后可能就会出这种情况:

1.memory = allocate();//分配对象的内存空间

2.ctorInstance(memory);//初始化对象

3.single = memory;//single指向刚刚分配的内存地址

其中第二步和第三步没有依赖性,是可以颠倒的,那就会出现这种情况

1.memory = allocate();//分配对象的内存空间

2.single = memory;//single指向刚刚分配的内存地址

3.ctorInstance(memory);//初始化对象

可以看到执行到第二步的时候single就已经不为空了,但是实际上并没有初始化完成

就是说有可能会出现返回一个构造完成但还未初始化的对象,这样就不是单例模式了,new操作在执行到第二步时,single对象并不为null,如果这时候正好有一个线程执行到第一个if判断single不为空就会直接返回,就会返回一个不同的对象。

双重检测锁问题的解决办法,这里就不再阐述了,读者可以自行百度。

所以说,基础真的太重要了,这篇文章就先讲到这里吧。

总结

1.  多线程并发的时候,线程启动后进入就绪队列,只有抢到时间片才能运行。时间片到了其他线程会得到运行的机会。

2.  如果多个线程在争夺同一把锁,那么必须等待获得锁的线程释放锁,其他争夺这把锁的线程才能有进入就绪队列的机会,但是依然会和不争夺同一把锁的线程争夺时间片。

3.  t1.join()方法调用后会让当前线程获得t1的对象锁并进入等待状态,后续代码不会执行,同时会释放锁和CPU控制权让其他线程有执行的机会,只有调用join方法的线程对象运行完成后该线程才会被JVM自动唤醒。

4.  notify/notifyAll方法不会释放锁只会唤醒需要获取同一把锁的其他线程,并且只有一个线程可以抢到这把从而进入就绪队列。

5.  sleep方法是不会释放锁的

6.  i++不是一个原子操作,多线程并发会出现问题所以需要加锁。

7.  编译器会对没有依赖关系的几行指令进行重排序

8.  在写单例模式的时候应该避免双重检测锁的问题

9.  我大概是总结完了吧

10.未来会对这些内容进行补充,还请读者指出文中的不足之处,我会及时改正,谢谢

欢迎留言