I figured out how to solve my problem using only Jersey. There's apparently no way to match a request's URI to the method that will be matched before that method is invoked, at least in Jersey 1.x. However, I was able to use a ResourceFilterFactory to create a ResourceFilter for each individual resource method - that way these filters can know about the destination method ahead of time.
Here's my solution, including the validation for required query params (uses Guava and JSR 305):
public final class ValidationFilterFactory implements ResourceFilterFactory {
    @Override
    public List<ResourceFilter> create(AbstractMethod abstractMethod) {
        //keep track of required query param names
        final ImmutableSet.Builder<String> requiredQueryParamsBuilder =
                ImmutableSet.builder();
        //get the list of params from the resource method
        final ImmutableList<Parameter> params =
                Invokable.from(abstractMethod.getMethod()).getParameters();
        for (Parameter param : params) {
            //if the param isn't marked as @Nullable,
            if (!param.isAnnotationPresent(Nullable.class)) {
                //try getting the @QueryParam value
                @Nullable final QueryParam queryParam =
                        param.getAnnotation(QueryParam.class);
                //if it's present, add its value to the set
                if (queryParam != null) {
                    requiredQueryParamsBuilder.add(queryParam.value());
                }
            }
        }
        //return the new validation filter for this resource method
        return Collections.<ResourceFilter>singletonList(
                new ValidationFilter(requiredQueryParamsBuilder.build())
        );
    }
    private static final class ValidationFilter implements ResourceFilter {
        final ImmutableSet<String> requiredQueryParams;
        private ValidationFilter(ImmutableSet<String> requiredQueryParams) {
            this.requiredQueryParams = requiredQueryParams;
        }
        @Override
        public ContainerRequestFilter getRequestFilter() {
            return new ContainerRequestFilter() {
                @Override
                public ContainerRequest filter(ContainerRequest request) {
                    final Collection<String> missingRequiredParams =
                            Sets.difference(
                                    requiredQueryParams,
                                    request.getQueryParameters().keySet()
                            );
                    if (!missingRequiredParams.isEmpty()) {
                        final String message =
                                "Required query params missing: " +
                                Joiner.on(", ").join(missingRequiredParams);
                        final Response response = Response
                                .status(Status.BAD_REQUEST)
                                .entity(message)
                                .build();
                        throw new WebApplicationException(response);
                    }
                    return request;
                }
            };
        }
        @Override
        public ContainerResponseFilter getResponseFilter() {
            return null;
        }
    }
}
And the ResourceFilterFactory is registered with Jersey as an init param of the servlet in web.xml:
<init-param>
    <param-name>com.sun.jersey.spi.container.ResourceFilters</param-name>
    <param-value>my.package.name.ValidationFilterFactory</param-value>
</init-param>
At startup, ValidationFilterFactory.create gets called for each resource method detected by Jersey.
Credit goes to this post for getting me on the right track: How can I get resource annotations in a Jersey ContainerResponseFilter