其实这个我是打算写一系列的。毕竟并发编程这块内容很多。
我看了《java并发编程实践》也看了《精通java并发编程》,甚至还在b站看了《马士兵老师java多线程高并发编程》的全部视频(也不过七个多小时)。但是只能说资料看的越多越觉得高并发是一种很复杂的东西。甚至好多很出名的开源框架都会有一些隐藏的bug。就是因为并发会让很多无法想象的问题。
好了,闲话少叙,我是打算写到哪里算哪里。正好最近因为疫情原因在家里待着,有大把的时间可以整理。
另外我之前看了《深入理解jvm虚拟机》这本书的时候也有做了笔记,但是现在看了觉得又傻又教条。差不多就是把书本的内容照着敲了下来。也有一方面是因为jvm的源码太远了。反正这次并发这块我是打算代码和实际结合的方式来尽量用自己的话写。如果稍微措辞不准备也麻烦大家指出,我会查证修改。
线程
线程是什么?
并发中线程是个基本知识。稍微有点java基础的应该也能理解。但是怎么准确的描述呢?
我先附上百度上的说法:
是操作系统能够进行运算调度的最小单位。它被包含在进程之中,是进程中的实际运作单位。一条线程指的是进程中一个单一顺序的控制流,一个进程中可以并发多个线程,每条线程并行执行不同的任务。
然后我看到的一种我觉得说的比较明了的说法:
线程就是一个程序里不同的执行路径可以放在不同cpu中同步运行。
如何启动一个线程呢?
java中启动一个线程的方法常用的有两种:
- 继承Thread类。
- 实现Runnable接口。
其实无论是继承类还是实现接口,都是要实现一个叫做run的方法。 这个方法就是线程要执行的代码块。而且很有意思,Thread就是继承了Runnable接口的。以下是两个类的源码片段。


如图也可以看出来,Runnable接口只有一个run方法,简单便捷。但是Thread是写了好多方法的,例如sleep等。统共这个类两千多行。
而启动一个线程就是new一个线程的实例,然后 对象.start();
我用代码来演示两种方法:
package thread;
public class Demo {
public static void main(String[] args) {
Thread t1 = new Thread(new MyThread1());
Thread t2 = new MyThread2();
t2.start();
t1.start();
}
}
class MyThread1 implements Runnable{
@Override
public void run() {
System.out.println("I am T1");
}
}
class MyThread2 extends Thread{
@Override
public void run() {
System.out.println("I am T2");
}
}
如上代码,我用两种方式分别创建了两个线程。并同时start启动。
(ps:对于新手小白来讲可能不是很理解线程到底是什么。反正我当时是很懵逼的。我只能说相对于一个main方法的顺序执行。线程的运行是并行的。就是这个t2的启动和t1的启动是同时进行的。所以输出结果可能是1在上面,也可能是2在上面。这一个小小的运行区别可能让我们对线程有更明显和深刻的理解。)


因为我的代码只是单纯的输出了一句话,所以运行五次有四次都是2在上面,因为我先启动的2.如果没出现两种情况都存在的结果就多运行几次。实在不行还可以睡一会儿啥的。反正这个启动就到这。
基本的线程同步
synchronized
-
synchronized是一个关键字,可以用来修饰方法或者代码块。加锁的意思。
-
synchronized也叫做互斥锁,因为当一个线程拿到了锁其余的所有线程都拿不到了(通常情况下)。
-
synchronized锁的是一个对象(这个对象可以被new出来,啥也不干只当锁),获取这个对象才能继续往下执行synchronized锁包围的代码块。所以标准意义上synchronized锁定的是对象而不是代码块。
(ps:synchronized可以锁定代码块是错误的说法) -
如果一个方法从开始到结束都被synchronized锁包围,我们可以直接将这个方法设置为synchronized。等同于 synchronized(this){}(附上代码截图)
两者是等价的
-
synchronized如果锁在静态方法中,因为静态方法没有引用this的存在,所以相当于锁定T.class对象(T.class是java.lang.Class中的一个实例对象)。
如图,静态方法中没有this引用
静态方法的默认锁是T.class
-
一个synchronized是一个原子操作,不可分割,可防止线程重入等问题。
-
在一个synchronized方法中,非synchronized方法是可以正常执行的。因为这个时候是不需要在意锁的。(通俗来说,比如上厕所,隔间带锁,有人进去了,并将门锁上了。如果有别人想进这个隔间是不行的,这个就是防止重入。但是如果有别人想进这个厕所水池洗下手是完全没有影响的。)
银行Demo:
首先我们都知道银行中账户对应户主和余额。几乎大多数人都会有共识,这个账户的余额写入一定要加锁。不然会出现各种数据错误。但是是否觉得读就不用加锁呢?
下面的代码是一个简单的demo:
package thread;
public class Account {
String name;
Double money;
public Account(String name, Double money) {
super();
this.name = name;
this.money = money;
}
public Double getMoney() {
System.out.println(money);
return money;
}
public synchronized void setMoney(Double money) {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
this.money = money;
}
@Override
public String toString() {
return "Account [name=" + name + ", money=" + money + "]";
}
public static void main(String[] args) {
Account account = new Account("lisa",100.0);
new Thread(()->account.setMoney(1.1),"t1").start();
new Thread(()->account.getMoney(),"t2").start();
}
}
如上代码,这里如果是正常的业务逻辑,是先更改了余额,然后再查余额的时候是要显示更改后 余额。
但是这里第一个线程是改余额的,然后第二个线程查询余额应该是得到更改后的余额。但是以上面的代码,则查询出来的还是第一次的余额。
当然了,主要原因是我在set中睡了1ms。如果不睡这么一秒因为代码的简单,很难出现这个错误。运行几十次才有那么一次我还没截图。。所以还是睡了比较容易看出错误。
所以说这个demo一方面证明了以上第七条规则。另一方面告诉我们只锁写不锁读也会使程序出错。这种错就是在写的过程中读数据,则会使读的结果不准确,读出了已经废弃的数据。这个叫做“脏读”。
(ps:脏读产生的原因就是只对写加锁不对读加锁。)
解决脏读最无脑的解决办法:读写都加锁。
但是因为这个synchronized是重量锁。都加锁了程序运行没问题,但是性能就有问题了。至于怎么解决这个其实方法很多,java中的锁也不仅仅synchronized,以后有机会再讲怎么实现。
- 一个同步方法可以调用另一个同步方法,也就是说synchronized获得的锁是可重入的。还有子类调用父类对象也是可以重入的。(下面是代码demo)
package thread;
public class Demo2 {
public synchronized void test() {
System.out.println("I am Father");
}
public static void main(String[] args) {
Son son = new Son();
son.test();
}
}
class Son extends Demo2{
public synchronized void test() {
System.out.println("I am Son");
super.test();
System.out.println("method end");
}
}

