1. 多线程概念
以前使用c或者java编程的时候,所有程序都是执行在一个进程里,那时候无论多长的代码,虽然会分成多个public类,但最终还是在main函数里一个接一个的new,最后main就成了整个进程的开始,而且从头到尾都是main在执行,所以也就没有线程的概念。
但是计算机发展到现在,从当初的一核到现在的多核CPU,比如真正4核心CPU,同时能运行4个线程,如果你把一个进程只分做一个线程执行,那么其他3个核无疑是空置的得不到使用。举个例子:
public static void main(String[] s){
int a=0;
int b = 0;
int c = 0;
int d = 0;
while(a<1){ //用时1ms
a++;
}
while(b<10){ //用时10ms
b++;
}
while(c<100){ //用时1000ms
c++;
}
while(d<1000){ //用时1000ms
d++;
}
System.out.println(a);
System.out.println(b);
System.out.println(c);
System.out.println(d);
}
上面所有代码在一个线程内执行,总用时2011毫秒,有三个CPU核心是用不上的,就算把四个核心轮流使用,每个时间段也只能用其中一个,这样就大大浪费了CPU资源。假如在第一个while循环里再加入一个键盘输入操作,输入时间为999毫秒,那么后面的while只能等待,这样使得程序变得“卡顿”。
如果把每个while循环都放在创建的一个线程里执行,尽管第一个while也用了1000ms,但是由于并行执行最后也控制在1000ms内而且不用等待io操作。这就是多线程的好处,充分利用资源,减少等待时间。
事实上多线程的优势并不主要依赖于多核并行运行,多线程只是想让程序不用按照死板的特定顺序执行,前面一卡住后面就完蛋,或者后面要执行还要等前面的先执行完即使后面的程序跟前面的没任何关系。为了实现这个,不管硬件上是否多核能否并行,都在操作系统层面把CPU变成并发使用,实现的原理是时间片轮转,比如有四个线程,让他们轮流获得1ms的时间片来执行,那么在1000ms内每个线程都运行了250ms,由于时间片很小而且轮流执行,宏观上看来就像是四个线程同时在执行,这样也就实现了多线程的并发,虽然都是在一个CPU上执行。
但是比如第一个while循环到等待键盘输入的时候,如果还分给它时间片那就浪费CPU了,所以我们开始给线程设置一些状态(标志),比如在等待键盘输入的线程,就给他设置为阻塞状态,那些正在运行的线程设置为运行状态,剩下的等待中的线程都设置为可运行状态,然后把它们放到可运行队列里头等待获得CPU的使用权。这样,当有线程遇到比如键盘输入时就让出CPU,把自己标志位阻塞,等到输入完毕后再从阻塞状态变为可运行状态并且加入到可运行队列里等待获得CPU的使用,这就是多线程并发的一种机制。
CPU空闲时就会调用下一个线程,这里就要提到CPU调度策略了。可能最容易想到的是根据可运行队列的顺序进行调度使用CPU,但是这样并非最好的调度策略,因为有些线程运行时间长,而且比其他线程重要,不能平均分配CPU占有时间。因此各种调度算法就出来了。其中,抢占式调度是最常用的算法,我的理解是,给每个线程设置一个优先级,在CPU需要调度下一个线程的时候选择优先级最高的。但是优先级不是设置好后就不变的,比如这个线程等待的时间很长了,那么给它提高相应的优先级让它能够获得CPU使用权,又比如有个线程只运行1ms那么给它提高优先级让它能够先运行而运行时间长的放后点。所以,线程是根据各种因素来确定谁先执行的算法就是调度算法。
补充一点是,Java虚拟机的多线程应该有自己的一套东西,因为虚拟机也不过是一个应用,而java程序运行在这个应用上。jvm是没有直接获得计算机硬件的直接调配权的,它应该只是在jvm里模拟出CPU、寄存器、内存等,所以它的调度算法可能也不依赖于操作系统本身。
2. 多线程的存在方式
有一次我在使用Timer类的时候,我让程序每隔10s执行一次,代码类似这样:
public static void main(String[] s) {
Timer timer = new Timer();
timer.scheduleAtFixedRate(new TimerTask() {
@Override
public void run() {
try {
Thread.sleep(1000 * 10);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}, 0, 1000*10);
System.out.println("main语句执行了");
}
我以为timer的Thread.sleep(1000 * 10);会让main等待10s然后再输出,可是实际上timer是新建一个线程t,然后执行线程t的run方法,这个方法跟线程main是并发的,所以sleep当前线程并不能阻止main线程的执行。因此,我以为没有用到多线程的程序里被一个API带入了多线程,从而导致了实际结果跟我预测的结果不一样。
既然拜托不了,既然多线程能带来那么多的优势(同时也要避免一些问题),那么好好的利用多线程势在必行。
让我们来执行下面的代码:
public class TT {
public static void main(String[] s) {
Map<Thread, StackTraceElement[]> TS = Thread.getAllStackTraces();
for (Thread t : TS.keySet()) {
System.out.println(t.getThreadGroup() + " , " + t.getName());
}
}
}
输出所有活着的线程和它所属的线程组:
java.lang.ThreadGroup[name=system,maxpri=10] , Reference Handler
java.lang.ThreadGroup[name=system,maxpri=10] , Signal Dispatcher
java.lang.ThreadGroup[name=system,maxpri=10] , Finalizer
java.lang.ThreadGroup[name=system,maxpri=10] , Signal Dispatcher
java.lang.ThreadGroup[name=main,maxpri=10] , main
可以看到,当前活动的线程有5个,其中除了main线程属于main线程组,其他的都属于system线程组。
每个线程都属于一个线程组,线程组又会有自己的父线程组,从而多个线程和多个线程组混合形成一颗树,在Java里线程组的根是System线程组,一般包括有Reference Handler(处理引用,或者把没有引用的对象变成finalizer),Finalizer(应该是处理那些finalizer,也就是GC的预处理吧),Signal Dispatcher和Signal Dispatcher应该是响应和处理请求,然后还遇到一个DestroyJavaVM线程,是在main线程执行完毕之后(也就是main方法中的语句都执行过了),main线程被销毁的同时激活这个线程,它负责销毁jvm,但是如果还有其它通过main线程衍生出来的线程没有执行完毕的话,就等待所以这些衍生的子孙线程执行完毕之后启动DestroyJavaVM线程的销毁方法。
Thread.getAllStackTraces()是一个静态方法,返回所有活动线程的线程键和线程对应的堆栈值数组,如果只是需要所以活动线程,在ThreadGroup类里有这样一个方法API:
enumerate
public int enumerate(Thread[] list)把此线程组及其子组中的所有活动线程复制到指定数组中。
首先,不使用任何参数调用此线程组的 checkAccess 方法;这可能导致一个安全性异常。
应用程序可以使用 activeCount 方法获取数组大小的估计数,但是,如果数组太小而无法保持所有线程,则忽略额外的线程。如果获得此线程组及其子组中的所有活动线程非常重要,则调用方应该验证返回的 int 值是否严格小于 list 的长度。
由于使用此方法所固有的竞争条件,建议只将此方法用于信息目的。
参数:
list - 放置线程列表的数组。
返回:
放入数组中的线程数。
抛出:
SecurityException - 如果不允许当前线程枚举此线程组。
然后只要找到根线程组也就是system ThreadGroup来调用此方法一样能得到所有的线程。
创建线程组会要选择一个父线程组,如果没选,则默认是main线程组,同样的,创建一个线程,如果没有指定线程组,则默认是main线程组,这个可以自己测试下。把线程分组的好处是权限和资源共享的限制等等。
然后通过Thread的构造方法New出自己想要的线程,并重写run方法在方法体写上想要执行的内容。
3. 线程安全和同步
如果没有多线程,就不需要考虑线程安全问题。
来看一个例子:
public class TT {
public static int i = 5;
public static void main(String[] s) {
while(TT.i<10){
System.out.println(TT.i++);
}
while(TT.i>0){
System.out.println(TT.i--);
}
}
}
在一个线程内,和预期一样:
5
6
7
8
9
-----------
10
9
8
7
6
5
4
3
2
1
然后改成多线程代码如下:
public class TT {
public static int i = 5;
public static void main(String[] s) {
Thread t1 = new T1();
Thread t2 = new T2();
t1.start();
t2.start();
}
}
class T1 extends Thread{
public void run(){
while(TT.i<10){
System.out.println("T1 : "+TT.i++);
}
}
}
class T2 extends Thread{
public void run(){
while(TT.i>0){
System.out.println("T2 : "+TT.i--);
}
}
}
结果
T2 : 6
T1 : 5
T2 : 5
T1 : 4
T2 : 5
T1 : 4
T2 : 5
T1 : 4
T2 : 5
T1 : 4
T2 : 5
T1 : 4
........
线程t1还没到10,t2就同时对i进行修改,破坏了读写的一致性,所以,如果有一个共享资源(数据、对象)同时被多个线程访问、修改,这样保证不了结果就是你要的结果。
在把整个进程(应用)分成多个线程来执行的过程中,有些数据资源是单独被一个线程使用从而不存在线程安全问题,但是有一些可能同时被两个以上线程操作,这样就会有安全问题。所以线程的安全就是保证这些临界资源使用的一致性,保证线程操作临界资源的原子性。最常见的是集合Collection,当一个线程在遍历而另一个线程同时在修改(增加或者删除元素)可能抛出遍历越界异常。
因此需要把临界资源的并行访问变成串行化访问。Java使用了对象锁机制来实现。就是每个对象都有且仅有一把锁(synchronized关键字),如果一个线程获得了某个对象的锁,那么其他所有线程将不能获得这个对象锁需要监听锁的释放,这样一来拥有对象锁的线程就能完整的执行完一个步骤。但是如果其他线程里没有对这个对象也加上synchronized关键字,也就是说它访问这个对象不需要锁,即使这个对象的锁被一个线程拥有,它还是可以直接修改,因为对象锁只是限制所有需要锁才能访问的那些线程,而没有注明需要锁才能访问的线程就不需要获得锁直接访问,所以给synchronized(obj)关键字要用在需要同步的所有线程里而不只是一个线程里,让所有线程都需要锁,然后一把锁就不能并发使用了。
获得锁和释放锁需要消耗资源,不是所有的对象都需要同步,在需要的对象上同步即可。而且等待锁也需要时间,因为锁住的是一块区域,比如这样:
synchronized (obj) {
语句1使用obj;
语句2...;
语句3...;
....
}
虽然只有语句1使用obj需要加锁,但是释放锁是要在执行完包住的整块代码之后的,所以如果上锁的区域过大,就出现持有锁时间的浪费而其他线程又得不到锁。
最简单的死锁理解是,两个线程都各拥有一个对象的锁,但是又需要对方的对象的锁才能执行完自己锁住区域的代码然后释放锁,那这样谁都释放不了锁,谁也获得不了对方的锁。比如:
线程t1代码:
synchronized (A) {
synchronized (B) {
System.out.println("执行我就释放对象A的锁");
}
}
线程t2代码:
synchronized (B) {
synchronized (A) {
System.out.println("执行我就释放对象B的锁");
}
}
两个线程同时获得第一个锁,然后同时等待对方的锁,死锁了,但是说到底线程并发还是有先后顺序的,可能t1被调度10遍然后才到t2被调度,只要不是在获得A锁和B锁的空隙遇到重新调度线程,都不会出现死锁。
写一个保证能死锁的:
t1代码:
synchronized (A) {
A.notify();
synchronized (B) {
B.wait();
}
}
t2代码:
synchronized (B) {
B.notify();
synchronized (A) {
A.wait();
}
}
wait()释放此对象的锁并在此对象上等待被唤醒(notify),肯定先有一个线程获得锁,但是最终都要释放第二层锁等待,然后第二个线程必定能够进来并且唤醒第一个线程,这样两个线程必定都有一个锁,但是必定都在等待下一个锁,也就是必定死锁。要避免死锁。
线程也是对象,不同的是它有自己的区域,能够占有CPU。