进程有两个状态就绪状态和阻塞状态。
这些状态决定了系统会按照什么样的态度来调度这个进程(这些一般是针对一个进程里面有一个线程的情况)。在实际的大多数情况下,一个进程中包含多个线程,其状态则会绑定在线程上。
上诉状态一般是在应用于系统层面上线程的状态(PCB)。我们可以通过Thread里面的方法t.getState()读取到进程t现在的状态。
在java中Thread类中提供了更为细分的状态情况。
NEW:表示创建好了线程,但是还没有调用start方法。
TERMINATED:表示线程执行完了,但是Thread对象还在。
使用join()方法来暂停mian()线程来得到等待t线程执行完了之后的线程状态。
RUNNABLE:就绪状态处于这个状态的线程,在就绪队列中,随时可以被调度到CPU上。(当代码中如果没有sleep,也没有其他可能造成的阻塞的操作,一般线程大概率处在这个状态)
TIMED_WAITING:表示线程处于阻塞状态
线程先在就绪状态等待要执行,然后遇到sleep要休眠处于阻塞状态.处于阻塞时期的线程t时,线程Main已经执行完了。之后在访问线程t状态时就发现因为sleep导致线程t处于阻塞状态。
线程状态转化简图
其中WAITING和BLOCKED还没有涉及到。
线程的安全问题
在多线程中是最重要,最复杂的的问题。操作系统中,调度线程的时候是随机(抢占式执行)
由于这样的执行策略,导致程序很容易出现一些bug。因为这样的调度随机性的引入bug,称代码不安全。反之如果这样的调度没有带了bug,则称之为安全的。
比如设置一个整形变量让两个线程对其进行从零开始的5000自增操作,按逻辑来说应该最后应该变为10000.
最终的输出结果是小于10000,countd的自增内部发生了什么导致误差是八分之一左右这么大。
从计算的底层CPU角度来看,count++实际上是三个CPU的指令分别是
把内存中的count的值,加载到CPU寄存器中.load(加载)
把寄存器中的值,给+1add(增加)
.把寄存器的值写回到内存的count中.save(保存)
在多个线程执行这个操作时由于线程之间会发生抢占式执行,导致线程在同时执行这三个命令时,在执行顺序上会发生随机性。
本来应该先是t1执行完这三个指令,然后t2在执行这三个指令,或者t2先执行,t1后执行。由于随机性导致在t1执行时或者t2执行时,t1执行着t2突然抢占了CPU开始执行导致其运算逻辑发生较大的改变。如下图所示抢占式运行
这样的抢占式执行导致本来应该count自增两次最后结果却自增一次,这个情况就是产生bug 的根源.也就导致了线程不安全问题。
至于上述代码的最终结果是在8749,是因为在极端情况下全发生向上述所说的自增一次的情况下是5000,若在另外一种全部运行正常不发生bug的情况下结果是10000,这两种的概论一般来说都很小所以最后结果一般会处于两者之间。
那如何解决上述问题?
加锁
通过使用加锁限制进程在运行时会一直占用资源不会被其他线程所抢占,保证了其线程的安全性。
我们就可以在如上面的情况之中在自增之前,开始加锁,自增结束之后开始解锁。也就是将多线程的并发执行变为了串行执行,减小了运行的速率增加了线程的安全性。
加锁有多种方式,日常经常使用的是synchronized关键字来进行加锁。在给方法加锁之后,进入方法执行时会给线程自动加锁待执行完成,会自动解锁。并且当一个进程进行加锁成功之后,其他线程也进行加锁会触发阻塞等待线程的状态就是BLOCKED,并且阻塞会一直等待直到另外一个线程开始解锁。
导致线程不安全的原因
线程的抢占式执行,线程之间的调度随机。
多个线程对同一变量进行修改操作
针对变量的操作不是原子的,比如读取变量的值,只是对应一条机器指令,此时这样的操作本身就可以视为是原子的,通过加锁操作将好几个指令给打包成一个原子的了。
内存可见性:有时候针对同一个变量,一个线程进行读操作(循环进行很多次),一个线程进行修改操作(合适的时候执行一次)。假设读操作是t1,修改操作是t2,则t1这个线程会循环读这个变量(读取内存操作,相比于读取寄存器,是一个非常低效的操作!!!(慢3-4个数量级)),因此在t1中频繁的读取这里的内存的值,就会非常低效。而且如果t2线程迟迟不修改, t1线程读到的值又始终是一样的值。因此, t1就有了一个大胆的想法,不再从内存读数据了,而是直接从寄存器里读~~(不执行load 了)。一旦t1做出了这种操作,此时万一t2修改了count值, t1就不能感知到了。这是java编译器优化导致的结果。主流编译器是由各种各样顶尖的人进行实现的,里面会由多种的优化方式,这里面会有各种的优化,当编译器中的代码处于哪种情况时,就会产生这种类似的优化,这种优化会大大提高执行效率不改变内在逻辑。大多数情况都是保证不会出现差错,但在多线程中可能会发生差错。
使用synchronized关键字.
synchronized不光能保证指令的原子性,同时也能保证内存可见性。被synchronized包裹起来的代码,编译器就不敢轻易的做出上述假设,相当于当于手动禁用了编译器的优化。
使用volatile关键字
volatile和原子性无关,但是能够保证内存可见性.
禁止编译器做出上述优化.编译器每次执行判定相等,都会重新从内存读取 isQuit的值!!
按照上述所说线程t不停的读isQuit的值,并且如果mian线程长时间不改的话系统自动会优化使其不更新的读这个值,不会改变。
从输出结果中我们可以看出isQuit的值一改变,t线程里面的循环就被打破,证明了使用volatile是可以使数据一直更新的读取不会被系统默认的进行优化,进行不更新的读操作。
指令重排序
指令重排序,也会影响到线程安全问题
指令重排序,也是编译器优化中的一种操作
日常写的很多代码,在前在后的顺序无所谓不影响程序的正常运行但是在前在后的执行效率是不一样的,所以编译器就会智能的调整这里代码的前后顺序从而提高程序的效率,保证逻辑不变的前提,再去调整顺序
如果代码是单线程的程序,编译器的判定一般都是很准
但是如果代码是多线程的,编译器也可能产生误判。
synchronized关键字
其不止能保证原子性,同时还能保证内存可见性,同时还能禁止指令重排序。
直接修饰普通的方法
在线程中使用synchronized关键字修饰的方法时会自动对本身进行加锁,因为其会自动调用this方法来指向自己。
修饰代码块
需要显式指定针为哪个对象加锁. (Java 中的任意对象都可以作为锁对象)
修饰一个静态方法
针对当前类的类对象进行加锁,也就是当对其进行使用时会给这个类的所有实例化对象进行加锁。