如上代码,父方法有锁,子方法也有锁。我们在子类方法中调用了父类的test。然后正常进入并且执行完毕。说明了父类的锁子类是可重入的。
-
synchronized默认遇到异常释放锁。
程序执行过程中,如果出现异常,默认情况下synchronized锁会释放。所以在并发处理过程中有异常要多加小心。不然可能会发生不一致的情况。
比如在一个web app处理过程中多个servlet线程共同访问同一个资源,这时候如果异常处理不合适,在第一个线程中抛出异常。其他线程就会进入到同步代码区,有可能会访问到异常产生时的数据。因此要非常小心的处理同步业务逻辑中的异常。 - synchronized锁的是对象,如果锁的对象发生改变则锁会改变(锁的是堆中 的对象,而不是单纯的引用)。java的锁只能锁堆中的对象而不能锁栈。这句话比较抽象,我用一个demo说明这种情况。
package thread;
public class Demo2 {
Object object= new Object();
public synchronized void test() {
System.out.println("I am Father");
}
public void test1() {
synchronized (object) {
System.out.println(" test1 start...");
try {
Thread.sleep(5000);
} catch (InterruptedException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
System.out.println(" test1 end...");
}
}
public static void main(String[] args) {
Demo2 demo2 = new Demo2();
Thread t1 = new Thread(()->demo2.test1(),"t1");
t1.start();
Thread t2 = new Thread(()->demo2.test1(),"t2");
demo2.object = new Object();
t2.start();
}
}
如上代码,正常来讲因为test1方法是synchronized的,所以t1.t2应该是顺序执行的,一个end另一个才会开始。但是因为中途object对象改变了,所以改变锁了,导致t1.t2两个线程同时进到test1方法中了。我直接上运行结果的截图。(我为了让效果明显,这个方法中间睡了五m,所以得到的结果很明显。)


- 尽量不要用常量作为synchronized的锁。(因为这个锁的是对象,常量很容易撞车。我做个简单的demo)
package thread;
public class Demo1 {
String s1 = "Hello";
String s2 = "Hello";
public void test() {
synchronized (s1) {
System.out.println("I am lock");
while(true) {
}
}
}
public void test1() {
synchronized (s2) {
System.out.println("I am lock too");
}
}
public static void main(String[] args) {
Demo1 demo1 = new Demo1();
new Thread(()->demo1.test(),"t1").start();
new Thread(()->demo1.test1(),"t2").start();
}
}
如上代码,明明锁的一个是s1.一个是s2.但是显而易见,这两个方法是不能同时进行的。所以说锁的是对象。

关于synchronized暂时就整理这十一点结论(是我目前整理在笔记上的,可能说的不全,如果以后还有涉及到没写的会续写)。本篇笔记如果稍微帮到你了记得点个喜欢点个关注。另外我现在也是正在学习整理多线程这一块,有什么问题可以留言或者私聊共同讨论,有好的教材也欢迎推荐。顺便祝大家周末愉快!
网友评论