完整版代码
多线程并发问题
多线程的并发问题主要出现在当一个程序涉及多个线程同时运行时,这些线程可能会同时访问共享资源(如数据、内存、文件等)。由于多个线程之间的竞争和冲突,这可能导致程序出现不稳定、不可预测的行为。
上次我们对服务端存储的客户端的数据集合进行操作,真实情况 服务端可能面临多频繁的客户传递数据,这次我们需要考虑多线程的问题
synchronized的引用
synchronized
是一个关键字,用于控制多个线程对共享资源的访问,以避免并发问题。
1.方法上的引入
修饰实例方法:
当一个方法被synchronized
修饰时,该方法就被称为同步方法。这意味着一次只能有一个线程可以进入该方法。当某个线程正在执行同步方法时,其他任何线程都无法访问该方法,直到当前线程执行完该方法并释放锁。
public synchronized void synchronizedMethod() { // 线程安全的代码 }
修饰静态方法:
当synchronized
修饰一个静态方法时,它锁定的是该类的Class对象。这意味着所有访问这个静态同步方法的线程都需要获得这个类的Class对象的锁,这样在一个时间点只能有一个线程能够执行这个静态方法。
public static synchronized void staticSynchronizedMethod() {
// 线程安全的代码
}
2.代码块的引入
修饰代码块:synchronized
也可以用来修饰一个代码块,允许更细粒度的控制。这允许开发者指定哪些特定的代码段需要同步,而不是整个方法。在这种情况下,开发者需要提供一个对象作为锁。
this 是 调用改方法的对象类
public void someMethod() {
synchronized(this) {
// 线程安全的代码
}
}
总结 : synchronized修饰方法时候整个方法相当于带了一把锁,而修饰代码块的时候就整个代码快就上了一把锁,而且修饰代码快,可以根据具体义务灵活使用,且可以根据争抢的资源进行上锁避免了并发问题出现.
这边举个例子 多个线程调用同一个类的同一个方法
这边同步锁synchronized修饰的代码块锁的是这个boo对象类,这边可以在改为sychronized(this) 注意的是sychronized(boo.class)中的boo.class获取对象的类涉及到反射的概念
public class SyncDemo3 {
public static void main(String[] args) {
// Boo b1 = new Boo();
// Boo b2 = new Boo();
// Thread t1 = new Thread(()->b1.doSome());
// Thread t2 = new Thread(()->b2.doSome());
Thread t1 = new Thread(()->Boo.doSome());
Thread t2 = new Thread(()->Boo.doSome());
t1.start();
t2.start();
}
}
class Boo{
// public synchronized static void doSome(){
public static void doSome(){
//显示的获取类对象可以用:类名.class获取到
synchronized (Boo.class) {//静态方法中指定同步监视器对象通常就用当前类的类对象
try {
Thread t = Thread.currentThread();
System.out.println(t.getName() + ":正在执行doSome方法...");
Thread.sleep(5000);
System.out.println(t.getName() + ":执行doSome方法完毕!");
} catch (InterruptedException e) {
}
}
}
}
回归正题
服务端
因为我们是服务端收集客户端的数据集合是对集合进行删减,所以我们使用同步锁同步块
//这边是锁添加集合元素
synchronized (allOut) {
allOut.add(pw);
}
//这边是锁删除集合元素
synchronized (allOut) {
allOut.remove(pw);
}
同于遍历集合同时,我们服务端的集合可能在添加集合元素也可能在删除集合元素,我们需要加一把互斥锁
使用synchronized关键字实现互斥锁
synchronized是Java内置的语言特性,它提供了隐式的互斥锁。当线程进入
synchronized
方法或代码块时,它会自动获取对象的内置锁;当线程退出synchronized
方法或代码块时,它会自动释放锁。
这边定义一个同步方法,我们知道当两个方法上锁的锁的是对象类,当我们新建一个实例化类的时候,
在调用一个方法时候其对象类会上锁,以保证另一个方法不会被调用
public class Counter {
private int count = 0;
public synchronized void increment() {
count++;
}
public synchronized int getCount() {
return count;
}
}
接下来我们解决如何私聊的问题,我们知道在进入聊天室之前会输入昵称
这边只有服务端根据昵称选择对应的客户端进行发送对应的信息即可,
这边先私有一个方法
这边我们使用一个正则表达式,根据 @符判断其属于私聊,
截取信息方面 @nickname: message 可以根据":"进行拆分,这边因为只有一个":"我们使用的substring进行对信息进行拆分 indexof 用于查询对应的元素的返回其所用索引值
思路 第一次if判断用户输出是否符合私聊的正则表达式,第二次if是判断用户是否存在
private void sendMessageToSomeOne(String message){
/*
张三->李四 发送一个私聊
message:
@李四:在吗?
@对方昵称:聊天消息
@.+:.+
tips:
.在正则表达式里表示任意一个字符
+是一个量词,表示前面的内容出现1次以上
所以".+"表示1次以上的任意字符
*/
//进行私聊格式验证
if(message.matches("@.+:.+")){
//根据聊天消息,截取出对方的昵称 @abc:你好
String toNickname = message.substring(1, message.indexOf(":"));
if(allOut.contains(toNickname)) {//对方昵称在allOut中是否存在
//获取对方的输出流
PrintWriter pw = allOut.get(toNickname);
//张三悄悄对你说:在吗?
String content = message.substring(message.indexOf(":") + 1);
pw.println(nickname+"悄悄对你说:"+content);
}else{
PrintWriter pw = allOut.get(nickname);
//用户[abc]不存在
pw.println("用户["+toNickname+"]不存在");
}
}else{//格式不对,则同时该客户端格式不对
PrintWriter pw = allOut.get(nickname);
pw.println("私聊格式不对,应当是@对方昵称:聊天消息");
}
}
调用私聊的方法 因为我们服务端接受客户端发送的消息和私聊对应的用户名进行判断
while ((message = br.readLine()) != null) {
//聊天信息以"@"开始,应当是私聊
if(message.startsWith("@")){
sendMessageToSomeOne(message);
}else {
sendMessage(nickname + "[" + ip + "]说:" + message);
}
问题,私聊的信息有点乱,我们集合是根据用户的输入的数据存储,并没有将昵称和输入到服务端的值绑定
解决 使用MAP 二维集合利用键值对将其昵称和数据存储
这边利用map添加元素put(index,element)方法 ,
remove(key) 注意这边移除是键值对,c
ontainskey()判断包含元素,
values()获取集合中的值
服务端
// private Collection<PrintWriter> allOut = new ArrayList<>();
private Map<String,PrintWriter> allOut = new HashMap<>();
修改点 集合 添加元素
synchronized (allOut) { // allOut.add(pw); allOut.put(nickname,pw); }
修改点 集合移除文件输出流变为移除对应的昵称
synchronized (allOut) { // allOut.remove(pw); allOut.remove(nickname); }
修改点 遍历集合 改为对应values遍历
synchronized (allOut) { // for (PrintWriter o : allOut) {//发送给所有客户端 for(PrintWriter o : allOut.values()){ o.println(message); } }
修改点 私聊是否包含该用户时候变为containkey
if(allOut.contains(toNickname)) -> if if(allOut.containskey(toNickname))
这边是全部代码
客户端
package socket;
import java.io.*;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
import java.util.Scanner;
/**
* 聊天室客户端
*/
public class Client {
/*
java.net.Socket 套接字
Socket封装了TCP协议的通讯细节,使用它可以和远端计算机建立网络链接,并基于
两条流(一条输入,一条输出)的读写与对方进行数据交换。
*/
private Socket socket;
/**
* 构造器,用于初始化客户端
*/
public Client(){
try {
System.out.println("正在链接服务端...");
/*
Socket实例化时就是与服务端建立链接的过程,此时需要传入两个
参数
参数1:服务端的IP地址,用于找到服务端的计算机
参数2:服务端口,用于找到服务端程序
如何查看IP地址:
windows:窗口键+R 打开控制台
输入ipconfig
查看以太网适配器-以太网,找到ipv4查看自己的IP地址
mac:打开[终端]程序
输入/sbin/ifconfig查看自己的IP地址
*/
// socket = new Socket("127.0.0.1",8088);//127.0.0.1和localhost都是表示本机
socket = new Socket("localhost",8088);
System.out.println("与服务端成功链接!");
} catch (IOException e) {
e.printStackTrace();
}
}
/**
* 用于客户端开始工作的方法
*/
public void start(){
/*
Socket提供的方法:
OutputStream getOutputStream()
通过Socket获取一个字节输出流,通过向该流写出字节,就可以发送给远端链接
的计算机的Socket了
*/
try {
OutputStream out = socket.getOutputStream();
OutputStreamWriter osw = new OutputStreamWriter(out, StandardCharsets.UTF_8);
BufferedWriter bw = new BufferedWriter(osw);
PrintWriter pw = new PrintWriter(bw, true);
/*
write(int d)通过流向目标位置写出1个字节,写出的是int值2进制的"低八位"
*/
// out.write(1);//00000001
Scanner scanner = new Scanner(System.in);
//首先要求用户输入一个昵称
String nickname = "";
while(true) {
System.out.println("请输入昵称:");
nickname = scanner.nextLine();
if(nickname.trim().length() > 0){
pw.println(nickname);//将昵称发送给服务端
break;
}
System.out.println("昵称不能为空");
}
//将接收服务端发送过来消息的线程启动
ServerHandler handler = new ServerHandler();
Thread t = new Thread(handler);
t.setDaemon(true);
t.start();
System.out.println("开始聊天吧");
while(true) {
String line = scanner.nextLine();
if("exit".equalsIgnoreCase(line)){
break;
}
pw.println(line);
}
} catch (IOException e) {
e.printStackTrace();
} finally {
try {
//socket的close方法会进行四次挥手
//并且也会关闭通过socket获取的输入流和输出流
socket.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
public static void main(String[] args) {
//实际开发中不会在main方法中写业务逻辑,main方法是静态方法会有很多不便
Client client = new Client();//调用构造器初始化客户端
client.start();//调用start方法使客户端开始工作
}
private class ServerHandler implements Runnable{
@Override
public void run() {
try {
//通过socket获取输入流,用于读取服务端发送过来的消息
InputStream in = socket.getInputStream();
InputStreamReader isr = new InputStreamReader(in, StandardCharsets.UTF_8);
BufferedReader br = new BufferedReader(isr);
String line;
while((line = br.readLine())!=null){
System.out.println(line);
}
} catch (IOException e) {
}
}
}
}
服务端
package socket;
import java.io.*;
import java.io.OutputStreamWriter;
import java.net.ServerSocket;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 聊天室服务端
*/
public class Server {
/*
java.net.ServerSocket
运行在服务端的ServerSocket相当于时客户中心的"总机",上面有若干的插座(Socket)
客户端的插座就是与总机建立链接,然后总机这边分配一个插座与之建立链接,来保持双方
通讯的。
ServerSocket有两个主要工作
1:创建时向系统申请服务端口,以便客户端可以通过端口找到
2:监听该端口,一旦一个客户端链接,便创建一个Socket,通过它与客户端通讯
*/
private ServerSocket serverSocket;
//存放所有客户端的输出流,用于广播消息
// private List<PrintWriter> allOut = new ArrayList<>();
/*
存放所有客户端的输出流Map
key:该客户端的昵称
value:对应该客户端的输出流,用于给开客户端发送消息
*/
private Map<String,PrintWriter> allOut = new HashMap<>();
public Server(){
try {
System.out.println("正在启动服务端");
/*
实例化ServerSocket时需要指定向系统申请的服务端口,如果该端口
已经被系统的其他应用程序占据,则这里会抛出异常
java.net.BindException: Address already in use: bind()
*/
serverSocket = new ServerSocket(8088);
System.out.println("服务端启动完毕");
} catch (IOException e) {
throw new RuntimeException(e);
}
}
public void start(){
try {
while(true) {
System.out.println("等待客户端链接...");
/*
ServerSocket的重要方法:
Socket accept()
该方法是一个阻塞方法,调用该方法后程序会"卡住",直到一个客户端使用
Socket与服务端建立链接为止,此时accept方法会立即返回一个Socket
通过返回的Socket就可以与链接的客户端双向通讯了。
*/
Socket socket = serverSocket.accept();
System.out.println("一个客户端链接了!");
//实例化线程任务
ClientHandler handler = new ClientHandler(socket);
//实例化线程
Thread t = new Thread(handler);
//启动线程
t.start();
}
} catch (IOException e) {
e.printStackTrace();
}
}
public static void main(String[] args) {
Server server = new Server();
server.start();
}
private class ClientHandler implements Runnable{
private Socket socket;
private String ip;//记录当前客户端的IP地址
private String nickname;//记录当前客户端的昵称
public ClientHandler(Socket socket){
this.socket = socket;
//通过socket获取远端计算机的IP地址
ip = socket.getInetAddress().getHostAddress();
}
public void run(){
PrintWriter pw = null;
try {
/*
Socket的方法:
InputStream getInputStream()
通过socket获取一个字节输入流,读取该流就可以读取到远端计算机发送过来的数据
*/
InputStream in = socket.getInputStream();
InputStreamReader isr = new InputStreamReader(in, StandardCharsets.UTF_8);
BufferedReader br = new BufferedReader(isr);
//通过socket获取输出流并流链接为PrintWriter,为了给客户端发送消息
OutputStream out = socket.getOutputStream();
OutputStreamWriter osw = new OutputStreamWriter(out, StandardCharsets.UTF_8);
BufferedWriter bw = new BufferedWriter(osw);
pw = new PrintWriter(bw, true);
//首先单独读取一行字符串,客户端发送过来的第一行字符串应当是昵称
nickname = br.readLine();
//将该客户端的输出流存入共享集合中
/*
每个客户端链接后,主线程都会实例化一个ClientHandler(线程任务)
然后实例化一个线程来执行这个任务.当线程获取时间片后开始执行任务的run方法
因此,每个线程执行到下面要操作集合的这里,需要让多个线程不能同时操作这个集合
那么同步监视器必须让多个线程看到的是同一个对象.
这里不能使用this,因为线程在执行ClientHandler的run方法,因此run方法中
的这个this是一个ClientHandler的实例,每个线程都在执行各自ClientHandler
任务的run方法,因此他们看到的ClientHandler并非同一个对象
*/
// synchronized (this) {
//由于他们都要操作allOut集合,因此将它作为同步监视器对象是合适的
//实际开发中我们总是使用临界资源作为同步监视器对象,即:抢谁就锁谁
synchronized (allOut) {
// allOut.add(pw);
allOut.put(nickname,pw);
}
sendMessage(nickname+"上线了,当前在线人数:"+allOut.size());
String message;
while ((message = br.readLine()) != null) {
//聊天信息以"@"开始,应当是私聊
if(message.startsWith("@")){
sendMessageToSomeOne(message);
}else {
sendMessage(nickname + "[" + ip + "]说:" + message);
}
}
} catch (IOException e) {
//可以添加处理客户端异常断开的操作
} finally {
//处理客户端断开链接后的操作
//将该客户端的输出流从共享集合allOut中删除
synchronized (allOut) {
// allOut.remove(pw);
allOut.remove(nickname);
}
sendMessage(nickname+"下线了,当前在线人数:"+allOut.size());
//将socket关闭,释放底层资源
try {
socket.close();
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}
/**
* 将消息发送给所有客户端
*/
public void sendMessage(String message){
System.out.println(message);//先在服务端控制台上输出一下
//遍历要和增删互斥,迭代器要求遍历过程中不可以通过集合方法增删元素
synchronized (allOut) {
// for (PrintWriter o : allOut) {//发送给所有客户端
for(PrintWriter o : allOut.values()){
o.println(message);
}
}
}
/**
* 将消息发送给指定用户(私聊)
* @param message 格式:@对方昵称:聊天消息
*/
private void sendMessageToSomeOne(String message){
/*
张三->李四 发送一个私聊
message:
@克晶:在吗?
@对方昵称:聊天消息
@.+:.+
tips:
.在正则表达式里表示任意一个字符
+是一个量词,表示前面的内容出现1次以上
所以".+"表示1次以上的任意字符
*/
//进行私聊格式验证
if(message.matches("@.+:.+")){
//根据聊天消息,截取出对方的昵称 @abc:你好
String toNickname = message.substring(1, message.indexOf(":"));
if(allOut.containsKey(toNickname)) {//对方昵称在allOut中是否存在
//获取对方的输出流
PrintWriter pw = allOut.get(toNickname);
//张三悄悄对你说:在吗?
String content = message.substring(message.indexOf(":") + 1);
pw.println(nickname+"悄悄对你说:"+content);
}else{
PrintWriter pw = allOut.get(nickname);
//用户[abc]不存在
pw.println("用户["+toNickname+"]不存在");
}
}else{//格式不对,则同时该客户端格式不对
PrintWriter pw = allOut.get(nickname);
pw.println("私聊格式不对,应当是@对方昵称:聊天消息");
}
}
}
}