概念
什么是管程
管程(Monitor,直译是”监视器“的意思)是一种操作系统中的同步机制,它的引入是为了解决多线程或多进程环境下的并发控制问题。
翻译为 Java 领域的语言,就是管理类的成员变量和成员方法,让这个类支持并发访问,是线程安全的。
参考: https://www.cnblogs.com/xidongyu/p/10891303.html
临界区
临界区 Critical Section
- 一个程序运行多个线程本身是没有问题的
- 问题出在多个线程访问共享资源
1、多个线程读共享资源其实也没有问题
2、在多个线程对共享资源读写操作时发生指令交错,就会出现问题 - 一段代码块内如果存在对共享资源的多线程读写操作,称这段代码块为临界区
竞态条件 Race Condition
多个线程在临界区内执行,由于代码的执行序列不同而导致结果无法预测,称之为发生了竞态条件
解决方案
为了避免临界区的竞态条件发生,有多种手段可以达到目的。
- 阻塞式的解决方案:synchronized,Lock
- 非阻塞式的解决方案:原子变量
本次课使用阻塞式的解决方案:synchronized,来解决上述问题,即俗称的【对象锁】,它采用互斥的方式让同一
时刻至多只有一个线程能持有【对象锁】,其它线程再想获取这个【对象锁】时就会阻塞住。这样就能保证拥有锁
的线程可以安全的执行临界区内的代码,不用担心线程上下文切换
注意
虽然 java 中互斥和同步都可以采用 synchronized 关键字来完成,但它们还是有区别的:
- 互斥是保证临界区的竞态条件发生,同一时刻只能有一个线程执行临界区代码
- 同步是由于线程执行的先后、顺序不同、需要一个线程等待其它线程运行到某个点
synchronized
语法
synchronized(对象) // 线程1, 线程2(blocked)
{
临界区
}
@Slf4j
public class SynchronizedTest1 {
static int count = 0;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int i = 0; i < 5000; i++) {
synchronized (SynchronizedTest1.class){
count++;
}
}
}, "t1");
Thread t2 = new Thread(() -> {
for (int i = 0; i < 5000; i++) {
synchronized (SynchronizedTest1.class){
count--;
}
}
}, "t2");
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("count = " + count);
}
}
思考
synchronized
实际是用对象锁保证了临界区内代码的原子性,临界区内的代码对外是不可分割的,不会被线程切
换所打断。
为了加深理解,请思考下面的问题
- 如果把 synchronized(obj) 放在 for 循环的外面,如何理解?-- 原子性
答: 个人认为是锁住了整个循环,第二个循环只能等第一个循环执行完才能执行,执行顺序上相当于没有使用多线程,代码一行一行执行。 - 如果 t1 synchronized(obj1) 而 t2 synchronized(obj2) 会怎样运作?-- 锁对象
答:有线程安全问题,相当于并没有锁。 - 如果 t1 synchronized(obj) 而 t2 没有加会怎么样?如何理解?-- 锁对象
答: 有线程安全问题,相当于并没有锁。
synchronized 的位置
class Test{
public synchronized void test() {
}
}
等价于
class Test{
public void test() {
synchronized(this) {
}
}
}
class Test{
public synchronized static void test() {
}
}
等价于
class Test{
public static void test() {
synchronized(Test.class) {
}
}
}
问题:synchronized锁的到底是什么?
参考这个回答,自认为还是不错的
https://blog.csdn.net/YangYF1997/article/details/117164944?spm=1001.2101.3001.6650.9&utm_medium=distribute.pc_relevant.none-task-blog-2%7Edefault%7EBlogCommendFromBaidu%7ERate-9-117164944-blog-122815348.235%5Ev43%5Epc_blog_bottom_relevance_base9&depth_1-utm_source=distribute.pc_relevant.none-task-blog-2%7Edefault%7EBlogCommendFromBaidu%7ERate-9-117164944-blog-122815348.235%5Ev43%5Epc_blog_bottom_relevance_base9&utm_relevant_index=16
synchronized锁住同一个对象,线程才会互斥阻塞,才会线程安全。
线程八锁 练习
其实就是考察 synchronized 锁住的是哪个对象
- 当synchronized修饰一个static方法时,多线程下,获取的是类锁(即Class本身,注意:不是实例),
作用范围是整个静态方法,作用的对象是这个类的所有对象。 - 当synchronized修饰一个非static方法时,多线程下,获取的是对象锁(即类的实例对象),
作用范围是整个方法,作用对象是调用该方法的对象
----------------结论: 类锁和对象锁不同,它们之间不会产生互斥
一、
//结果是 先1后2 或者先2后1
@Slf4j
public class Test1 {
public static void main(String[] args) {
Number n1 = new Number();
new Thread(() -> {
//n1对于a来讲就是this
n1.a();
}, "t1").start();
new Thread(() -> {
//n1对于b来讲也是this 和 n1.a()中n1一样都是this
n1.b();
}, "t2").start();
}
}
@Slf4j
class Number {
/**
* public synchronized void a() {} 相当于 synchronized (this) {},所以其锁住的是number实例对象本身
*/
public synchronized void a() {
log.debug("1");
}
public synchronized void b() {
log.debug("2");
}
}
二、
/**
* 程序执行 1s后先打印1后打印2,或者程序先打印2,1s后打印1
* @author Spider Man
* @date 2024-05-10 15:50
*/
public class Test2 {
public static void main(String[] args) {
Number2 n2 = new Number2();
new Thread(() -> {
n2.a();
}, "t1").start();
new Thread(() -> {
n2.b();
}, "t2").start();
}
}
@Slf4j
class Number2 {
public synchronized void a() {
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
log.debug("1");
}
public synchronized void b() {
log.debug("2");
}
}
三、
/**
* 首先,该案例启用了3个线程,但是线程 t3 没有上锁,t1和t2共用一把锁,所以可以把其看成两个梯队,第一梯队两个线程,第二梯队一个线程,
* 而且t3总是在第一梯队中,要么在第一梯队的第一个,要么在第一梯队在第二个
* 所以其运行情况可分为三种:
* t3 t2 1s后t1 t2 t3 1s后t1 t3 1s后t1 t2
* 这个案例主要在于 c() 方法没有上锁,所以不用排队总是会被第一次调用,
* @author Spider Man
* @date 2024-05-10 16:00
*/
public class Test3 {
public static void main(String[] args) {
Number3 n3 = new Number3();
new Thread(() -> {
n3.a();
}, "t1").start();
new Thread(() -> {
n3.b();
}, "t2").start();
new Thread(() -> {
n3.c();
}, "t3").start();
}
}
@Slf4j
class Number3 {
public synchronized void a() {
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
log.debug("1");
}
public synchronized void b() {
log.debug("2");
}
public void c(){
log.debug("3");
}
}
四、
/**
* synchronized 是由不同的number对象加锁,所以两个线程不会阻塞互斥
* 永远都是 t2 1s后t1
* @author Spider Man
* @date 2024-05-10 16:18
*/
@Slf4j
public class Test4 {
public static void main(String[] args) {
Number4 n1 = new Number4();
new Thread(() -> {
n1.a();
}, "t1").start();
Number4 n2 = new Number4();
new Thread(() -> {
n2.b();
}, "t2").start();
}
}
@Slf4j
class Number4 {
public synchronized void a() {
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
log.debug("1");
}
public synchronized void b() {
log.debug("2");
}
}
五、
/**
* 因为a()方法由static,所以其锁住的是类对象, b()方法是非static方法,锁住的是实例对象
* 所以两个线程不是同一个锁,所以都是单独运行,但因为t1会先睡1s,所以视觉上总是t2先打印之后打印t1
* @author Spider Man
* @date 2024-05-10 17:14
*/
public class Test5 {
public static void main(String[] args) {
Number5 n1 = new Number5();
new Thread(() -> {
n1.a();
}, "t1").start();
new Thread(() -> {
n1.b();
}, "t2").start();
}
}
@Slf4j
class Number5 {
public static synchronized void a() {
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
log.debug("1");
}
public synchronized void b() {
log.debug("2");
}
}
六、
/**
* 对类对象加锁,类对象整个内存只有一份,所以其用的锁是同一个对象
* 所以其结果是 1s后 t1 t2 或者 t2 1s后t1
* @author Spider Man
* @date 2024-05-10 17:14
*/
public class Test6 {
public static void main(String[] args) {
Number6 n1 = new Number6();
new Thread(() -> {
n1.a();
}, "t1").start();
new Thread(() -> {
n1.b();
}, "t2").start();
}
}
@Slf4j
class Number6 {
// 对类对象加锁,类对象整个内存只有一份,所以其用的锁是同一个对象
public static synchronized void a() {
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
log.debug("1");
}
public static synchronized void b() {
log.debug("2");
}
}
七、
/**
* 两个线程不是同一个锁,所以都是单独运行,但因为t1会先睡1s,所以视觉上总是t2先打印之后打印t1
* @author Spider Man
* @date 2024-05-10 17:14
*/
public class Test7 {
public static void main(String[] args) {
Number7 n1 = new Number7();
Number7 n2 = new Number7();
new Thread(() -> {
n1.a();
}, "t1").start();
new Thread(() -> {
n2.b();
}, "t2").start();
}
}
@Slf4j
class Number7 {
public static synchronized void a() {
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
log.debug("1");
}
public synchronized void b() {
log.debug("2");
}
}
八、
/**
* 其结果是 1s后 t1 t2 或者 t2 1s后t1
* @author Spider Man
* @date 2024-05-10 17:14
*/
public class Test8 {
public static void main(String[] args) {
Number8 n1 = new Number8();
Number8 n2 = new Number8();
new Thread(() -> {
n1.a();
}, "t1").start();
new Thread(() -> {
n2.b();
}, "t2").start();
}
}
@Slf4j
class Number8 {
public static synchronized void a() {
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
log.debug("1");
}
public static synchronized void b() {
log.debug("2");
}
}
线程安全分析
成员变量和静态变量是否线程安全?
-
成员变量
有可能会发生线程安全。如果只有读操作,则线程安全,如果有读还有写,(临界区),就是线程不安全的。 -
局部变量
局部变量是线程安全的,因为它是线程私有的,不满足共享条件。原理是,每次方法调用对应着一个栈帧的创建,局部变量保存在栈帧的局部变量表中,而栈是线程私有的。
但,局部变量引用的对象则不一定:
解释一:
如果该对象没有跨越方法的作用范围,那么它是线程安全的
如果该对象跨越了方法的作用范围,它就不是线程安全的。
(
解释二:
如果该对象没有逃离方法的作用访问,它是线程安全的
如果该对象逃离(return)方法的作用范围,需要考虑线程安全
)
解释三:
有一种情况就是子类继承父类,重写父类中的方法,在重写的方法中开一个线程去操作共享变量,这样就会有线程安全问题这个时候就要通过 private、final 这些修饰符去限制了
视频举例:https://www.bilibili.com/video/BV16J411h7Rd?p=66&vd_source=4085910f7c5c4dddcc04446ebf3aed6b
常见线程安全类
- String
- Integer
- StringBuffer
- Random
- Vector
- Hashtable
- java.util.concurrent 包下的类
这里说它们是线程安全的是指,多个线程调用它们同一个实例的某个方法时,是线程安全的。
线程安全类方法的组合
比如:这就是线程安全的,两个线程调用同一个实例table的同一个方法put。
Hashtable table = new Hashtable();
new Thread(()->{
table.put("key", "value1");
}).start();
new Thread(()->{
table.put("key", "value2");
}).start();
也就是说他们每个方法是原子的,被sychronized修饰,但多个方法的组合不是原子的,会发生线程安全问题。
比如:下面代码中hashtable的get方法和put方法虽然都是线程安全的,但是其是单个方法的原子性的线程安全,也就是说其源码中sychronized只单独修饰了put或get方法,但现在示例中两个线程的都用到了get和put,所以第一个线程get判断时,cpu很可能会把时间片分给了第二个线程,直到第二个线程运行完才把时间片分给第一个线程去执行最后的put方法。
public class HashTableTest2 {
public static void main(String[] args) throws InterruptedException {
Hashtable<String, String> hashtable = new Hashtable<>();
Thread t1 = new Thread(() -> {
if (hashtable.get("1")==null){
hashtable.put("1", "1");
}
});
Thread t2 = new Thread(() -> {
if (hashtable.get("1")==null){
hashtable.put("1", "2");
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(hashtable);
}
}
不可变类线程安全性
String、Integer 等都是不可变类,因为其内部的状态不可以改变,因此它们的方法都是线程安全的
有同学或许有疑问,String 有 replace,substring 等方法【可以】改变值啊,那么这些方法又是如何保证线程安全的呢?
因为String 的 replace和substring 最终都是重新创建了个string 对象(new String)
线程安全—示例分析
前提知识:servlet在tomcat中只有一个实例
例1、
public class MyServlet extends HttpServlet {
// 是否安全? ---不安全
Map<String,Object> map = new HashMap<>();
// 是否安全? --安全
String S1 = "...";
// 是否安全? --安全
final String S2 = "...";
// 是否安全? --不安全
Date D1 = new Date();
// 是否安全? --不安全
final Date D2 = new Date();
public void doGet(HttpServletRequest request, HttpServletResponse response) {
// 使用上述变量
}
}
例2、
线程不安全,因为UserServiceImpl中成员变量count值被改变。
public class MyServlet extends HttpServlet {
// 是否安全?
private UserService userService = new UserServiceImpl();
public void doGet(HttpServletRequest request, HttpServletResponse response) {
userService.update(...);
}
}
public class UserServiceImpl implements UserService {
// 记录调用次数
private int count = 0;
public void update() {
// ...
count++;
}
}
例3、
前提知识:
Spring默认使用单例模式管理Bean,简单来说就是在IoC容器中,默认情况下一个类只会存在一个它的实例,即对应的每个(标注了@Component等注解的)类只会被实例化一次。
答案:线程不安全,spring 中的对象默认是单例的,会被共享,所以针对MyAspect类中的start成员变量也是会被共享的,又因为before和after方法都对start作了写的操作,所以其是线程不安全的。
@Aspect
@Component
public class MyAspect {
// 是否安全?
private long start = 0L;
@Before("execution(* *(..))")
public void before() {
//前置通知 记录时间
start = System.nanoTime();
}
@After("execution(* *(..))")
public void after() {
//后置通知 记录时间
long end = System.nanoTime();
//求差值
System.out.println("cost time:" + (end-start));
}
}
例4、线程安全 三层结构中的典型调用
public class MyServlet extends HttpServlet {
// 是否安全 --安全 UserServiceImpl 中 UserDao 成员变量没有被执行写的操作
private UserService userService = new UserServiceImpl();
public void doGet(HttpServletRequest request, HttpServletResponse response) {
userService.update(...);
}
}
public class UserServiceImpl implements UserService {
// 是否安全 --安全 UserDaoImpl中没有成员变量
private UserDao userDao = new UserDaoImpl();
public void update() {
userDao.update();
}
}
public class UserDaoImpl implements UserDao {
public void update() {
String sql = "update user set password = ? where username = ?";
// 是否安全 --安全 因为其是局部变量,局部变量每个线程都会存一份在栈帧中
try (Connection conn = DriverManager.getConnection("","","")){
// ...
} catch (Exception e) {
// ...
}
}
}
例5、 线程不安全,根例4的区别在于userDao中Connection 在成员变量中
public class MyServlet extends HttpServlet {
// 是否安全 --不安全
private UserService userService = new UserServiceImpl();
public void doGet(HttpServletRequest request, HttpServletResponse response) {
userService.update(...);
}
}
public class UserServiceImpl implements UserService {
// 是否安全 --不安全
private UserDao userDao = new UserDaoImpl();
public void update() {
userDao.update();
}
}
public class UserDaoImpl implements UserDao {
// 是否安全 不安全
private Connection conn = null;
public void update() throws SQLException {
String sql = "update user set password = ? where username = ?";
conn = DriverManager.getConnection("","","");
// ...
conn.close();
}
}
例6
线程安全,虽然UserDaoImpl 中 Connection 是成员变量,但因为UserServiceImpl 中的update方法每次调用都会创建一个新的UserDaoImpl对象,其是局部变量,所以是线程安全的
public class MyServlet extends HttpServlet {
// 是否安全
private UserService userService = new UserServiceImpl();
public void doGet(HttpServletRequest request, HttpServletResponse response) {
userService.update(...);
}
}
public class UserServiceImpl implements UserService {
public void update() {
UserDao userDao = new UserDaoImpl(); ------关键点
userDao.update();
}
}
public class UserDaoImpl implements UserDao {
// 是否安全
private Connection = null;
public void update() throws SQLException {
String sql = "update user set password = ? where username = ?";
conn = DriverManager.getConnection("","","");
// ...
conn.close();
}
}
例7
线程不安全,其中 foo 的行为是不确定的(比如若有个类继承Test并重写其foo方法,在重写的方法中又新启了个线程对sdf进行操作,虽然SimpleDateFormat 是局部变量,但是其引用逃逸,泄露),可能导致不安全的发生,被称之为外星方法
public abstract class Test {
public void bar() {
// 是否安全
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
foo(sdf);
}
public abstract foo(SimpleDateFormat sdf);
public static void main(String[] args) {
new Test().bar();
}
}
public void foo(SimpleDateFormat sdf) {
String dateStr = "1999-10-11 00:00:00";
for (int i = 0; i < 20; i++) {
new Thread(() -> {
try {
sdf.parse(dateStr);
} catch (ParseException e) {
e.printStackTrace();
}
}).start();
}
}
例8、
虽然使用了 synchronized 关键字来保护对共享变量 i 的访问,但是这里对共享变量 i 的同步锁锁定的是 Integer 对象,而不是 i 的实际值。由于 Integer 是不可变对象,每次对 i 进行自增操作时,实际上是创建了一个新的 Integer 对象,因此每个线程获得的锁都是不同的对象,无法保证线程安全
@Slf4j
public class Test4 {
private static Integer i = 0;
public static void main(String[] args) {
List<Thread> list = new ArrayList<>();
for (int j = 0; j < 2; j++) {
Thread thread = new Thread(() -> {
for (int k = 0; k < 5000; k++) {
synchronized (i) {
i++;
}
}
}, "" + j);
list.add(thread);
}
list.stream().forEach(t -> t.start());
list.stream().forEach(t -> {
try {
t.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
});
log.debug("{}",i);
}
}