其实Java多线程中,锁只是一个很抽象的概念。锁是为了实现互斥和同步而设的。“锁”打个比方,获取锁可认为是“获取做某个事情的权限”,而“释放锁”可以认为是把做某件事情的权限交给别人了。也可以这样认为,“锁”锁住的是某个事物。获取锁指的是获得解开这个锁的钥匙,可以对这个事情进行操作,而释放锁是把这条钥匙给别人,或者放回某个钥匙柜子里,等别人来取。Java中锁的机制,是为了在多线程中实现同步互斥。如果对于单线程的程序,怎么样都无所谓了,也不需要同步的方案。但是对于多线程,如果不熟悉同步互斥的编程,会很容易造成程序的错误。在Java中,上锁的机制主要有几个关键字volatile、synchronized、final以及juc包。
一、Java多线程
在Java中,记住是有个主线程的。这个主线程不需要我们自己创建,是一运行就有的了。而我们自己要创建的是我们自己的线程。这是理所当然的,如果没有主线程,那怎么开始我们的程序呢。在Java中,一般通过两种方式来创建多线程。一个是创建一个我们自己的类(后面都命名为MyThread),这个类要继承Thread类,同时覆盖run方法。实例化这个我们自己的类,就创建好一个线程了。另一个方法是创建一个我们自己的类,这个类要实现Runnable接口,同时覆盖run方法。
通过newThread(MyThread())来创建线程。这里要记住,创建线程时候,一个线程是以一个对象的形式呈现的。下面我们以售票员为例展示如何创建多个线程(售票员这个例子很经典。毕竟,每年我们都深受春运之苦。售票员演示了如何在票数一定的情况下,各个不同的窗口卖票的过程)。那如何让线程开始跑呢?很容易,调用对象的start方法即可。不能直接调用run方法,那不是让这个线程启动,而只是执行了一次run方法而已。
使用第一种方法:
执行的结果如下:
Thread1 sold a ticket. Remain ticket: 10
Thread1 sold a ticket. Remain ticket: 9
Thread1 sold a ticket. Remain ticket: 8
Thread1 sold a ticket. Remain ticket: 7
Thread2 sold a ticket. Remain ticket: 7
Thread1 sold a ticket. Remain ticket: 6
Thread2 sold a ticket. Remain ticket: 5
Thread2 sold a ticket. Remain ticket: 3
Thread2 sold a ticket. Remain ticket: 2
Thread2 sold a ticket. Remain ticket: 1
Thread2 sold a ticket. Remain ticket: 0Thread1 sold a ticket. Remain ticket: 4
使用第二种方法:
执行的结果如下:
Thread1 sold a ticket. Remain ticket: 10
Thread2 sold a ticket. Remain ticket: 10
Thread1 sold a ticket. Remain ticket: 9
Thread2 sold a ticket. Remain ticket: 8
Thread1 sold a ticket. Remain ticket: 7
Thread1 sold a ticket. Remain ticket: 5
Thread2 sold a ticket. Remain ticket: 6
Thread1 sold a ticket. Remain ticket: 4
Thread2 sold a ticket. Remain ticket: 3
Thread1 sold a ticket. Remain ticket: 2
Thread1 sold a ticket. Remain ticket: 0
Thread2 sold a ticket. Remain ticket: 1
很明显,这个结果是有问题的。跟我们预想的,一张张买票不一样!这是因为,在上面的例子中,完全没有采用同步的方法。这就导致时间片在两个线程直接切换的同时,操作不是原子的,导致线程之间共享的数据紊乱(各种指令重排序、时间片分配等原因)。因此,我们下面要采用一些同步的措施,改变这个现象。
二、synchronized关键字
在java中,每一个对象有且仅有一个同步锁。这也意味着,同步锁是依赖于对象而存在(也就是所谓实例锁。后面可以看到,其实还有可以对整个类上锁的全局锁)。而synchronized就是对象的一个同步锁。这个关键字的作用是,被这个关键字修饰的部分代码,称为互斥区。当某个线程在访问这段代码的时候,其他线程对这段代码的访问是互斥的,被称为临界区。通俗点讲,临界区就是某个对象的一个加了锁的一段代码。假设当前只有两个线程t1和t2。当线程t1在访问某个对象的这段代码的时候,如果没访问完,突然线程t1的时间片被操作系统剥夺了,时间片给了t2。t2也想访问某个对象的这段代码。这个时候,t2是不被允许访问这段代码的。t2就会被阻塞,然后时间片交还给t1,知道t1执行完这段代码了,t1才释放锁,t2才有权访问这段代码。这就很好地避免了临界区内代码被其他线程破坏的可能性。记住,这段临界区不一定在程序上看起来是连续的。一个对象,使用synchronized关键字,有且仅可以划定一个临界区。
synchronized关键字有三种使用方法:修饰代码段、修饰方法、修饰一个对象
1、修饰代码段和一个对象
修饰代码段的话,固定的格式是:
这里this表示这个对象(当然,如果想修饰一个对象,那就填进去一个对象),而这个代码段是临界区。注意,这个对象的非临界区依然是可以被非互斥访问的。
执行结果:
t1 sold a ticket, rest 9 tickets
t1 sold a ticket, rest 8 tickets
t1 sold a ticket, rest 7 tickets
t1 sold a ticket, rest 6 tickets
t1 sold a ticket, rest 5 tickets
t2 sold a ticket, rest 4 tickets
t2 sold a ticket, rest 3 tickets
t2 sold a ticket, rest 2 tickets
t2 sold a ticket, rest 1 tickets
t2 sold a ticket, rest 0 tickets
可以看出,两个线程,分别卖出5张票。
2、修饰方法
直接在方法前加上关键字即可
3、修饰静态static方法,就是给类加锁(略)。
我们知道了锁的机制后,回到原来的话题,如何写一个让线程t1,t2一起卖票,知道卖完的程序呢?我们可以给run方法内判断加锁,这样一个判断就是一个临界区。
执行结果:
...
t1 sold a ticket, rest 50 tickets
t1 sold a ticket, rest 49 tickets
t1 sold a ticket, rest 48 tickets...
关于synchronized关键字暂时说到这里。要注意,这里获取锁和释放锁这两个抽象概念,要在代码中理解。获取锁,也就是进入互斥区。释放锁,也就是执行完了临界区的代码。同时,此时我们也应该对一个概念了解了:对象监视器。从上面例子可以看到当涉及实例锁的时候,synchronized关键字都是对一个对象上锁的。对于Java线程来说,一个线程,当然是要基于一个runnable对象的。而这个对象,我们可以认为是这个线程的对象监视器,这个线程是在这个对象上执行的。如果synchronized是对不同的对象上锁的,当然是不同的锁了。另外,synchronized关键字是无法被继承的。synchronized对象锁锁住的是对象,但是起作用的只是对象中的临界区。
在此我向大家推荐一个架构学习交流群。交流学习群号:478030634 里面会分享一些资深架构师录制的视频录像:有Spring,MyBatis,Netty源码分析,高并发、高性能、分布式、微服务架构的原理,JVM性能优化、分布式架构等这些成为架构师必备的知识体系。还能领取免费的学习资源,目前受益良多
三、wait,notify,notifyAll方法
1、为了后面继续了解Java多线程中同步的知识,我们必须了解更多关于线程的知识。我们首先来了解一下线程的状态。在Java中,线程的状态不多,总共有5种状态。这个状态转移图很重要,我们后面对线程操作的各种分析,都是基于这个状态转移图的。
新建(new):当线程对象被创建new后,就进入了新建状态;
就绪状态(runnable):当线程调用了start方法后,就进入了就绪状态,也叫可运行状态。此时,线程可以获得CPU,但线程不一定就立刻被分配CPU时间片!明白这点很重要!
运行状态(running):被分配了CPU时间,正在执行。
阻塞状态(blocked):阻塞状态是线程因为某种原因放弃CPU使用权,暂时停止运行。阻塞态有很多种,我们后面再详细分析。
死亡状态(Dead):线程执行完了或者因异常退出了run()方法,该线程结束生命周期。
我们下面来看一个例子,我们来更进一步说明线程之间的关系。
我们会发现,执行结果里,有一次是:
...
current thread is main 325
current thread is main 326
current thread is newthread 0
current thread is main 327
current thread is main 328
current thread is main 329...
这说明,主线程执行了thread.start()语句后,CPU的时间片并没有立刻交给newthread线程,而是继续在主线程中运行。而运行到326后,才有某个时间片交给了newthread线程,然后又交还给了主线程。线程调度,是由操作系统内核来完成的,我们无从得知操作系统会怎么调度线程。所以我们在编程中要特别注意同步。另外,线程也是有层次关系的。如果一个线程t1,创建了另一个线程t2,那t1就是t2的父线程。这点在后面讲yield()方法时候要知道。
这个时候,我们来看wait,notify,notifyAll方法。这几个方法是定义在Object类中的,是Object类的final native方法。所以这些方法不能被子类重写。要记住,对这些方法来说,他们针对作用的对象,是线程,但是却是由某个Object类调用的(其实就是随便一个Object。由于Object是所有类的父类,所以就是随便一个对象调用)。假如一个进程t,调用了某个对象的这些方法。此时这个对象就是这个进程的对象监视器。这些方法,就作用于这个对象监视器监视的进程t。
我们先来看wait()方法。wait()的作用是让当前调用这个方法的线程进入等待状态,同时,wait()也会让当前线程释放它所持有的锁,直到它被其他线程通过notify()或者notifyAll()唤醒。该方法只能在同步方法或同步代码段中调用。如果当前线程不是锁的持有者,该方法抛出一个IllegalMonitorStateException异常。另外,还有void wait(long millis)和void wait(long millis, int nanos),他们导致线程进入等待状态直到它被通知或者经过指定的时间。这些方法只能在同步方法或同步代码段中调用。如果当前线程不是锁的持有者,该方法抛出一个IllegalMonitorStateException异常。
notify()随机选择一个在该对象上调用wait方法的线程,解除其阻塞状态。该方法只能在同步方法或同步块内部调用。如果当前线程不是锁的持有者,该方法抛出一个IllegalMonitorStateException异常。而notifyAll()解除所有那些在该对象上调用wait方法的线程的阻塞状态。该方法只能在同步方法或同步块内部调用。如果当前线程不是锁的持有者,该方法抛出一个IllegalMonitorStateException异常。
从这里看出,其实这几个方法的用处是强行用人工剥夺某个线程的锁,把这把锁交给另外一些线程。我们举个例子。
运行结果:
current thread is main
current thread is main 1
current thread is main 2
current thread is newthread 1
current thread is newthread 2
current thread is newthread 3
current thread is newthread 4
current thread is newthread 5
current thread is main 3
current thread is main 4current thread is main 5
这里我们也就知道了,为什么这几个函数要定义在Object中了。因为Object中的wait(), notify()等函数,和synchronized一样,会对“对象的同步锁”进行操作。而对象的同步锁有且仅有一个。所以这样的操作直观而又简便。notify()等是依旧对象的同步锁来唤醒线程的。假设,t1唤醒了t2,但是此时仍旧是t1在持有该对象监视器的锁。t2虽然已经被唤醒,但是并不一定能立刻执行。必须等t1释放了锁后,t2才会执行。